@marcusrbrown/infra 0.9.11 → 0.9.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.9.11",
3
+ "version": "0.9.13",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -265,6 +265,79 @@ describe('backupGatewayCa — atomic write (COR2)', () => {
265
265
  })
266
266
  })
267
267
 
268
+ // ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
269
+
270
+ describe('backupGatewayCa — SSH command includes UserKnownHostsFile', () => {
271
+ let tmpOutput: string
272
+
273
+ beforeEach(() => {
274
+ tmpOutput = `/tmp/test-backup-known-hosts-${Date.now()}.tar`
275
+ })
276
+
277
+ afterEach(async () => {
278
+ try {
279
+ ;(await Bun.file(tmpOutput).exists()) && Bun.spawnSync(['rm', '-f', tmpOutput])
280
+ } catch {
281
+ // ignore
282
+ }
283
+ })
284
+
285
+ it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh', async () => {
286
+ let capturedCmd: string[] = []
287
+ const spawnCapture: BackupSpawnFn = (cmd, _opts) => {
288
+ capturedCmd = cmd
289
+ return {
290
+ stdout: new ReadableStream({
291
+ start(controller) {
292
+ controller.enqueue(new Uint8Array([1, 2, 3]))
293
+ controller.close()
294
+ },
295
+ }),
296
+ stderr: new ReadableStream({
297
+ start(controller) {
298
+ controller.close()
299
+ },
300
+ }),
301
+ exited: Promise.resolve(0),
302
+ }
303
+ }
304
+
305
+ await backupGatewayCa({host: 'gateway.example.com', output: tmpOutput, includeCa: true}, spawnCapture)
306
+
307
+ const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
308
+ expect(knownHostsIdx).toBeGreaterThan(-1)
309
+ expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
310
+ expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
311
+ })
312
+
313
+ it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
314
+ let capturedCmd: string[] = []
315
+ const spawnCapture: BackupSpawnFn = (cmd, _opts) => {
316
+ capturedCmd = cmd
317
+ return {
318
+ stdout: new ReadableStream({
319
+ start(controller) {
320
+ controller.enqueue(new Uint8Array([1, 2, 3]))
321
+ controller.close()
322
+ },
323
+ }),
324
+ stderr: new ReadableStream({
325
+ start(controller) {
326
+ controller.close()
327
+ },
328
+ }),
329
+ exited: Promise.resolve(0),
330
+ }
331
+ }
332
+
333
+ await backupGatewayCa({host: 'gateway.example.com', output: tmpOutput, includeCa: true}, spawnCapture)
334
+
335
+ const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
336
+ expect(strictIdx).toBeGreaterThan(-1)
337
+ expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
338
+ })
339
+ })
340
+
268
341
  // ─── SEC1: Tmp file born with mode 0600 (no chmod race) ──────────────────────
269
342
 
270
343
  describe('backupGatewayCa — SEC1: tmp file created with mode 0600 atomically', () => {
@@ -5,6 +5,7 @@ import type {ActionCtx} from '../../lib/action-ctx'
5
5
  import {closeSync, constants as fsConstants, openSync, renameSync, unlinkSync, writeSync} from 'node:fs'
6
6
 
7
7
  import {z} from 'zod'
8
+ import {buildKnownHostsArgs} from '../../lib/known-hosts'
8
9
  import {validateGatewayHost} from './host'
9
10
 
10
11
  declare const process: {
@@ -69,6 +70,7 @@ export async function backupGatewayCa(
69
70
  'BatchMode=yes',
70
71
  '-o',
71
72
  'StrictHostKeyChecking=yes',
73
+ ...buildKnownHostsArgs(),
72
74
  `root@${opts.host}`,
73
75
  `docker run --rm -v ${MITMPROXY_CERTS_VOLUME}:/src:ro alpine tar -cf - -C /src ${CA_CERT_FILE} ${CA_KEY_FILE}`,
74
76
  ]
@@ -13,7 +13,9 @@ const envKeys = [
13
13
  'DISCORD_GUILD_ID',
14
14
  'DISCORD_TOKEN',
15
15
  'GATEWAY_HOST',
16
+ 'GATEWAY_PRESENCE_CHANNEL_ID',
16
17
  'GATEWAY_TRIGGER_ROLE_ID',
18
+ 'GATEWAY_WEBHOOK_SECRET',
17
19
  'HOME',
18
20
  'OBJECT_STORE_HOSTS',
19
21
  'PATH',
@@ -361,6 +363,56 @@ describe('gateway deploy', () => {
361
363
  const env = getGatewayDeployEnv()
362
364
  expect(env.GATEWAY_TRIGGER_ROLE_ID).toBe('987654321098765432')
363
365
  })
366
+
367
+ // ── announce opt-in vars parity ───────────────────────────────────────────
368
+
369
+ it('forwards GATEWAY_WEBHOOK_SECRET when set', () => {
370
+ setManagedEnv({
371
+ GATEWAY_HOST: 'gateway.example.com',
372
+ GATEWAY_WEBHOOK_SECRET: 'hmac-secret-value',
373
+ HOME: '/tmp/test-home',
374
+ PATH: '/usr/bin:/bin',
375
+ SSH_AUTH_SOCK: '/tmp/test-sock',
376
+ })
377
+ const env = getGatewayDeployEnv()
378
+ expect(env.GATEWAY_WEBHOOK_SECRET).toBe('hmac-secret-value')
379
+ })
380
+
381
+ it('GATEWAY_WEBHOOK_SECRET unset → forwarded as empty string', () => {
382
+ setManagedEnv({
383
+ GATEWAY_HOST: 'gateway.example.com',
384
+ GATEWAY_WEBHOOK_SECRET: undefined,
385
+ HOME: '/tmp/test-home',
386
+ PATH: '/usr/bin:/bin',
387
+ SSH_AUTH_SOCK: '/tmp/test-sock',
388
+ })
389
+ const env = getGatewayDeployEnv()
390
+ expect(env.GATEWAY_WEBHOOK_SECRET).toBe('')
391
+ })
392
+
393
+ it('forwards GATEWAY_PRESENCE_CHANNEL_ID when set', () => {
394
+ setManagedEnv({
395
+ GATEWAY_HOST: 'gateway.example.com',
396
+ GATEWAY_PRESENCE_CHANNEL_ID: '111222333444555666',
397
+ HOME: '/tmp/test-home',
398
+ PATH: '/usr/bin:/bin',
399
+ SSH_AUTH_SOCK: '/tmp/test-sock',
400
+ })
401
+ const env = getGatewayDeployEnv()
402
+ expect(env.GATEWAY_PRESENCE_CHANNEL_ID).toBe('111222333444555666')
403
+ })
404
+
405
+ it('GATEWAY_PRESENCE_CHANNEL_ID unset → forwarded as empty string', () => {
406
+ setManagedEnv({
407
+ GATEWAY_HOST: 'gateway.example.com',
408
+ GATEWAY_PRESENCE_CHANNEL_ID: undefined,
409
+ HOME: '/tmp/test-home',
410
+ PATH: '/usr/bin:/bin',
411
+ SSH_AUTH_SOCK: '/tmp/test-sock',
412
+ })
413
+ const env = getGatewayDeployEnv()
414
+ expect(env.GATEWAY_PRESENCE_CHANNEL_ID).toBe('')
415
+ })
364
416
  })
365
417
 
366
418
  describe('validateGatewayRemotePreconditions', () => {
@@ -56,6 +56,8 @@ export function getGatewayDeployEnv(): Record<string, string> {
56
56
  WORKSPACE_OPENCODE_MODEL: process.env.WORKSPACE_OPENCODE_MODEL ?? '',
57
57
  WORKSPACE_OPENCODE_CONFIG: process.env.WORKSPACE_OPENCODE_CONFIG ?? '',
58
58
  GATEWAY_TRIGGER_ROLE_ID: process.env.GATEWAY_TRIGGER_ROLE_ID ?? '',
59
+ GATEWAY_WEBHOOK_SECRET: process.env.GATEWAY_WEBHOOK_SECRET ?? '',
60
+ GATEWAY_PRESENCE_CHANNEL_ID: process.env.GATEWAY_PRESENCE_CHANNEL_ID ?? '',
59
61
  }
60
62
  }
61
63
 
@@ -220,3 +220,44 @@ describe('tail forwarding', () => {
220
220
  expect(capturedCmd.join(' ')).toContain('--tail=25')
221
221
  })
222
222
  })
223
+
224
+ // ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
225
+
226
+ describe('streamGatewayLogs — SSH command includes UserKnownHostsFile', () => {
227
+ it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh', async () => {
228
+ delete process.env.CI
229
+
230
+ const {streamGatewayLogs} = await import('./logs')
231
+
232
+ let capturedCmd: string[] = []
233
+ const spawnCapture: SpawnFn = (cmd, _opts) => {
234
+ capturedCmd = cmd
235
+ return {exited: Promise.resolve(0)}
236
+ }
237
+
238
+ await streamGatewayLogs({host: 'gateway.example.com', service: 'gateway', tail: 100, allowCi: false}, spawnCapture)
239
+
240
+ const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
241
+ expect(knownHostsIdx).toBeGreaterThan(-1)
242
+ expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
243
+ expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
244
+ })
245
+
246
+ it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
247
+ delete process.env.CI
248
+
249
+ const {streamGatewayLogs} = await import('./logs')
250
+
251
+ let capturedCmd: string[] = []
252
+ const spawnCapture: SpawnFn = (cmd, _opts) => {
253
+ capturedCmd = cmd
254
+ return {exited: Promise.resolve(0)}
255
+ }
256
+
257
+ await streamGatewayLogs({host: 'gateway.example.com', service: 'gateway', tail: 100, allowCi: false}, spawnCapture)
258
+
259
+ const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
260
+ expect(strictIdx).toBeGreaterThan(-1)
261
+ expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
262
+ })
263
+ })
@@ -2,6 +2,7 @@ import type {goke} from 'goke'
2
2
 
3
3
  import {z} from 'zod'
4
4
 
5
+ import {buildKnownHostsArgs} from '../../lib/known-hosts'
5
6
  import {validateGatewayHost} from './host'
6
7
 
7
8
  declare const process: {
@@ -88,6 +89,7 @@ export async function streamGatewayLogs(
88
89
  'BatchMode=yes',
89
90
  '-o',
90
91
  'StrictHostKeyChecking=yes',
92
+ ...buildKnownHostsArgs(),
91
93
  `root@${opts.host}`,
92
94
  `docker compose --project-directory ${COMPOSE_PROJECT_DIR} logs --no-color --tail=${opts.tail} ${opts.service}`,
93
95
  ]
@@ -403,6 +403,162 @@ describe('restoreGatewayCa — SEC2: unguessable remote tmp path via mktemp', ()
403
403
  })
404
404
  })
405
405
 
406
+ // ─── SSH and SCP commands include repo-pinned UserKnownHostsFile ─────────────
407
+
408
+ describe('restoreGatewayCa — SSH and SCP commands include UserKnownHostsFile', () => {
409
+ it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to every ssh invocation', async () => {
410
+ const tarDir = `/tmp/test-kh-ssh-tar-dir-${Date.now()}`
411
+ const realTar = `/tmp/test-kh-ssh-tar-${Date.now()}.tar`
412
+ const certContent = 'CERT'
413
+ const keyContent = 'KEY'
414
+ Bun.spawnSync(['mkdir', '-p', tarDir])
415
+ await Bun.write(`${tarDir}/mitmproxy-ca-cert.pem`, certContent)
416
+ await Bun.write(`${tarDir}/mitmproxy-ca.pem`, keyContent)
417
+ Bun.spawnSync(['tar', '-cf', realTar, '-C', tarDir, 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.pem'])
418
+
419
+ const capturedCmds: string[][] = []
420
+ const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
421
+ capturedCmds.push([...cmd])
422
+ const cmdStr = cmd.join(' ')
423
+ const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
424
+ const isMktemp = cmdStr.includes('mktemp')
425
+ const encoder = new TextEncoder()
426
+ let stdout = ''
427
+ if (isTarTf) stdout = 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'
428
+ else if (isMktemp) stdout = '/tmp/gateway-ca-restore-kh-test.tar\n'
429
+ else stdout = certContent
430
+ return {
431
+ stdout: new ReadableStream({
432
+ start(controller) {
433
+ controller.enqueue(encoder.encode(stdout))
434
+ controller.close()
435
+ },
436
+ }),
437
+ stderr: new ReadableStream({
438
+ start(controller) {
439
+ controller.close()
440
+ },
441
+ }),
442
+ exited: Promise.resolve(0),
443
+ }
444
+ }
445
+
446
+ await restoreGatewayCa({host: 'gateway.example.com', input: realTar, includeCa: true}, spawnCapture)
447
+
448
+ // Every ssh invocation must include UserKnownHostsFile
449
+ const sshCmds = capturedCmds.filter(c => c[0] === 'ssh')
450
+ expect(sshCmds.length).toBeGreaterThan(0)
451
+ for (const sshCmd of sshCmds) {
452
+ const knownHostsIdx = sshCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
453
+ expect(knownHostsIdx).toBeGreaterThan(-1)
454
+ expect(sshCmd[knownHostsIdx - 1]).toBe('-o')
455
+ expect(sshCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
456
+ }
457
+
458
+ Bun.spawnSync(['rm', '-rf', tarDir, realTar])
459
+ })
460
+
461
+ it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to the scp invocation', async () => {
462
+ const tarDir = `/tmp/test-kh-scp-tar-dir-${Date.now()}`
463
+ const realTar = `/tmp/test-kh-scp-tar-${Date.now()}.tar`
464
+ const certContent = 'CERT'
465
+ const keyContent = 'KEY'
466
+ Bun.spawnSync(['mkdir', '-p', tarDir])
467
+ await Bun.write(`${tarDir}/mitmproxy-ca-cert.pem`, certContent)
468
+ await Bun.write(`${tarDir}/mitmproxy-ca.pem`, keyContent)
469
+ Bun.spawnSync(['tar', '-cf', realTar, '-C', tarDir, 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.pem'])
470
+
471
+ const capturedCmds: string[][] = []
472
+ const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
473
+ capturedCmds.push([...cmd])
474
+ const cmdStr = cmd.join(' ')
475
+ const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
476
+ const isMktemp = cmdStr.includes('mktemp')
477
+ const encoder = new TextEncoder()
478
+ let stdout = ''
479
+ if (isTarTf) stdout = 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'
480
+ else if (isMktemp) stdout = '/tmp/gateway-ca-restore-kh-scp-test.tar\n'
481
+ else stdout = certContent
482
+ return {
483
+ stdout: new ReadableStream({
484
+ start(controller) {
485
+ controller.enqueue(encoder.encode(stdout))
486
+ controller.close()
487
+ },
488
+ }),
489
+ stderr: new ReadableStream({
490
+ start(controller) {
491
+ controller.close()
492
+ },
493
+ }),
494
+ exited: Promise.resolve(0),
495
+ }
496
+ }
497
+
498
+ await restoreGatewayCa({host: 'gateway.example.com', input: realTar, includeCa: true}, spawnCapture)
499
+
500
+ const scpCmd = capturedCmds.find(c => c[0] === 'scp')
501
+ expect(scpCmd).toBeDefined()
502
+ const knownHostsIdx = scpCmd!.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
503
+ expect(knownHostsIdx).toBeGreaterThan(-1)
504
+ expect(scpCmd![knownHostsIdx - 1]).toBe('-o')
505
+ expect(scpCmd![knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
506
+
507
+ Bun.spawnSync(['rm', '-rf', tarDir, realTar])
508
+ })
509
+
510
+ it('does not weaken StrictHostKeyChecking in ssh or scp commands', async () => {
511
+ const tarDir = `/tmp/test-kh-strict-tar-dir-${Date.now()}`
512
+ const realTar = `/tmp/test-kh-strict-tar-${Date.now()}.tar`
513
+ const certContent = 'CERT'
514
+ const keyContent = 'KEY'
515
+ Bun.spawnSync(['mkdir', '-p', tarDir])
516
+ await Bun.write(`${tarDir}/mitmproxy-ca-cert.pem`, certContent)
517
+ await Bun.write(`${tarDir}/mitmproxy-ca.pem`, keyContent)
518
+ Bun.spawnSync(['tar', '-cf', realTar, '-C', tarDir, 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.pem'])
519
+
520
+ const capturedCmds: string[][] = []
521
+ const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
522
+ capturedCmds.push([...cmd])
523
+ const cmdStr = cmd.join(' ')
524
+ const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
525
+ const isMktemp = cmdStr.includes('mktemp')
526
+ const encoder = new TextEncoder()
527
+ let stdout = ''
528
+ if (isTarTf) stdout = 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'
529
+ else if (isMktemp) stdout = '/tmp/gateway-ca-restore-kh-strict-test.tar\n'
530
+ else stdout = certContent
531
+ return {
532
+ stdout: new ReadableStream({
533
+ start(controller) {
534
+ controller.enqueue(encoder.encode(stdout))
535
+ controller.close()
536
+ },
537
+ }),
538
+ stderr: new ReadableStream({
539
+ start(controller) {
540
+ controller.close()
541
+ },
542
+ }),
543
+ exited: Promise.resolve(0),
544
+ }
545
+ }
546
+
547
+ await restoreGatewayCa({host: 'gateway.example.com', input: realTar, includeCa: true}, spawnCapture)
548
+
549
+ // All ssh and scp commands must have StrictHostKeyChecking=yes
550
+ const sshAndScpCmds = capturedCmds.filter(c => c[0] === 'ssh' || c[0] === 'scp')
551
+ expect(sshAndScpCmds.length).toBeGreaterThan(0)
552
+ for (const cmd of sshAndScpCmds) {
553
+ const strictIdx = cmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
554
+ expect(strictIdx).toBeGreaterThan(-1)
555
+ expect(cmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
556
+ }
557
+
558
+ Bun.spawnSync(['rm', '-rf', tarDir, realTar])
559
+ })
560
+ })
561
+
406
562
  // ─── COR1: validateBackupArchive ─────────────────────────────────────────────
407
563
 
408
564
  describe('validateBackupArchive — COR1', () => {
@@ -2,6 +2,7 @@ import type {goke} from 'goke'
2
2
 
3
3
  import {z} from 'zod'
4
4
 
5
+ import {buildKnownHostsArgs} from '../../lib/known-hosts'
5
6
  import {validateGatewayHost} from './host'
6
7
 
7
8
  declare const process: {
@@ -38,7 +39,16 @@ export type RestoreResult = {ok: true; confirmed: true} | {ok: false; error: str
38
39
 
39
40
  /** Returns the argv array for an SSH command to the given host. */
40
41
  function sshCommand(host: string, remote: string): string[] {
41
- return ['ssh', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', `root@${host}`, remote]
42
+ return [
43
+ 'ssh',
44
+ '-o',
45
+ 'BatchMode=yes',
46
+ '-o',
47
+ 'StrictHostKeyChecking=yes',
48
+ ...buildKnownHostsArgs(),
49
+ `root@${host}`,
50
+ remote,
51
+ ]
42
52
  }
43
53
 
44
54
  async function spawnText(
@@ -143,6 +153,7 @@ export async function restoreGatewayCa(
143
153
  'BatchMode=yes',
144
154
  '-o',
145
155
  'StrictHostKeyChecking=yes',
156
+ ...buildKnownHostsArgs(),
146
157
  opts.input,
147
158
  `root@${opts.host}:${tmpRemote}`,
148
159
  ]
@@ -139,6 +139,71 @@ function makeSpawnError(message: string): SpawnFn {
139
139
  }
140
140
  }
141
141
 
142
+ // ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
143
+
144
+ describe('getGatewayComposeStatus — SSH command includes UserKnownHostsFile', () => {
145
+ it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh when the file exists', async () => {
146
+ const {getGatewayComposeStatus} = await import('./status')
147
+ let capturedCmd: string[] = []
148
+
149
+ const capturingSpawn: SpawnFn = (cmd, _opts) => {
150
+ capturedCmd = cmd
151
+ const encoder = new TextEncoder()
152
+ return {
153
+ stdout: new ReadableStream({
154
+ start(controller) {
155
+ controller.enqueue(encoder.encode(JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])))
156
+ controller.close()
157
+ },
158
+ }),
159
+ stderr: new ReadableStream({
160
+ start(c) {
161
+ c.close()
162
+ },
163
+ }),
164
+ exited: Promise.resolve(0),
165
+ }
166
+ }
167
+
168
+ await getGatewayComposeStatus('gateway.fro.bot', capturingSpawn)
169
+
170
+ const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
171
+ expect(knownHostsIdx).toBeGreaterThan(-1)
172
+ expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
173
+ expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
174
+ })
175
+
176
+ it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
177
+ const {getGatewayComposeStatus} = await import('./status')
178
+ let capturedCmd: string[] = []
179
+
180
+ const capturingSpawn: SpawnFn = (cmd, _opts) => {
181
+ capturedCmd = cmd
182
+ const encoder = new TextEncoder()
183
+ return {
184
+ stdout: new ReadableStream({
185
+ start(controller) {
186
+ controller.enqueue(encoder.encode(JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])))
187
+ controller.close()
188
+ },
189
+ }),
190
+ stderr: new ReadableStream({
191
+ start(c) {
192
+ c.close()
193
+ },
194
+ }),
195
+ exited: Promise.resolve(0),
196
+ }
197
+ }
198
+
199
+ await getGatewayComposeStatus('gateway.fro.bot', capturingSpawn)
200
+
201
+ const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
202
+ expect(strictIdx).toBeGreaterThan(-1)
203
+ expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
204
+ })
205
+ })
206
+
142
207
  describe('getGatewayComposeStatus — host validation (SEC1)', () => {
143
208
  it('rejects a leading-hyphen host and does not invoke ssh', async () => {
144
209
  const {getGatewayComposeStatus} = await import('./status')
@@ -3,6 +3,7 @@ import type {ActionCtx} from '../../lib/action-ctx'
3
3
  import type {StatusSummary} from '../status'
4
4
  import {z} from 'zod'
5
5
 
6
+ import {buildKnownHostsArgs} from '../../lib/known-hosts'
6
7
  import {redactHost} from '../../lib/redact'
7
8
  // ─── Minimal ctx interface (subset of GokeExecutionContext used by this action) ─
8
9
  import {validateGatewayHost} from './host'
@@ -108,6 +109,7 @@ export async function getGatewayComposeStatus(
108
109
  'BatchMode=yes',
109
110
  '-o',
110
111
  'StrictHostKeyChecking=yes',
112
+ ...buildKnownHostsArgs(),
111
113
  `root@${host}`,
112
114
  `docker compose --project-directory ${COMPOSE_PROJECT_DIR} ps --format json`,
113
115
  ]
@@ -152,3 +152,40 @@ describe('logs command', () => {
152
152
  expect(spawnCalled).toBe(false)
153
153
  })
154
154
  })
155
+
156
+ // ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
157
+
158
+ describe('streamUmamiLogs — SSH command includes UserKnownHostsFile', () => {
159
+ it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh', async () => {
160
+ delete process.env.CI
161
+
162
+ let capturedCmd: string[] = []
163
+ const spawnCapture: LogsSpawnFn = (cmd, _opts) => {
164
+ capturedCmd = cmd
165
+ return {exited: Promise.resolve(0)}
166
+ }
167
+
168
+ await streamUmamiLogs({host: 'metrics.fro.bot', service: 'umami', tail: 100, allowCi: false}, spawnCapture)
169
+
170
+ const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
171
+ expect(knownHostsIdx).toBeGreaterThan(-1)
172
+ expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
173
+ expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
174
+ })
175
+
176
+ it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
177
+ delete process.env.CI
178
+
179
+ let capturedCmd: string[] = []
180
+ const spawnCapture: LogsSpawnFn = (cmd, _opts) => {
181
+ capturedCmd = cmd
182
+ return {exited: Promise.resolve(0)}
183
+ }
184
+
185
+ await streamUmamiLogs({host: 'metrics.fro.bot', service: 'umami', tail: 100, allowCi: false}, spawnCapture)
186
+
187
+ const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
188
+ expect(strictIdx).toBeGreaterThan(-1)
189
+ expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
190
+ })
191
+ })
@@ -2,6 +2,7 @@ import type {goke} from 'goke'
2
2
 
3
3
  import {z} from 'zod'
4
4
 
5
+ import {buildKnownHostsArgs} from '../../lib/known-hosts'
5
6
  import {validateUmamiHost} from './host'
6
7
 
7
8
  declare const process: {
@@ -91,6 +92,7 @@ export async function streamUmamiLogs(
91
92
  'BatchMode=yes',
92
93
  '-o',
93
94
  'StrictHostKeyChecking=yes',
95
+ ...buildKnownHostsArgs(),
94
96
  `root@${opts.host}`,
95
97
  `docker compose --project-directory ${COMPOSE_PROJECT_DIR} logs --no-color --tail=${opts.tail} ${opts.service}`,
96
98
  ]
@@ -133,6 +133,70 @@ function makeSpawn(stdout: string, stderr = '', exitCode = 0): SpawnFn {
133
133
  }
134
134
  }
135
135
 
136
+ // ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
137
+
138
+ describe('getUmamiComposeStatus — SSH command includes UserKnownHostsFile', () => {
139
+ it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh when the file exists', async () => {
140
+ let capturedCmd: string[] = []
141
+
142
+ const capturingSpawn: SpawnFn = (cmd, _opts) => {
143
+ capturedCmd = cmd
144
+ const enc = new TextEncoder()
145
+ return {
146
+ stdout: new ReadableStream({
147
+ start(c) {
148
+ c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
149
+ c.close()
150
+ },
151
+ }),
152
+ stderr: new ReadableStream({
153
+ start(c) {
154
+ c.close()
155
+ },
156
+ }),
157
+ exited: Promise.resolve(0),
158
+ }
159
+ }
160
+
161
+ await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
162
+
163
+ // Find the UserKnownHostsFile option in the ssh command
164
+ const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
165
+ expect(knownHostsIdx).toBeGreaterThan(-1)
166
+ expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
167
+ expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
168
+ })
169
+
170
+ it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
171
+ let capturedCmd: string[] = []
172
+
173
+ const capturingSpawn: SpawnFn = (cmd, _opts) => {
174
+ capturedCmd = cmd
175
+ const enc = new TextEncoder()
176
+ return {
177
+ stdout: new ReadableStream({
178
+ start(c) {
179
+ c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
180
+ c.close()
181
+ },
182
+ }),
183
+ stderr: new ReadableStream({
184
+ start(c) {
185
+ c.close()
186
+ },
187
+ }),
188
+ exited: Promise.resolve(0),
189
+ }
190
+ }
191
+
192
+ await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
193
+
194
+ const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
195
+ expect(strictIdx).toBeGreaterThan(-1)
196
+ expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
197
+ })
198
+ })
199
+
136
200
  describe('getUmamiComposeStatus', () => {
137
201
  it('returns service rows from NDJSON output', async () => {
138
202
  const ndjson = [
@@ -3,6 +3,7 @@ import type {ActionCtx} from '../../lib/action-ctx'
3
3
  import type {StatusSummary} from '../status'
4
4
  import {z} from 'zod'
5
5
 
6
+ import {buildKnownHostsArgs} from '../../lib/known-hosts'
6
7
  import {redactHost} from '../../lib/redact'
7
8
  import {validateUmamiHost} from './host'
8
9
 
@@ -102,6 +103,7 @@ export async function getUmamiComposeStatus(host: string, spawn: SpawnFn = defau
102
103
  'BatchMode=yes',
103
104
  '-o',
104
105
  'StrictHostKeyChecking=yes',
106
+ ...buildKnownHostsArgs(),
105
107
  `root@${host}`,
106
108
  `docker compose --project-directory ${COMPOSE_PROJECT_DIR} ps --format json`,
107
109
  ]
@@ -276,6 +276,30 @@ describe('repo conventions', () => {
276
276
  expect(offenders).toEqual([])
277
277
  })
278
278
 
279
+ it('opencode.jsonc mcp.infra.command uses local repo source (not bunx published package)', async () => {
280
+ const jsoncText = await Bun.file(resolve(REPO_ROOT, 'opencode.jsonc')).text()
281
+ const stripped = jsoncText
282
+ .split('\n')
283
+ .map(line => (/^\s*\/\//.test(line) ? '' : line))
284
+ .join('\n')
285
+ .replaceAll(/,(\s*[}\]])/g, '$1')
286
+ const opencode = JSON.parse(stripped) as {mcp?: {infra?: {command?: unknown}}}
287
+
288
+ const infraCommand = opencode.mcp?.infra?.command
289
+ expect(Array.isArray(infraCommand), 'mcp.infra.command must be an array').toBe(true)
290
+
291
+ const cmd = infraCommand as string[]
292
+ expect(
293
+ cmd,
294
+ 'mcp.infra.command must be ["bun", "run", "packages/cli/src/cli.ts", "mcp"] — use local source, not bunx',
295
+ ).toEqual(['bun', 'run', 'packages/cli/src/cli.ts', 'mcp'])
296
+
297
+ // Explicit regression guard: bunx @marcusrbrown/infra resolves stale published
298
+ // package cache and exits before the MCP handshake, causing connection closed.
299
+ expect(cmd.join(' ')).not.toContain('bunx')
300
+ expect(cmd.join(' ')).not.toContain('@marcusrbrown/infra')
301
+ })
302
+
279
303
  it('gates every sensitive infra MCP tool in opencode.jsonc', async () => {
280
304
  // Parse opencode.jsonc tolerantly: it uses JSONC syntax (// line comments,
281
305
  // trailing commas). Strategy:
@@ -0,0 +1,192 @@
1
+ import {existsSync, mkdirSync, rmSync, writeFileSync} from 'node:fs'
2
+ import {tmpdir} from 'node:os'
3
+ import {join, resolve} from 'node:path'
4
+
5
+ import {describe, expect, it} from 'bun:test'
6
+
7
+ import {buildKnownHostsArgs, resolveKnownHostsPath, resolveRepoKnownHostsPath} from './known-hosts'
8
+
9
+ // ─── resolveRepoKnownHostsPath ────────────────────────────────────────────────
10
+
11
+ describe('resolveRepoKnownHostsPath', () => {
12
+ it('returns the path to .github/known_hosts when it exists at the repo root', () => {
13
+ // The actual repo has .github/known_hosts — this test runs from the repo root
14
+ const result = resolveRepoKnownHostsPath()
15
+
16
+ expect(result).not.toBeNull()
17
+ expect(result).toMatch(/\.github[/\\]known_hosts$/)
18
+ if (result) expect(existsSync(result)).toBe(true)
19
+ })
20
+
21
+ it('returns null when a custom base path has no .github/known_hosts', () => {
22
+ const tmpDir = join(tmpdir(), `known-hosts-test-${Date.now()}`)
23
+ mkdirSync(tmpDir, {recursive: true})
24
+
25
+ try {
26
+ const result = resolveRepoKnownHostsPath(tmpDir)
27
+ expect(result).toBeNull()
28
+ } finally {
29
+ rmSync(tmpDir, {recursive: true, force: true})
30
+ }
31
+ })
32
+
33
+ it('returns the path when a custom base path has .github/known_hosts', () => {
34
+ const tmpDir = join(tmpdir(), `known-hosts-test-${Date.now()}`)
35
+ const githubDir = join(tmpDir, '.github')
36
+ mkdirSync(githubDir, {recursive: true})
37
+ writeFileSync(join(githubDir, 'known_hosts'), 'example.com ssh-ed25519 AAAA...\n')
38
+
39
+ try {
40
+ const result = resolveRepoKnownHostsPath(tmpDir)
41
+ expect(result).not.toBeNull()
42
+ expect(result).toBe(join(tmpDir, '.github', 'known_hosts'))
43
+ } finally {
44
+ rmSync(tmpDir, {recursive: true, force: true})
45
+ }
46
+ })
47
+ })
48
+
49
+ // ─── resolveKnownHostsPath (new: multi-layout resolution) ────────────────────
50
+
51
+ describe('resolveKnownHostsPath', () => {
52
+ it('returns the packaged resource path when only src/resources/known_hosts exists (installed package layout)', () => {
53
+ // Simulate: node_modules/@marcusrbrown/infra/src/lib/known-hosts.ts
54
+ // Only src/resources/known_hosts exists; no .github/known_hosts at any ancestor.
55
+ const tmpDir = join(tmpdir(), `known-hosts-installed-${Date.now()}`)
56
+ const libDir = join(tmpDir, 'node_modules', '@marcusrbrown', 'infra', 'src', 'lib')
57
+ const resourcesDir = join(tmpDir, 'node_modules', '@marcusrbrown', 'infra', 'src', 'resources')
58
+ mkdirSync(libDir, {recursive: true})
59
+ mkdirSync(resourcesDir, {recursive: true})
60
+ writeFileSync(join(resourcesDir, 'known_hosts'), 'example.com ssh-ed25519 AAAA...\n')
61
+
62
+ try {
63
+ // Pass the simulated import.meta.dir (the lib dir inside the package)
64
+ const result = resolveKnownHostsPath(libDir)
65
+ expect(result).not.toBeNull()
66
+ expect(result).toBe(join(resourcesDir, 'known_hosts'))
67
+ } finally {
68
+ rmSync(tmpDir, {recursive: true, force: true})
69
+ }
70
+ })
71
+
72
+ it('prefers .github/known_hosts over packaged resource when both exist (repo checkout layout)', () => {
73
+ // Mirror the real layout: packages/cli/src/lib is 4 levels deep from repo root.
74
+ // tmpDir is the "repo root"; libDir is tmpDir/packages/cli/src/lib.
75
+ const tmpDir = join(tmpdir(), `known-hosts-both-${Date.now()}`)
76
+ const libDir = join(tmpDir, 'packages', 'cli', 'src', 'lib')
77
+ const githubDir = join(tmpDir, '.github')
78
+ const resourcesDir = join(tmpDir, 'packages', 'cli', 'src', 'resources')
79
+ mkdirSync(libDir, {recursive: true})
80
+ mkdirSync(githubDir, {recursive: true})
81
+ mkdirSync(resourcesDir, {recursive: true})
82
+ writeFileSync(join(githubDir, 'known_hosts'), 'repo.example.com ssh-ed25519 AAAA...\n')
83
+ writeFileSync(join(resourcesDir, 'known_hosts'), 'packaged.example.com ssh-ed25519 AAAA...\n')
84
+
85
+ try {
86
+ // lib is at tmpDir/packages/cli/src/lib — 4 levels up is tmpDir (the "repo root")
87
+ const result = resolveKnownHostsPath(libDir)
88
+ expect(result).not.toBeNull()
89
+ // Should prefer .github/known_hosts (repo checkout)
90
+ expect(result).toBe(join(githubDir, 'known_hosts'))
91
+ } finally {
92
+ rmSync(tmpDir, {recursive: true, force: true})
93
+ }
94
+ })
95
+
96
+ it('throws a clear error when neither .github/known_hosts nor packaged resource exists', () => {
97
+ const tmpDir = join(tmpdir(), `known-hosts-none-${Date.now()}`)
98
+ const libDir = join(tmpDir, 'src', 'lib')
99
+ mkdirSync(libDir, {recursive: true})
100
+
101
+ try {
102
+ expect(() => resolveKnownHostsPath(libDir)).toThrow(
103
+ 'Pinned SSH known_hosts file not found; reinstall @marcusrbrown/infra or run from the repo checkout',
104
+ )
105
+ } finally {
106
+ rmSync(tmpDir, {recursive: true, force: true})
107
+ }
108
+ })
109
+ })
110
+
111
+ // ─── buildKnownHostsArgs ──────────────────────────────────────────────────────
112
+
113
+ describe('buildKnownHostsArgs', () => {
114
+ it('returns UserKnownHostsFile args when .github/known_hosts exists at repo root', () => {
115
+ const args = buildKnownHostsArgs()
116
+
117
+ // The actual repo has .github/known_hosts
118
+ expect(args).toHaveLength(2)
119
+ expect(args[0]).toBe('-o')
120
+ expect(args[1]).toMatch(/^UserKnownHostsFile=/)
121
+ expect(args[1]).toMatch(/\.github[/\\]known_hosts$/)
122
+ })
123
+
124
+ it('throws when no known_hosts file exists (fail-closed behavior)', () => {
125
+ const tmpDir = join(tmpdir(), `known-hosts-test-${Date.now()}`)
126
+ const libDir = join(tmpDir, 'src', 'lib')
127
+ mkdirSync(libDir, {recursive: true})
128
+
129
+ try {
130
+ expect(() => buildKnownHostsArgs(libDir)).toThrow(
131
+ 'Pinned SSH known_hosts file not found; reinstall @marcusrbrown/infra or run from the repo checkout',
132
+ )
133
+ } finally {
134
+ rmSync(tmpDir, {recursive: true, force: true})
135
+ }
136
+ })
137
+
138
+ it('returns UserKnownHostsFile args pointing to the packaged resource in installed layout', () => {
139
+ const tmpDir = join(tmpdir(), `known-hosts-installed-${Date.now()}`)
140
+ const libDir = join(tmpDir, 'node_modules', '@marcusrbrown', 'infra', 'src', 'lib')
141
+ const resourcesDir = join(tmpDir, 'node_modules', '@marcusrbrown', 'infra', 'src', 'resources')
142
+ mkdirSync(libDir, {recursive: true})
143
+ mkdirSync(resourcesDir, {recursive: true})
144
+ writeFileSync(join(resourcesDir, 'known_hosts'), 'example.com ssh-ed25519 AAAA...\n')
145
+
146
+ try {
147
+ const args = buildKnownHostsArgs(libDir)
148
+ expect(args).toHaveLength(2)
149
+ expect(args[0]).toBe('-o')
150
+ expect(args[1]).toBe(`UserKnownHostsFile=${join(resourcesDir, 'known_hosts')}`)
151
+ } finally {
152
+ rmSync(tmpDir, {recursive: true, force: true})
153
+ }
154
+ })
155
+
156
+ it('returns UserKnownHostsFile args pointing to the custom base path (repo checkout)', () => {
157
+ // Mirror the real layout: packages/cli/src/lib is 4 levels deep from repo root.
158
+ const tmpDir = join(tmpdir(), `known-hosts-test-${Date.now()}`)
159
+ const libDir = join(tmpDir, 'packages', 'cli', 'src', 'lib')
160
+ const githubDir = join(tmpDir, '.github')
161
+ mkdirSync(libDir, {recursive: true})
162
+ mkdirSync(githubDir, {recursive: true})
163
+ writeFileSync(join(githubDir, 'known_hosts'), 'example.com ssh-ed25519 AAAA...\n')
164
+
165
+ try {
166
+ const args = buildKnownHostsArgs(libDir)
167
+ expect(args).toHaveLength(2)
168
+ expect(args[0]).toBe('-o')
169
+ expect(args[1]).toBe(`UserKnownHostsFile=${join(githubDir, 'known_hosts')}`)
170
+ } finally {
171
+ rmSync(tmpDir, {recursive: true, force: true})
172
+ }
173
+ })
174
+ })
175
+
176
+ // ─── Drift guard: packaged resource matches .github/known_hosts ───────────────
177
+
178
+ describe('packaged known_hosts resource drift guard', () => {
179
+ const REPO_ROOT = resolve(import.meta.dir, '..', '..', '..', '..')
180
+ const REPO_KNOWN_HOSTS = join(REPO_ROOT, '.github', 'known_hosts')
181
+ const PACKAGED_RESOURCE = resolve(import.meta.dir, '..', 'resources', 'known_hosts')
182
+
183
+ it('src/resources/known_hosts exists in the package source tree', () => {
184
+ expect(existsSync(PACKAGED_RESOURCE)).toBe(true)
185
+ })
186
+
187
+ it('src/resources/known_hosts matches .github/known_hosts (no drift)', async () => {
188
+ const repoContent = await Bun.file(REPO_KNOWN_HOSTS).text()
189
+ const packagedContent = await Bun.file(PACKAGED_RESOURCE).text()
190
+ expect(packagedContent).toBe(repoContent)
191
+ })
192
+ })
@@ -0,0 +1,76 @@
1
+ import {existsSync} from 'node:fs'
2
+ import {join, resolve} from 'node:path'
3
+
4
+ const FAIL_CLOSED_ERROR =
5
+ 'Pinned SSH known_hosts file not found; reinstall @marcusrbrown/infra or run from the repo checkout'
6
+
7
+ /**
8
+ * Resolve the path to the repo-pinned `.github/known_hosts` file.
9
+ *
10
+ * SSH commands in this CLI use `StrictHostKeyChecking=yes` but the invoking
11
+ * user's `~/.ssh/known_hosts` may not contain the pinned host keys for
12
+ * `metrics.fro.bot`, `gateway.fro.bot`, etc. The repo ships authoritative
13
+ * host keys in `.github/known_hosts`. When that file exists, SSH commands
14
+ * should use it via `UserKnownHostsFile=<path>` so host verification succeeds
15
+ * without weakening `StrictHostKeyChecking`.
16
+ *
17
+ * @param repoRoot - Optional override for the repo root directory. Defaults to
18
+ * the actual repo root resolved relative to this file's location. Pass a
19
+ * custom path in tests to avoid touching the real filesystem.
20
+ * @returns Absolute path to `.github/known_hosts` if it exists, otherwise null.
21
+ */
22
+ export function resolveRepoKnownHostsPath(repoRoot?: string): string | null {
23
+ // This file lives at packages/cli/src/lib/known-hosts.ts
24
+ // Repo root is 4 levels up: lib → src → cli → packages → repo root
25
+ const base = repoRoot ?? resolve(import.meta.dir, '..', '..', '..', '..')
26
+ const candidate = join(base, '.github', 'known_hosts')
27
+ return existsSync(candidate) ? candidate : null
28
+ }
29
+
30
+ /**
31
+ * Resolve the pinned known_hosts file from either layout:
32
+ *
33
+ * 1. Repo checkout: `.github/known_hosts` at the monorepo root (4 levels up
34
+ * from `src/lib/`).
35
+ * 2. Installed package: `src/resources/known_hosts` bundled alongside the
36
+ * source (1 level up from `src/lib/`, then into `resources/`).
37
+ *
38
+ * Prefers the repo checkout layout when both exist. Throws a clear error if
39
+ * neither is found — fail-closed, never silently falls back to user
40
+ * `~/.ssh/known_hosts`.
41
+ *
42
+ * @param libDir - Override for the directory containing this file (for tests).
43
+ * Defaults to `import.meta.dir`.
44
+ * @returns Absolute path to the pinned known_hosts file.
45
+ * @throws Error if no pinned known_hosts file is found.
46
+ */
47
+ export function resolveKnownHostsPath(libDir?: string): string {
48
+ const dir = libDir ?? import.meta.dir
49
+
50
+ // Layout 1: repo checkout — walk up 4 levels from src/lib to repo root
51
+ const repoRoot = resolve(dir, '..', '..', '..', '..')
52
+ const repoCandidate = join(repoRoot, '.github', 'known_hosts')
53
+ if (existsSync(repoCandidate)) return repoCandidate
54
+
55
+ // Layout 2: installed package — src/resources/known_hosts is 1 level up from src/lib
56
+ const packagedCandidate = resolve(dir, '..', 'resources', 'known_hosts')
57
+ if (existsSync(packagedCandidate)) return packagedCandidate
58
+
59
+ throw new Error(FAIL_CLOSED_ERROR)
60
+ }
61
+
62
+ /**
63
+ * Build the SSH `-o UserKnownHostsFile=<path>` argument pair for the
64
+ * repo-pinned known_hosts file.
65
+ *
66
+ * Returns `['-o', 'UserKnownHostsFile=<path>']` when the file is found.
67
+ * Throws a clear error if no pinned known_hosts file is available — fail-closed,
68
+ * never silently omits `UserKnownHostsFile` for SSH commands that rely on
69
+ * pinned verification.
70
+ *
71
+ * @param libDir - Override for the directory containing this file (for tests).
72
+ */
73
+ export function buildKnownHostsArgs(libDir?: string): string[] {
74
+ const path = resolveKnownHostsPath(libDir)
75
+ return ['-o', `UserKnownHostsFile=${path}`]
76
+ }
@@ -0,0 +1,28 @@
1
+ box.heatvision.co ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJNjkvxUDLB432BdSnrfYGgYyfUQxrdwV6sNqETNkyqs
2
+ box.heatvision.co ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBPnP2/6iqHUeOJX4+aVcFpaal0lLdT6sZrRSF95+MgyCY+p3K21Kgr2xp9eyGJ8splnaJv7Kwv1V7+HC3CMXoqM=
3
+ box.heatvision.co ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCpOziObL6mGk7hT113yo8kElbut5DU6xXoUYw8QWc4ix8iHxJV6T1hLl4wemFkSdA1jUhqhfR94Wdk7DtSGUiq9LidwfmL9ZsrAlnThLJeugZE1f+EtIye9hIqpzmmqMZvHeYFu3zbiB2/Xpc/krTmfKjIOsI0MeOKPAKEiFLG1DU9i5bWKs2ktC8TZCqp+Pfc4xt54Ug4lfLspbGbRZ96SQuxns+SSfyQW5peH13ZMl92/GGA2Tdh3bHausOYI6AvUimd/0mVc6b0bSWVS/k1bdb27rE/6TrZZ4ICXElGkgNndOsX5lFNcJdU+T6pJicA5fO8mpZ/ysakvyesmPigXxYsW3Q/tQkBoQXcKcxi+ItqU/ahJ1I4DObKnvJlu5htr3Gzo4dO5sxpDdufECqgicauFZzvXzD49XZvctRG01ZSy6ZmIekBsiTFHaTMO8zV88/AwVOT8CrgbrmIiwUG6ZfXCPFMztu4Z5IwxMWRnKG2GHWGNBb2/F5PDAz/Xrc=
4
+
5
+ # cliproxy droplet (147.182.133.210 / cliproxy.fro.bot)
6
+ cliproxy.fro.bot ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEq1Jbp0zCI12p7JIEtDTajBcCQyz0gmHls/BNXYTwIJ
7
+ cliproxy.fro.bot ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNIaI6OauhhSiho+UtAl9ZfP2NkbOo/Pw6i/beGyxl/Z/dqnigruDXWuhlitnAtpOvpeQXOcx3xKSOjX8iQkKXc=
8
+ cliproxy.fro.bot ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDcC+mMGekM5zzg3JcpVQD8Q+eRrhTPZptaG7sz1r5R5WVxdOboZ6xtjCQjCuqafig/PIYsZCnJrgmonW0gdKhpNbwobb9FBF8kFbeOa4rRKUoU7k8nmubWCn87L1GCGAtiX50YcxkxTTzZqHPZKiKRV38ExdfwR8uzeP2k8I/ay1uEjP5P1r/LCqHvKU8Dm3qF+NHZPGb0Ri/Iz9aLj3Mo73/BM8D7fhaG/3WXA+vRLwO+vqaIcGraYd5csNFJEXbpU7FBlnJmeKg4VPN7HJHsI69SGmmqGVHQ75A5e5AlNme/nCZmE5SptUy2mt/Y4/BIShDTfuyLG9Q36P1mC3PT0zZwdl4iAO3fciRo2R4/tSPh4nkFT0gEH79SQkZrV42uR5h6BNtf1/Bob8IAwdZL//1J97oc9EAfSntJQvSrYcMCL5ui+uK3S/V24jqYSsywQwfwGNMgDwDHQ66vP/CMbB86PnNmRajHDyF/vQK1ueSVX5sCEYEmQsx2Q6OPLxM=
9
+ |1|h7erWkle+dN76VrVVAiQPmOSee4=|LkpzPt8SQjsCbnPm+txg5Me1SYM= ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDcC+mMGekM5zzg3JcpVQD8Q+eRrhTPZptaG7sz1r5R5WVxdOboZ6xtjCQjCuqafig/PIYsZCnJrgmonW0gdKhpNbwobb9FBF8kFbeOa4rRKUoU7k8nmubWCn87L1GCGAtiX50YcxkxTTzZqHPZKiKRV38ExdfwR8uzeP2k8I/ay1uEjP5P1r/LCqHvKU8Dm3qF+NHZPGb0Ri/Iz9aLj3Mo73/BM8D7fhaG/3WXA+vRLwO+vqaIcGraYd5csNFJEXbpU7FBlnJmeKg4VPN7HJHsI69SGmmqGVHQ75A5e5AlNme/nCZmE5SptUy2mt/Y4/BIShDTfuyLG9Q36P1mC3PT0zZwdl4iAO3fciRo2R4/tSPh4nkFT0gEH79SQkZrV42uR5h6BNtf1/Bob8IAwdZL//1J97oc9EAfSntJQvSrYcMCL5ui+uK3S/V24jqYSsywQwfwGNMgDwDHQ66vP/CMbB86PnNmRajHDyF/vQK1ueSVX5sCEYEmQsx2Q6OPLxM=
10
+ |1|hNHRbNSfyK/BBEM32tiaVoRnRRA=|6KslRRxr9KNaKlbnzkdg1/tQokA= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNIaI6OauhhSiho+UtAl9ZfP2NkbOo/Pw6i/beGyxl/Z/dqnigruDXWuhlitnAtpOvpeQXOcx3xKSOjX8iQkKXc=
11
+ |1|NMQwWGuubNdklyTxDZ5rRgmaq0k=|J3xvvUOXXMYgC0vvsn1zvRDlSVQ= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEq1Jbp0zCI12p7JIEtDTajBcCQyz0gmHls/BNXYTwIJ
12
+
13
+ # gateway droplet (162.243.163.59 / gateway.fro.bot)
14
+ gateway.fro.bot ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC3g6QXCHGjaahf8hMBcRYtDWGAQTBpiUn7yrjVsbIb9U9K7d9yypLaTYzYKU0WFVtVFEi25CcRU5/xB6KOTWOSU/izPlgJ8ht7bNDWmSy6X1JaqxOOh2qQWWRklF+g8I1JwXO0X1Bmu7rFGFqaDEyWKChM19DvRi66x+kPYOeCeACwkYOA1L/SeUpF4H82lWGSiql4rzT7M/n/6RXa5gd4oPG1C8rkM8EqPnpFIZSUNv5/TAhGVsZu1RFJ4OvlZrNQba0LON6DUHTKuUE9o2r+VoaehhOuMDq5gOUAcDEDpPdyfCgnWvRkiLZ5osqw5ECcAGhIxXVYdpGhmX157UmLDKosowRbqPibetCJQ4sisa2WPm5jIrQnf/65mFOlkfIIYuzi0OBFG7VOaU0H8+AAOUQ9fnjseIQJV7GaD9F7viE+zgMoCB1fUlG04iIdJ2SgXRLt2+4uUfwuYSJHTvQUxs+6uMtzFQwHUZpBVE71I0g40beyulok7qiCxIBmLZ8=
15
+ gateway.fro.bot ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGCGPvUmuVd8X6/CxbvhTjjO0Ee6rS1D6XujRgmVl0zcHYIFUYLAFZVosN0RKvcmdmppm64PxH/cLAx7gvKF3t0=
16
+ gateway.fro.bot ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBUfj6wqjyh+BLIqHlRxfFlRpBuesAAnUYllFbWN9qzw
17
+
18
+ |1|8fBUi0jyLBNX5eBIp6NllCFFwVw=|gC1yH9h2zglwFYgKVhvslvDqgqE= ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC3g6QXCHGjaahf8hMBcRYtDWGAQTBpiUn7yrjVsbIb9U9K7d9yypLaTYzYKU0WFVtVFEi25CcRU5/xB6KOTWOSU/izPlgJ8ht7bNDWmSy6X1JaqxOOh2qQWWRklF+g8I1JwXO0X1Bmu7rFGFqaDEyWKChM19DvRi66x+kPYOeCeACwkYOA1L/SeUpF4H82lWGSiql4rzT7M/n/6RXa5gd4oPG1C8rkM8EqPnpFIZSUNv5/TAhGVsZu1RFJ4OvlZrNQba0LON6DUHTKuUE9o2r+VoaehhOuMDq5gOUAcDEDpPdyfCgnWvRkiLZ5osqw5ECcAGhIxXVYdpGhmX157UmLDKosowRbqPibetCJQ4sisa2WPm5jIrQnf/65mFOlkfIIYuzi0OBFG7VOaU0H8+AAOUQ9fnjseIQJV7GaD9F7viE+zgMoCB1fUlG04iIdJ2SgXRLt2+4uUfwuYSJHTvQUxs+6uMtzFQwHUZpBVE71I0g40beyulok7qiCxIBmLZ8=
19
+ |1|XsFKIlbq2aifqw1if+WGnRz0MBs=|LrHknyfL8diBLT1G7+ss8+9Kbaw= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGCGPvUmuVd8X6/CxbvhTjjO0Ee6rS1D6XujRgmVl0zcHYIFUYLAFZVosN0RKvcmdmppm64PxH/cLAx7gvKF3t0=
20
+ |1|Ytflq7+lWpR/TNEQ8C/wvirEIsc=|hcnjv25+apKJxy6UlAHhtR0baRI= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBUfj6wqjyh+BLIqHlRxfFlRpBuesAAnUYllFbWN9qzw
21
+
22
+ # umami droplet (167.99.236.120 / metrics.fro.bot)
23
+ metrics.fro.bot ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICRCMPA99bZJ1w+QdZPrTzLK06LF7t91tb0GbKfBkA72
24
+ metrics.fro.bot ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCma/Y9qRPlZ8v1wnQQBfwghQ0XbtSMn7QHVgIFGBjbO+Xqim7Lv9EArn58HIVACsZKMD0e9T90r70TDXYTWuZ6+RQ2x2soE6DcG/T/2wyan40hVtXLSCg8hx9DNjLR3RBwkiJcbdrYCqpDmi4kOuJAJ+b5JxUhJAkdF5M9xhUko59HtUm5I/j1T2Df60+7CdKMGZKMrCRios/q4lvgImuWS3aZuNQNuJiv6rfhX7L9LqezlJUgw1AbOzaqPXbZ9xpzt8yklGZQNa5I/CS6pEgn4/SKiRsetpgd4SCB9UuCR7ia/M0VU8itNPMZY11Pi+YwJKfJVZDtS+OSFkFxea8B1MyxDZkvOwnzYVx9xG6W48Oi06x4pQgJcqq5V52xM4upSlVtws5gTLNm/3KaJZ3kb7IK4HnTEUQrz/UShtyP1crsqwj5eoDnvcUfaMHx/YC/7LlQFp8AO8W/KEYWzlycJpZLqPrO/gqoiR0AG9NMP0SSRaD7M2pcQ4tKM1xyopE=
25
+ metrics.fro.bot ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOm6bQkB9dGaPJAVbk9IrVxZSM0KrkUikv8JgVrW0iJMKorJ233GaK03n2/5cuAJ/D3z6nN/pF6QGApWfm6s/JA=
26
+ |1|8o6qExp0klmE80pMU2CYvmbGqSQ=|064FdS/NWWEIU7EjmGBCwCek+DE= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICRCMPA99bZJ1w+QdZPrTzLK06LF7t91tb0GbKfBkA72
27
+ |1|1nwXP2iRwBq0GXyE94EXYJuaUCQ=|Wxoe6EvzjZbk02Hr4mx1YZOciUM= ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCma/Y9qRPlZ8v1wnQQBfwghQ0XbtSMn7QHVgIFGBjbO+Xqim7Lv9EArn58HIVACsZKMD0e9T90r70TDXYTWuZ6+RQ2x2soE6DcG/T/2wyan40hVtXLSCg8hx9DNjLR3RBwkiJcbdrYCqpDmi4kOuJAJ+b5JxUhJAkdF5M9xhUko59HtUm5I/j1T2Df60+7CdKMGZKMrCRios/q4lvgImuWS3aZuNQNuJiv6rfhX7L9LqezlJUgw1AbOzaqPXbZ9xpzt8yklGZQNa5I/CS6pEgn4/SKiRsetpgd4SCB9UuCR7ia/M0VU8itNPMZY11Pi+YwJKfJVZDtS+OSFkFxea8B1MyxDZkvOwnzYVx9xG6W48Oi06x4pQgJcqq5V52xM4upSlVtws5gTLNm/3KaJZ3kb7IK4HnTEUQrz/UShtyP1crsqwj5eoDnvcUfaMHx/YC/7LlQFp8AO8W/KEYWzlycJpZLqPrO/gqoiR0AG9NMP0SSRaD7M2pcQ4tKM1xyopE=
28
+ |1|lwRlQiSSd0h7iti76fvRXTtbkSw=|D//yCCMd1iZftlbjJ0NhKCCf5/8= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOm6bQkB9dGaPJAVbk9IrVxZSM0KrkUikv8JgVrW0iJMKorJ233GaK03n2/5cuAJ/D3z6nN/pF6QGApWfm6s/JA=