@marcusrbrown/infra 0.9.4 → 0.9.5

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.4",
3
+ "version": "0.9.5",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -239,6 +239,107 @@ describe('cliproxy login', () => {
239
239
  })
240
240
  })
241
241
 
242
+ describe('host forwarding into ssh argv', () => {
243
+ it('forwards resolved host from CLIPROXY_DOMAIN env var into ssh argv', async () => {
244
+ const {cliproxyLoginAction} = await import('./login')
245
+ const {spawnFn, calls} = makeSpawnOk(0)
246
+ setValidEnv()
247
+ process.env.CLIPROXY_DOMAIN = 'env-host.example.com'
248
+
249
+ await cliproxyLoginAction('claude', {}, spawnFn)
250
+
251
+ expect(calls).toHaveLength(1)
252
+ expect(calls[0]!.cmd).toContain('root@env-host.example.com')
253
+ })
254
+
255
+ it('forwards resolved host from --host option into ssh argv', async () => {
256
+ const {cliproxyLoginAction} = await import('./login')
257
+ const {spawnFn, calls} = makeSpawnOk(0)
258
+ setValidEnv()
259
+ delete process.env.CLIPROXY_DOMAIN
260
+
261
+ await cliproxyLoginAction('claude', {host: 'flag-host.example.com'}, spawnFn)
262
+
263
+ expect(calls).toHaveLength(1)
264
+ expect(calls[0]!.cmd).toContain('root@flag-host.example.com')
265
+ })
266
+ })
267
+
268
+ describe('non-zero exit propagation', () => {
269
+ it('throws with exit code in message when remote ssh exits non-zero', async () => {
270
+ const {cliproxyLoginAction} = await import('./login')
271
+ const {spawnFn, calls} = makeSpawnOk(17)
272
+ setValidEnv()
273
+
274
+ await expect(cliproxyLoginAction('claude', {}, spawnFn)).rejects.toThrow(
275
+ 'Remote login command failed with exit code 17',
276
+ )
277
+ expect(calls).toHaveLength(1)
278
+ })
279
+ })
280
+
281
+ describe('ssh spawn contract', () => {
282
+ it('happy path — claude: spawn argv and options match ssh boundary contract', async () => {
283
+ const {cliproxyLoginAction} = await import('./login')
284
+ const {spawnFn, calls} = makeSpawnOk(0)
285
+ setValidEnv()
286
+ process.env.SSH_AUTH_SOCK = '/tmp/contract-test.sock'
287
+ const capturedPath = process.env.PATH!
288
+ const capturedHome = process.env.HOME!
289
+
290
+ await cliproxyLoginAction('claude', {}, spawnFn)
291
+
292
+ expect(calls).toHaveLength(1)
293
+ const {cmd, opts} = calls[0]!
294
+ const spawnOpts = opts as Parameters<SpawnFn>[1]
295
+
296
+ // SSH flags
297
+ expect(cmd).toContain('-tt')
298
+ expect(cmd).toContain('BatchMode=yes')
299
+ expect(cmd).toContain('ConnectTimeout=10')
300
+
301
+ // stdio inheritance
302
+ expect(spawnOpts.stdin).toBe('inherit')
303
+ expect(spawnOpts.stdout).toBe('inherit')
304
+ expect(spawnOpts.stderr).toBe('inherit')
305
+
306
+ // forwarded env vars
307
+ expect(spawnOpts.env.PATH).toBe(capturedPath)
308
+ expect(spawnOpts.env.HOME).toBe(capturedHome)
309
+ expect(spawnOpts.env.SSH_AUTH_SOCK).toBe('/tmp/contract-test.sock')
310
+ })
311
+
312
+ it('happy path — codex: spawn argv and options match ssh boundary contract', async () => {
313
+ const {cliproxyLoginAction} = await import('./login')
314
+ const {spawnFn, calls} = makeSpawnOk(0)
315
+ setValidEnv()
316
+ process.env.SSH_AUTH_SOCK = '/tmp/contract-test.sock'
317
+ const capturedPath = process.env.PATH!
318
+ const capturedHome = process.env.HOME!
319
+
320
+ await cliproxyLoginAction('codex', {}, spawnFn)
321
+
322
+ expect(calls).toHaveLength(1)
323
+ const {cmd, opts} = calls[0]!
324
+ const spawnOpts = opts as Parameters<SpawnFn>[1]
325
+
326
+ // SSH flags
327
+ expect(cmd).toContain('-tt')
328
+ expect(cmd).toContain('BatchMode=yes')
329
+ expect(cmd).toContain('ConnectTimeout=10')
330
+
331
+ // stdio inheritance
332
+ expect(spawnOpts.stdin).toBe('inherit')
333
+ expect(spawnOpts.stdout).toBe('inherit')
334
+ expect(spawnOpts.stderr).toBe('inherit')
335
+
336
+ // forwarded env vars
337
+ expect(spawnOpts.env.PATH).toBe(capturedPath)
338
+ expect(spawnOpts.env.HOME).toBe(capturedHome)
339
+ expect(spawnOpts.env.SSH_AUTH_SOCK).toBe('/tmp/contract-test.sock')
340
+ })
341
+ })
342
+
242
343
  describe('anti-phishing notice', () => {
243
344
  let logLines: string[]
244
345
  let originalLog: typeof console.log
@@ -37,7 +37,7 @@ describe('formatDryRunPreview', () => {
37
37
  expect(preview).toContain('Planned secrets:')
38
38
  expect(preview).toContain('OPENCODE_AUTH_JSON')
39
39
  expect(preview).toContain('OPENCODE_CONFIG')
40
- expect(preview).toContain('OMO_PROVIDERS')
40
+ expect(preview).not.toContain('OMO_PROVIDERS')
41
41
  })
42
42
 
43
43
  it('renders planned variables', () => {
@@ -123,7 +123,6 @@ describe('getHarnessTemplate', () => {
123
123
  expect(template.secrets.map((entry: SecretAssignment) => entry.name)).toEqual([
124
124
  'OPENCODE_AUTH_JSON',
125
125
  'OPENCODE_CONFIG',
126
- 'OMO_PROVIDERS',
127
126
  ])
128
127
  expect(template.variables.map((entry: VariableAssignment) => entry.name)).toEqual(['FRO_BOT_MODEL'])
129
128
  })
@@ -135,11 +134,11 @@ describe('getHarnessTemplate', () => {
135
134
  expect(modelEntry?.value).toMatch(/^anthropic\//)
136
135
  })
137
136
 
138
- it('uses the expected OMO_PROVIDERS default value', () => {
137
+ it('does not include an OMO_PROVIDERS secret', () => {
139
138
  const template = getHarnessTemplate('opencode', {keyValue: 'sk-test'})
140
- const providersEntry = template.secrets.find((entry: SecretAssignment) => entry.name === 'OMO_PROVIDERS')
139
+ const secretNames = template.secrets.map((entry: SecretAssignment) => entry.name)
141
140
 
142
- expect(providersEntry?.value).toBe('claude-max20')
141
+ expect(secretNames).not.toContain('OMO_PROVIDERS')
143
142
  })
144
143
 
145
144
  it('writes an OPENCODE_CONFIG baseURL with the /v1 suffix', () => {
@@ -183,11 +182,11 @@ describe('getHarnessTemplate provider-aware', () => {
183
182
  expect(configEntry?.value).toBe(ANTHROPIC_ONLY_CONFIG)
184
183
  })
185
184
 
186
- it('no providers/model args → OMO_PROVIDERS is claude-max20', () => {
185
+ it('no providers/model args → OMO_PROVIDERS is not emitted', () => {
187
186
  const template = getHarnessTemplate('opencode', {keyValue: 'test-key'})
188
- const entry = template.secrets.find((e: SecretAssignment) => e.name === 'OMO_PROVIDERS')
187
+ const secretNames = template.secrets.map((e: SecretAssignment) => e.name)
189
188
 
190
- expect(entry?.value).toBe('claude-max20')
189
+ expect(secretNames).not.toContain('OMO_PROVIDERS')
191
190
  })
192
191
 
193
192
  it('no providers/model args → FRO_BOT_MODEL is anthropic/claude-sonnet-4-6', () => {
@@ -234,15 +233,15 @@ describe('getHarnessTemplate provider-aware', () => {
234
233
  expect(configEntry?.value).toBe('{"provider":{"openai":{"options":{"baseURL":"https://cliproxy.fro.bot/v1"}}}}')
235
234
  })
236
235
 
237
- it("providers: ['openai'], model: 'openai/gpt-5.4-mini' → OMO_PROVIDERS is openai", () => {
236
+ it("providers: ['openai'], model: 'openai/gpt-5.4-mini' → OMO_PROVIDERS is not emitted", () => {
238
237
  const template = getHarnessTemplate('opencode', {
239
238
  keyValue: 'sk-openai-key',
240
239
  providers: ['openai'],
241
240
  model: 'openai/gpt-5.4-mini',
242
241
  })
243
- const entry = template.secrets.find((e: SecretAssignment) => e.name === 'OMO_PROVIDERS')
242
+ const secretNames = template.secrets.map((e: SecretAssignment) => e.name)
244
243
 
245
- expect(entry?.value).toBe('openai')
244
+ expect(secretNames).not.toContain('OMO_PROVIDERS')
246
245
  })
247
246
 
248
247
  it("providers: ['openai'], model: 'openai/gpt-5.4-mini' → FRO_BOT_MODEL is openai/gpt-5.4-mini", () => {
@@ -294,15 +293,15 @@ describe('getHarnessTemplate provider-aware', () => {
294
293
  )
295
294
  })
296
295
 
297
- it("providers: ['anthropic', 'openai'] → OMO_PROVIDERS is claude-max20,openai", () => {
296
+ it("providers: ['anthropic', 'openai'] → OMO_PROVIDERS is not emitted", () => {
298
297
  const template = getHarnessTemplate('opencode', {
299
298
  keyValue: 'sk-dual',
300
299
  providers: ['anthropic', 'openai'],
301
300
  model: 'openai/gpt-5.4-mini',
302
301
  })
303
- const entry = template.secrets.find((e: SecretAssignment) => e.name === 'OMO_PROVIDERS')
302
+ const secretNames = template.secrets.map((e: SecretAssignment) => e.name)
304
303
 
305
- expect(entry?.value).toBe('claude-max20,openai')
304
+ expect(secretNames).not.toContain('OMO_PROVIDERS')
306
305
  })
307
306
 
308
307
  it("providers: ['anthropic', 'openai'] → FRO_BOT_MODEL is the supplied model", () => {
@@ -25,13 +25,6 @@ export interface HarnessTemplate {
25
25
 
26
26
  const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
27
27
 
28
- // OMO_PROVIDERS token map — template/harness concern, not a provider concern.
29
- // Keyed by ProviderId but lives here because it drives harness template construction.
30
- const OMO_TOKEN: Record<ProviderId, string> = {
31
- anthropic: 'claude-max20',
32
- openai: 'openai',
33
- }
34
-
35
28
  export function stripTrailingSlash(value: string): string {
36
29
  return value.endsWith('/') ? value.slice(0, -1) : value
37
30
  }
@@ -78,8 +71,6 @@ export function getHarnessTemplate(
78
71
  providerConfig[p] = {options: {baseURL: `${baseUrl}/v1`}}
79
72
  }
80
73
 
81
- const omoProviders = providers.map(p => OMO_TOKEN[p]).join(',')
82
-
83
74
  return {
84
75
  secrets: [
85
76
  {
@@ -90,10 +81,6 @@ export function getHarnessTemplate(
90
81
  name: 'OPENCODE_CONFIG',
91
82
  value: JSON.stringify({provider: providerConfig}),
92
83
  },
93
- {
94
- name: 'OMO_PROVIDERS',
95
- value: omoProviders,
96
- },
97
84
  ],
98
85
  variables: [
99
86
  {
@@ -230,14 +230,36 @@ describe('cliproxy setup helpers', () => {
230
230
  expect(Object.keys(dualResult)).toEqual(['kind', 'stepsWithGaps'])
231
231
  })
232
232
 
233
- it('REQUIRED_OPENCODE_INPUTS covers exactly auth-json, opencode-config, omo-providers, model (no enable-omo)', () => {
234
- // Infer the required inputs from fixture-based testing: a workflow with exactly
235
- // these four inputs and no others (besides github-token and prompt) passes with zero gaps.
236
- const result = analyzeFroBotWorkflow(WORKFLOW_WITH_OPENAI_MODEL)
233
+ it('REQUIRED_OPENCODE_INPUTS covers exactly auth-json, opencode-config, model (no omo-providers, no enable-omo)', () => {
234
+ // A workflow that wires auth-json, opencode-config, model (but NOT omo-providers) must
235
+ // pass with zero gaps — omo-providers is vestigial and must not be required.
236
+ const workflowWithoutOmo = ` - uses: fro-bot/agent@abc123
237
+ with:
238
+ github-token: \${{ secrets.FRO_BOT_PAT }}
239
+ auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
240
+ model: \${{ vars.FRO_BOT_MODEL }}
241
+ opencode-config: \${{ secrets.OPENCODE_CONFIG }}
242
+ prompt: \${{ env.PROMPT }}
243
+ `
244
+ const result = analyzeFroBotWorkflow(workflowWithoutOmo)
245
+
246
+ expect(result.kind).toBe('analyzed')
247
+ if (result.kind !== 'analyzed') throw new Error('unreachable')
248
+ // Zero gaps confirms omo-providers is NOT required.
249
+ expect(result.stepsWithGaps).toEqual([])
250
+ })
251
+
252
+ it('a workflow missing only omo-providers (but with auth-json, opencode-config, model) is not flagged', () => {
253
+ const workflowWithoutOmo = ` - uses: fro-bot/agent@abc123
254
+ with:
255
+ auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
256
+ opencode-config: \${{ secrets.OPENCODE_CONFIG }}
257
+ model: \${{ vars.FRO_BOT_MODEL }}
258
+ `
259
+ const result = analyzeFroBotWorkflow(workflowWithoutOmo)
237
260
 
238
261
  expect(result.kind).toBe('analyzed')
239
262
  if (result.kind !== 'analyzed') throw new Error('unreachable')
240
- // Zero gaps confirms the four inputs in the fixture are sufficient — enable-omo is NOT required.
241
263
  expect(result.stepsWithGaps).toEqual([])
242
264
  })
243
265
  })
@@ -23,7 +23,7 @@ export type FroBotWorkflowCheck =
23
23
  // For proxy-routed providers configured via OPENCODE_CONFIG.provider.<name>.options.baseURL,
24
24
  // the fro-bot/agent action honors auth.json directly (regardless of oMo state).
25
25
  // Source: fro-bot/agent@v0.44.3+ action.yaml lines 99-104; verified by librarian 2026-05-25.
26
- const REQUIRED_OPENCODE_INPUTS = ['auth-json', 'opencode-config', 'omo-providers', 'model'] as const
26
+ const REQUIRED_OPENCODE_INPUTS = ['auth-json', 'opencode-config', 'model'] as const
27
27
  type RequiredOpencodeInput = (typeof REQUIRED_OPENCODE_INPUTS)[number]
28
28
 
29
29
  // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -129,7 +129,6 @@ export function formatWorkflowSnippet(missingInputs: readonly string[]): string
129
129
  const inputMap = {
130
130
  'auth-json': 'auth-json: ${{ secrets.OPENCODE_AUTH_JSON }}',
131
131
  'opencode-config': 'opencode-config: ${{ secrets.OPENCODE_CONFIG }}',
132
- 'omo-providers': 'omo-providers: ${{ secrets.OMO_PROVIDERS }}',
133
132
  model: 'model: ${{ vars.FRO_BOT_MODEL }}',
134
133
  } satisfies Record<RequiredOpencodeInput, string>
135
134
  /* eslint-enable no-template-curly-in-string */
@@ -2038,7 +2038,7 @@ describe('post-write readback verification', () => {
2038
2038
 
2039
2039
  it('happy path: readback returns all written names → no new warning emitted', async () => {
2040
2040
  // Pre-write list is empty; post-write readback returns all written names
2041
- // The opencode harness writes: OPENCODE_AUTH_JSON, OPENCODE_CONFIG, OMO_PROVIDERS (secrets)
2041
+ // The opencode harness writes: OPENCODE_AUTH_JSON, OPENCODE_CONFIG (secrets)
2042
2042
  // and FRO_BOT_MODEL (variable)
2043
2043
  let callCount = 0
2044
2044
  const {deps} = makeDeps(async (_repo, kind) => {
@@ -2048,7 +2048,7 @@ describe('post-write readback verification', () => {
2048
2048
  return []
2049
2049
  }
2050
2050
  // Post-write readback: return all written names
2051
- if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG', 'OMO_PROVIDERS']
2051
+ if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG']
2052
2052
  return ['FRO_BOT_MODEL']
2053
2053
  })
2054
2054
 
@@ -2068,7 +2068,7 @@ describe('post-write readback verification', () => {
2068
2068
  callCount++
2069
2069
  if (callCount <= 2) return []
2070
2070
  // Post-write: secret readback missing OPENCODE_AUTH_JSON
2071
- if (kind === 'secret') return ['OPENCODE_CONFIG', 'OMO_PROVIDERS']
2071
+ if (kind === 'secret') return ['OPENCODE_CONFIG']
2072
2072
  return ['FRO_BOT_MODEL']
2073
2073
  })
2074
2074
 
@@ -2088,7 +2088,7 @@ describe('post-write readback verification', () => {
2088
2088
  const {deps} = makeDeps(async (_repo, kind) => {
2089
2089
  callCount++
2090
2090
  if (callCount <= 2) return []
2091
- if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG', 'OMO_PROVIDERS']
2091
+ if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG']
2092
2092
  // Variable readback missing FRO_BOT_MODEL
2093
2093
  return []
2094
2094
  })
@@ -2102,12 +2102,12 @@ describe('post-write readback verification', () => {
2102
2102
  })
2103
2103
 
2104
2104
  it('partial visibility: readback shows some but not all written names → warning lists exactly the absent names', async () => {
2105
- // Post-write: OPENCODE_CONFIG and OMO_PROVIDERS present, OPENCODE_AUTH_JSON absent
2105
+ // Post-write: OPENCODE_CONFIG present, OPENCODE_AUTH_JSON absent
2106
2106
  let callCount = 0
2107
2107
  const {deps} = makeDeps(async (_repo, kind) => {
2108
2108
  callCount++
2109
2109
  if (callCount <= 2) return []
2110
- if (kind === 'secret') return ['OPENCODE_CONFIG', 'OMO_PROVIDERS'] // OPENCODE_AUTH_JSON absent
2110
+ if (kind === 'secret') return ['OPENCODE_CONFIG'] // OPENCODE_AUTH_JSON absent
2111
2111
  return ['FRO_BOT_MODEL']
2112
2112
  })
2113
2113
 
@@ -2117,9 +2117,8 @@ describe('post-write readback verification', () => {
2117
2117
  expect(mismatchWarnings.length).toBeGreaterThan(0)
2118
2118
  // Must name OPENCODE_AUTH_JSON (absent)
2119
2119
  expect(mismatchWarnings.some(m => m.includes('OPENCODE_AUTH_JSON'))).toBe(true)
2120
- // Must NOT name OPENCODE_CONFIG or OMO_PROVIDERS (they ARE present)
2120
+ // Must NOT name OPENCODE_CONFIG (it IS present)
2121
2121
  expect(mismatchWarnings.some(m => m.includes('OPENCODE_CONFIG'))).toBe(false)
2122
- expect(mismatchWarnings.some(m => m.includes('OMO_PROVIDERS'))).toBe(false)
2123
2122
  })
2124
2123
 
2125
2124
  it('cannot verify: listExistingGhNames throws on post-write call → softer warning, command does NOT throw, rollback NOT fired', async () => {
@@ -2268,7 +2267,7 @@ describe('post-write readback verification', () => {
2268
2267
  return []
2269
2268
  }
2270
2269
  // Post-write readback: all names present
2271
- if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG', 'OMO_PROVIDERS']
2270
+ if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG']
2272
2271
  return ['FRO_BOT_MODEL']
2273
2272
  })
2274
2273
 
@@ -2338,7 +2337,7 @@ describe('non-interactive overwrite warning concurrency caveat', () => {
2338
2337
  it('--force overwrite with a collision present → warning carries the last-write-wins concurrency caveat', async () => {
2339
2338
  // OPENCODE_AUTH_JSON already exists → collision on the opencode secret set → overwrite warning fires.
2340
2339
  const deps = makeDeps(async (_repo, kind) => {
2341
- if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG', 'OMO_PROVIDERS']
2340
+ if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG']
2342
2341
  return ['FRO_BOT_MODEL']
2343
2342
  })
2344
2343
 
@@ -350,7 +350,7 @@ export async function buildNonInteractivePlan(
350
350
  // Check BEFORE verifyModelsAvailable to avoid a network call when the gate will reject anyway.
351
351
  if (requiresDestructiveProviderChangeConfirmation(providers) && !options.force) {
352
352
  throw new Error(
353
- `Refusing destructive provider change on ${options.repo ?? ''} without --force. Selected providers ${providers.join(', ')} would overwrite existing GitHub secret values (OPENCODE_AUTH_JSON, OPENCODE_CONFIG, OMO_PROVIDERS, FRO_BOT_MODEL). Note: --force authorizes overwriting these GitHub secret values; it does NOT rotate the underlying CLIProxyAPI proxy bearer token (which is preserved byte-for-byte when --key is supplied).`,
353
+ `Refusing destructive provider change on ${options.repo ?? ''} without --force. Selected providers ${providers.join(', ')} would overwrite existing GitHub secret values (OPENCODE_AUTH_JSON, OPENCODE_CONFIG, FRO_BOT_MODEL). Note: --force authorizes overwriting these GitHub secret values; it does NOT rotate the underlying CLIProxyAPI proxy bearer token (which is preserved byte-for-byte when --key is supplied).`,
354
354
  )
355
355
  }
356
356