insta compute ssh: one-time setup, then plain ssh - #215
Conversation
`--setup` does the one-time client work: a dedicated key under ~/.insta/ssh (the user's existing keys are never touched), a short-lived certificate signed by the platform, one @cert-authority line in known_hosts, and an ssh_config block. `--ensure-cert` is the renewal hook the block installs. After that it is plain ssh, scp and -L. The config block goes AT THE TOP, and that is the whole reason the editing lives in pure functions that can be tested. OpenSSH takes the FIRST obtained value for each keyword, and ssh_config(5) says host-specific declarations belong near the beginning. Appended at the end, our block loses every keyword to an earlier `Host *` -- silently, with no error, producing a connection that ignores the IdentityFile we just wrote. It carries IdentitiesOnly for a failure that is worse than it looks: SSH offers public keys ONE AT A TIME, so a developer with a full ssh-agent is identified non-deterministically and the server may never see the key holding the certificate. exe.dev calls it heisen-connect, and the symptom is intermittent auth failures with no pattern. ControlMaster is there because without it scp, an IDE's several connections and a second terminal each burn a session, and one developer reaches the per-service cap in an afternoon. known_hosts is APPENDED rather than inserted, deliberately the opposite choice: it has no first-wins rule, so position carries no meaning there. Its idempotency keys on the CA KEY rather than the whole line, so a re-run with a changed host pattern updates instead of leaving two anchors. The trust anchor widens only the region label -- ssh.*.compute.example, not *.compute.example -- because the broader pattern would make this CA authoritative for every tenant's service hostname too. Renewal treats every uncertain case as "renew": no certificate, an unparsable one, a date that yields NaN. "Cannot confirm it is valid" and "it is valid" must not collapse, since the cost of an unnecessary renewal is one HTTPS call and the cost of the opposite is a login that fails with no explanation. The hook itself is silent and never fails the connection: a renewal that cannot run leaves the existing certificate in place and SSH reports its own error rather than ours. Six negative controls, all firing: appending the block instead of inserting it, dropping IdentitiesOnly, never replacing an existing block, and each of the three uncertain-renewal cases reading as valid. Full CLI suite green (813 tests). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
11 issues found across 4 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/commands/compute.ts">
<violation number="1" location="src/commands/compute.ts:811">
P2: When the certificate is still valid, the renewal hook still performs project discovery and a services API request before it can return. Check certificate validity before loading or resolving the project, and handle discovery failures inside the silent renewal path.</violation>
<violation number="2" location="src/commands/compute.ts:849">
P1: When setup returns a `ssh.<region>.compute.example` host, OpenSSH does not match the generated `Host *.insta` block because no `.insta` alias is created. Use the returned host pattern (for example `hostPatternFor(out.host)`) so plain `ssh` actually uses the certificate and renewal settings.</violation>
<violation number="3" location="src/commands/compute.ts:851">
P1: After `insta compute ssh <service> --setup` in a multi-service project, the renewal hook has no service identity and `%h` is ignored, so renewal throws on the multiple-service check. Preserve the selected service and branch in the hook, or resolve the host back to its service before renewing.</violation>
</file>
<file name="src/index.ts">
<violation number="1" location="src/index.ts:253">
P2: `insta compute ssh` does not open an interactive session despite this description; it only prints an `ssh` command and exits. Launch the SSH child process, or describe this command as setup/certificate issuance rather than an interactive session.</violation>
</file>
<file name="src/commands/ssh-config.ts">
<violation number="1" location="src/commands/ssh-config.ts:22">
P1: On Windows or when the home path contains spaces, this raw `IdentityFile` value is parsed incorrectly and SSH cannot load the generated key. Normalize backslashes and quote the path before writing it to `ssh_config`.</violation>
<violation number="2" location="src/commands/ssh-config.ts:42">
P2: When the generated block precedes global directives in `~/.ssh/config`, this `Match` remains active because the end marker is only a comment. Close the section with `Match all` before `BLOCK_END` so later user directives retain their intended scope.</violation>
<violation number="3" location="src/commands/ssh-config.ts:42">
P1: When a hostname contains shell metacharacters, OpenSSH expands `%h` before running this renewal command through the shell, enabling command injection. Do not interpolate `%h` into a shell command; use a fixed wrapper that validates the host or pass the value without shell evaluation.</violation>
<violation number="4" location="src/commands/ssh-config.ts:69">
P2: When an existing owned block is below an earlier `Host *`, this replacement keeps it at that offset and first-wins parsing still ignores its settings. Remove the old block and prepend the replacement so every upsert restores top placement.</violation>
<violation number="5" location="src/commands/ssh-config.ts:96">
P2: When another CA key contains this key as a substring, this filter deletes the other trust line. Compare the cert-authority key-type and base64 fields exactly instead of using `includes`.</violation>
</file>
<file name="test/ssh-config.test.ts">
<violation number="1" location="test/ssh-config.test.ts:22">
P3: The second assertion in the top-placement test does not test what its message claims. `out.indexOf('Host *')` resolves to the block's own `Host *.insta` line, not the user's `Host *`, so it always passes once line 21 asserts the block starts at index 0. Assert against the user's `Host *` specifically (e.g. `out.lastIndexOf('Host *')`) or drop the line, otherwise it gives false confidence that an earlier `Host *` cannot steal the keywords.</violation>
<violation number="2" location="test/ssh-config.test.ts:110">
P3: The renewal tests cover only the negative branches (missing file, unparsable file). The source's own highlighted trap is untested: a parsed-but-unparsable date producing NaN (compute.ts:783 'must mean RENEW, not valid forever') and the valid/unexpired path returning false. Without a test that a valid, in-margin cert returns false and an expired one returns true, a regression that flips those branches would not be caught.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| writeFileSync(cfg, upsertConfigBlock(existing, renderConfigBlock({ | ||
| hostPattern: '*.insta', | ||
| identityFile: instaKeyPath(), | ||
| ensureCertCommand: 'insta compute ssh --ensure-cert %h', |
There was a problem hiding this comment.
P1: After insta compute ssh <service> --setup in a multi-service project, the renewal hook has no service identity and %h is ignored, so renewal throws on the multiple-service check. Preserve the selected service and branch in the hook, or resolve the host back to its service before renewing.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/compute.ts, line 851:
<comment>After `insta compute ssh <service> --setup` in a multi-service project, the renewal hook has no service identity and `%h` is ignored, so renewal throws on the multiple-service check. Preserve the selected service and branch in the hook, or resolve the host back to its service before renewing.</comment>
<file context>
@@ -749,3 +749,119 @@ export async function computeLimits(serviceName: string | undefined, opts: Limit
+ writeFileSync(cfg, upsertConfigBlock(existing, renderConfigBlock({
+ hostPattern: '*.insta',
+ identityFile: instaKeyPath(),
+ ensureCertCommand: 'insta compute ssh --ensure-cert %h',
+ })), { mode: 0o600 })
+ }
</file context>
| const cfg = join(homedir(), '.ssh', 'config') | ||
| const existing = existsSync(cfg) ? readFileSync(cfg, 'utf8') : '' | ||
| writeFileSync(cfg, upsertConfigBlock(existing, renderConfigBlock({ | ||
| hostPattern: '*.insta', |
There was a problem hiding this comment.
P1: When setup returns a ssh.<region>.compute.example host, OpenSSH does not match the generated Host *.insta block because no .insta alias is created. Use the returned host pattern (for example hostPatternFor(out.host)) so plain ssh actually uses the certificate and renewal settings.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/compute.ts, line 849:
<comment>When setup returns a `ssh.<region>.compute.example` host, OpenSSH does not match the generated `Host *.insta` block because no `.insta` alias is created. Use the returned host pattern (for example `hostPatternFor(out.host)`) so plain `ssh` actually uses the certificate and renewal settings.</comment>
<file context>
@@ -749,3 +749,119 @@ export async function computeLimits(serviceName: string | undefined, opts: Limit
+ const cfg = join(homedir(), '.ssh', 'config')
+ const existing = existsSync(cfg) ? readFileSync(cfg, 'utf8') : ''
+ writeFileSync(cfg, upsertConfigBlock(existing, renderConfigBlock({
+ hostPattern: '*.insta',
+ identityFile: instaKeyPath(),
+ ensureCertCommand: 'insta compute ssh --ensure-cert %h',
</file context>
| hostPattern: '*.insta', | |
| hostPattern: hostPatternFor(out.host), |
| const lines = [ | ||
| BLOCK_BEGIN, | ||
| `Host ${o.hostPattern}`, | ||
| ` IdentityFile ${o.identityFile}`, |
There was a problem hiding this comment.
P1: On Windows or when the home path contains spaces, this raw IdentityFile value is parsed incorrectly and SSH cannot load the generated key. Normalize backslashes and quote the path before writing it to ssh_config.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/ssh-config.ts, line 22:
<comment>On Windows or when the home path contains spaces, this raw `IdentityFile` value is parsed incorrectly and SSH cannot load the generated key. Normalize backslashes and quote the path before writing it to `ssh_config`.</comment>
<file context>
@@ -0,0 +1,100 @@
+ const lines = [
+ BLOCK_BEGIN,
+ `Host ${o.hostPattern}`,
+ ` IdentityFile ${o.identityFile}`,
+ // IdentitiesOnly is not tidiness. SSH offers public keys ONE AT A TIME,
+ // so a user with several keys is identified non-deterministically -- the
</file context>
| ` IdentityFile ${o.identityFile}`, | |
| ` IdentityFile "${o.identityFile.replaceAll('\\', '/')}",` |
| // a certificate that expired since the last login is replaced silently | ||
| // rather than surfacing as a refused login. Without it, "after setup it is | ||
| // just ssh" stops being true the moment the first certificate expires. | ||
| lines.push(`Match host ${o.hostPattern} exec "${o.ensureCertCommand}"`) |
There was a problem hiding this comment.
P1: When a hostname contains shell metacharacters, OpenSSH expands %h before running this renewal command through the shell, enabling command injection. Do not interpolate %h into a shell command; use a fixed wrapper that validates the host or pass the value without shell evaluation.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/ssh-config.ts, line 42:
<comment>When a hostname contains shell metacharacters, OpenSSH expands `%h` before running this renewal command through the shell, enabling command injection. Do not interpolate `%h` into a shell command; use a fixed wrapper that validates the host or pass the value without shell evaluation.</comment>
<file context>
@@ -0,0 +1,100 @@
+ // a certificate that expired since the last login is replaced silently
+ // rather than surfacing as a refused login. Without it, "after setup it is
+ // just ssh" stops being true the moment the first certificate expires.
+ lines.push(`Match host ${o.hostPattern} exec "${o.ensureCertCommand}"`)
+ }
+ lines.push(BLOCK_END)
</file context>
| } | ||
|
|
||
| export async function computeSSH(serviceName: string | undefined, opts: SSHOpts): Promise<void> { | ||
| const api = await ApiClient.load() |
There was a problem hiding this comment.
P2: When the certificate is still valid, the renewal hook still performs project discovery and a services API request before it can return. Check certificate validity before loading or resolving the project, and handle discovery failures inside the silent renewal path.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/compute.ts, line 811:
<comment>When the certificate is still valid, the renewal hook still performs project discovery and a services API request before it can return. Check certificate validity before loading or resolving the project, and handle discovery failures inside the silent renewal path.</comment>
<file context>
@@ -749,3 +749,119 @@ export async function computeLimits(serviceName: string | undefined, opts: Limit
+}
+
+export async function computeSSH(serviceName: string | undefined, opts: SSHOpts): Promise<void> {
+ const api = await ApiClient.load()
+ const p = await requireProject()
+ const branch = opts.branch ?? p.branch
</file context>
| const key = caKey.trim() | ||
| const kept = existing | ||
| .split('\n') | ||
| .filter((l) => !(l.startsWith('@cert-authority') && l.includes(key))) |
There was a problem hiding this comment.
P2: When another CA key contains this key as a substring, this filter deletes the other trust line. Compare the cert-authority key-type and base64 fields exactly instead of using includes.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/ssh-config.ts, line 96:
<comment>When another CA key contains this key as a substring, this filter deletes the other trust line. Compare the cert-authority key-type and base64 fields exactly instead of using `includes`.</comment>
<file context>
@@ -0,0 +1,100 @@
+ const key = caKey.trim()
+ const kept = existing
+ .split('\n')
+ .filter((l) => !(l.startsWith('@cert-authority') && l.includes(key)))
+ .join('\n')
+ const base = kept === '' ? '' : kept.endsWith('\n') ? kept : kept + '\n'
</file context>
| // Swallow the newline that followed the end marker, so repeated runs do | ||
| // not accumulate blank lines. | ||
| const tail = existing.slice(after).replace(/^\n/, '') | ||
| return existing.slice(0, begin) + block + tail |
There was a problem hiding this comment.
P2: When an existing owned block is below an earlier Host *, this replacement keeps it at that offset and first-wins parsing still ignores its settings. Remove the old block and prepend the replacement so every upsert restores top placement.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/ssh-config.ts, line 69:
<comment>When an existing owned block is below an earlier `Host *`, this replacement keeps it at that offset and first-wins parsing still ignores its settings. Remove the old block and prepend the replacement so every upsert restores top placement.</comment>
<file context>
@@ -0,0 +1,100 @@
+ // Swallow the newline that followed the end marker, so repeated runs do
+ // not accumulate blank lines.
+ const tail = existing.slice(after).replace(/^\n/, '')
+ return existing.slice(0, begin) + block + tail
+ }
+ // A begin marker with no end is a file someone edited by hand. Leave it
</file context>
| return existing.slice(0, begin) + block + tail | |
| return block + existing.slice(0, begin) + tail |
| // a certificate that expired since the last login is replaced silently | ||
| // rather than surfacing as a refused login. Without it, "after setup it is | ||
| // just ssh" stops being true the moment the first certificate expires. | ||
| lines.push(`Match host ${o.hostPattern} exec "${o.ensureCertCommand}"`) |
There was a problem hiding this comment.
P2: When the generated block precedes global directives in ~/.ssh/config, this Match remains active because the end marker is only a comment. Close the section with Match all before BLOCK_END so later user directives retain their intended scope.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/ssh-config.ts, line 42:
<comment>When the generated block precedes global directives in `~/.ssh/config`, this `Match` remains active because the end marker is only a comment. Close the section with `Match all` before `BLOCK_END` so later user directives retain their intended scope.</comment>
<file context>
@@ -0,0 +1,100 @@
+ // a certificate that expired since the last login is replaced silently
+ // rather than surfacing as a refused login. Without it, "after setup it is
+ // just ssh" stops being true the moment the first certificate expires.
+ lines.push(`Match host ${o.hostPattern} exec "${o.ensureCertCommand}"`)
+ }
+ lines.push(BLOCK_END)
</file context>
| lines.push(`Match host ${o.hostPattern} exec "${o.ensureCertCommand}"`) | |
| lines.push(`Match host ${o.hostPattern} exec "${o.ensureCertCommand}"`, 'Match all') |
| it('renews when the file cannot be parsed', () => { | ||
| // ssh-keygen fails on a non-certificate, which is caught and reported as | ||
| // "renew" rather than swallowed into "fine". | ||
| expect(certNeedsRenewal('/etc/hosts')).toBe(true) |
There was a problem hiding this comment.
P3: The renewal tests cover only the negative branches (missing file, unparsable file). The source's own highlighted trap is untested: a parsed-but-unparsable date producing NaN (compute.ts:783 'must mean RENEW, not valid forever') and the valid/unexpired path returning false. Without a test that a valid, in-margin cert returns false and an expired one returns true, a regression that flips those branches would not be caught.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/ssh-config.test.ts, line 110:
<comment>The renewal tests cover only the negative branches (missing file, unparsable file). The source's own highlighted trap is untested: a parsed-but-unparsable date producing NaN (compute.ts:783 'must mean RENEW, not valid forever') and the valid/unexpired path returning false. Without a test that a valid, in-margin cert returns false and an expired one returns true, a regression that flips those branches would not be caught.</comment>
<file context>
@@ -0,0 +1,127 @@
+ it('renews when the file cannot be parsed', () => {
+ // ssh-keygen fails on a non-certificate, which is caught and reported as
+ // "renew" rather than swallowed into "fine".
+ expect(certNeedsRenewal('/etc/hosts')).toBe(true)
+ })
+})
</file context>
| const existing = 'Host *\n IdentityFile ~/.ssh/id_rsa\n User root\n' | ||
| const out = upsertConfigBlock(existing, block()) | ||
| expect(out.indexOf(BLOCK_BEGIN), 'our block is not first; an earlier Host * would win every keyword').toBe(0) | ||
| expect(out.indexOf(BLOCK_BEGIN)).toBeLessThan(out.indexOf('Host *')) |
There was a problem hiding this comment.
P3: The second assertion in the top-placement test does not test what its message claims. out.indexOf('Host *') resolves to the block's own Host *.insta line, not the user's Host *, so it always passes once line 21 asserts the block starts at index 0. Assert against the user's Host * specifically (e.g. out.lastIndexOf('Host *')) or drop the line, otherwise it gives false confidence that an earlier Host * cannot steal the keywords.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/ssh-config.test.ts, line 22:
<comment>The second assertion in the top-placement test does not test what its message claims. `out.indexOf('Host *')` resolves to the block's own `Host *.insta` line, not the user's `Host *`, so it always passes once line 21 asserts the block starts at index 0. Assert against the user's `Host *` specifically (e.g. `out.lastIndexOf('Host *')`) or drop the line, otherwise it gives false confidence that an earlier `Host *` cannot steal the keywords.</comment>
<file context>
@@ -0,0 +1,127 @@
+ const existing = 'Host *\n IdentityFile ~/.ssh/id_rsa\n User root\n'
+ const out = upsertConfigBlock(existing, block())
+ expect(out.indexOf(BLOCK_BEGIN), 'our block is not first; an earlier Host * would win every keyword').toBe(0)
+ expect(out.indexOf(BLOCK_BEGIN)).toBeLessThan(out.indexOf('Host *'))
+ // And the user's own config survives intact.
+ expect(out).toContain(' IdentityFile ~/.ssh/id_rsa')
</file context>
| expect(out.indexOf(BLOCK_BEGIN)).toBeLessThan(out.indexOf('Host *')) | |
| expect(out.indexOf(BLOCK_BEGIN)).toBeLessThan(out.lastIndexOf('Host *')) |
The CLI half of developer SSH access. Plane side: https://github.com/InsForge/instacloud-compute/pull/251. Platform side: https://github.com/InsForge/instacloud-platform/pull/429.
--setupgenerates a dedicated key under~/.insta/ssh(existing keys are never touched), has the platform sign a short-lived certificate, adds one@cert-authorityline toknown_hosts, and writes an ssh_config block.--ensure-certis the renewal hook that block installs.Two traps, which is why the editing is pure functions
The config block goes at the TOP. OpenSSH takes the first obtained value for each keyword, and
ssh_config(5)says host-specific declarations belong near the beginning. Appended at the end, our block loses every keyword to an earlierHost *— silently, no error, producing a connection that ignores theIdentityFilewe just wrote.It carries
IdentitiesOnly. SSH offers public keys one at a time, so a developer with a full ssh-agent is identified non-deterministically and the server may never see the key holding the certificate. exe.dev calls this heisen-connect; the symptom is intermittent auth failures with no pattern.Neither is observable from a function that does its own I/O, so the editing lives in
src/commands/ssh-config.tsand is tested directly.Deliberately opposite choices
known_hostsis appended, not inserted — it has no first-wins rule, so position carries no meaning there. Its idempotency keys on the CA key rather than the whole line, so a re-run with a changed host pattern updates instead of leaving two anchors.The trust anchor widens only the region label —
ssh.*.compute.example, not*.compute.example— because the broader pattern would make the CA authoritative for every tenant's service hostname.Renewal fails toward renewing
No certificate, an unparsable one, a date yielding NaN: all renew. "Cannot confirm it is valid" and "it is valid" must not collapse — an unnecessary renewal costs one HTTPS call, the opposite costs a login that fails with no explanation. The hook is silent and never fails the connection: if it cannot run, the existing certificate stays and SSH reports its own error rather than ours.
Testing
Six negative controls, all firing: appending instead of inserting, dropping
IdentitiesOnly, never replacing an existing block, and each uncertain-renewal case reading as valid. Full suite green — 813 tests.🤖 Generated with Claude Code
Summary by cubic
Adds
insta compute ssh --setupfor one-time SSH certificate setup; afterward plainssh api.insta,scp, and-Lwork. Setup generates a dedicated key under~/.insta/ssh(existing keys are never touched), mints a short-lived certificate, adds one@cert-authorityline toknown_hosts, and writes anssh_configblock.--ensure-certis the renewal hook that block installs. Requires an interactive login — API keys are refused; useinsta compute execfor CI.New Features
~/.ssh/config: OpenSSH takes the first value per keyword, so an appended block would silently lose every setting to an earlierHost *.IdentitiesOnly yes, preventing intermittent auth failures from a fullssh-agent.known_hostsis appended and keyed on the CA key, so re-runs with a changed host pattern update instead of duplicating.ssh.*.compute.example, not*.compute.example, keeping the CA out of scope for tenant service hostnames.Written for commit 148c5ae. Summary will update on new commits.
Submodule pointers and merge order: https://github.com/InsForge/instacloud/pull/145