Skip to content

insta compute ssh: one-time setup, then plain ssh - #215

Open
jwfing wants to merge 1 commit into
mainfrom
feat/compute-ssh
Open

insta compute ssh: one-time setup, then plain ssh#215
jwfing wants to merge 1 commit into
mainfrom
feat/compute-ssh

Conversation

@jwfing

@jwfing jwfing commented Sep 12, 2026

Copy link
Copy Markdown
Member

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.

insta compute ssh --setup        # once
ssh api.insta                    # thereafter, plain OpenSSH

--setup generates a dedicated key under ~/.insta/ssh (existing keys are never touched), has the platform sign a short-lived certificate, adds one @cert-authority line to known_hosts, and writes an ssh_config block. --ensure-cert is 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 earlier Host * — silently, no error, producing a connection that ignores the IdentityFile we 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.ts and is tested directly.

Deliberately opposite choices

known_hosts is 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 --setup for one-time SSH certificate setup; afterward plain ssh api.insta, scp, and -L work. Setup generates a dedicated key under ~/.insta/ssh (existing keys are never touched), mints a short-lived certificate, adds one @cert-authority line to known_hosts, and writes an ssh_config block. --ensure-cert is the renewal hook that block installs. Requires an interactive login — API keys are refused; use insta compute exec for CI.

New Features

  • Config block is inserted at the top of ~/.ssh/config: OpenSSH takes the first value per keyword, so an appended block would silently lose every setting to an earlier Host *.
  • Block sets IdentitiesOnly yes, preventing intermittent auth failures from a full ssh-agent.
  • known_hosts is appended and keyed on the CA key, so re-runs with a changed host pattern update instead of duplicating.
  • Trust anchor covers ssh.*.compute.example, not *.compute.example, keeping the CA out of scope for tenant service hostnames.
  • Renewal is conservative: missing, unparsable, or NaN-dated certificates all renew, and the hook never fails the connection.

Written for commit 148c5ae. Summary will update on new commits.

Review in cubic

Submodule pointers and merge order: https://github.com/InsForge/instacloud/pull/145

`--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>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/commands/compute.ts
writeFileSync(cfg, upsertConfigBlock(existing, renderConfigBlock({
hostPattern: '*.insta',
identityFile: instaKeyPath(),
ensureCertCommand: 'insta compute ssh --ensure-cert %h',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment thread src/commands/compute.ts
const cfg = join(homedir(), '.ssh', 'config')
const existing = existsSync(cfg) ? readFileSync(cfg, 'utf8') : ''
writeFileSync(cfg, upsertConfigBlock(existing, renderConfigBlock({
hostPattern: '*.insta',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
hostPattern: '*.insta',
hostPattern: hostPatternFor(out.host),

const lines = [
BLOCK_BEGIN,
`Host ${o.hostPattern}`,
` IdentityFile ${o.identityFile}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
` 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}"`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment thread src/commands/compute.ts
}

export async function computeSSH(serviceName: string | undefined, opts: SSHOpts): Promise<void> {
const api = await ApiClient.load()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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}"`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
lines.push(`Match host ${o.hostPattern} exec "${o.ensureCertCommand}"`)
lines.push(`Match host ${o.hostPattern} exec "${o.ensureCertCommand}"`, 'Match all')

Comment thread test/ssh-config.test.ts
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment thread test/ssh-config.test.ts
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 *'))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
expect(out.indexOf(BLOCK_BEGIN)).toBeLessThan(out.indexOf('Host *'))
expect(out.indexOf(BLOCK_BEGIN)).toBeLessThan(out.lastIndexOf('Host *'))

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