@marcusrbrown/infra 0.12.3 → 0.13.0

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.12.3",
3
+ "version": "0.13.0",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -28,6 +28,7 @@ Commands:
28
28
 
29
29
  --url [url] Base URL for CLIProxyAPI health checks. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.
30
30
  --key [key] Management API key. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.
31
+ --api-key [key] Downstream API key (bearer) for the upstream provider-auth probe. Falls back to CLIPROXY_API_KEY. Skipped when absent.
31
32
  --verbose Enable verbose output for all commands
32
33
 
33
34
 
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
3
3
  import {createCapturedCtx, expectCapturedToInclude, MockProcessExit} from '../../__test__/mcp-ctx-fixture'
4
4
  import {
5
5
  checkHttpReachability,
6
+ checkProviderAuth,
6
7
  checkUsageStats,
7
8
  checkVersion,
8
9
  cliproxyStatusAction,
@@ -839,3 +840,318 @@ describe('cliproxyStatusAction — ambient key does not follow agent-supplied UR
839
840
  expect(output).toMatch(/CLIPROXY_MANAGEMENT_KEY|no key|skipping/i)
840
841
  })
841
842
  })
843
+
844
+ // ─── checkProviderAuth ────────────────────────────────────────────────────────
845
+
846
+ describe('checkProviderAuth', () => {
847
+ afterEach(() => {
848
+ globalThis.fetch = originalFetch
849
+ })
850
+
851
+ it('returns ok when POST /v1/chat/completions returns 200', async () => {
852
+ globalThis.fetch = createFetchImplementation(
853
+ async () =>
854
+ new Response(JSON.stringify({choices: [{message: {content: 'pong'}}]}), {
855
+ status: 200,
856
+ headers: {'content-type': 'application/json'},
857
+ }),
858
+ )
859
+
860
+ const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
861
+
862
+ expect(result.level).toBe('ok')
863
+ expect(result.title).toBe('Upstream provider auth (anthropic)')
864
+ expect(result.summary).toContain('anthropic route OK')
865
+ expect(result.summary).toContain('claude-sonnet-4-6')
866
+ })
867
+
868
+ it('returns error when POST returns 401', async () => {
869
+ globalThis.fetch = createFetchImplementation(async () => new Response('Unauthorized', {status: 401}))
870
+
871
+ const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
872
+
873
+ expect(result.level).toBe('error')
874
+ expect(result.summary).toContain('401')
875
+ expect(result.summary).toContain('cliproxy login claude')
876
+ })
877
+
878
+ it('returns error when POST returns 503 with auth_unavailable body', async () => {
879
+ globalThis.fetch = createFetchImplementation(
880
+ async () => new Response('auth_unavailable: no auth available (providers=claude)', {status: 503}),
881
+ )
882
+
883
+ const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
884
+
885
+ expect(result.level).toBe('error')
886
+ expect(result.summary).toContain('503')
887
+ expect(result.summary).toContain('cliproxy login claude')
888
+ })
889
+
890
+ it('returns error when POST returns 503 with "no auth available" in body', async () => {
891
+ globalThis.fetch = createFetchImplementation(async () => new Response('no auth available', {status: 503}))
892
+
893
+ const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
894
+
895
+ expect(result.level).toBe('error')
896
+ expect(result.summary).toContain('cliproxy login claude')
897
+ })
898
+
899
+ it('returns error when POST returns 503 with "providers=claude" in body', async () => {
900
+ globalThis.fetch = createFetchImplementation(
901
+ async () => new Response('{"error":"providers=claude unavailable"}', {status: 503}),
902
+ )
903
+
904
+ const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
905
+
906
+ expect(result.level).toBe('error')
907
+ expect(result.summary).toContain('cliproxy login claude')
908
+ })
909
+
910
+ it('returns warning when POST returns 503 without auth-unavailable markers', async () => {
911
+ globalThis.fetch = createFetchImplementation(async () => new Response('Service Unavailable', {status: 503}))
912
+
913
+ const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
914
+
915
+ expect(result.level).toBe('warning')
916
+ expect(result.summary).toContain('503')
917
+ expect(result.summary).not.toContain('cliproxy login claude')
918
+ })
919
+
920
+ it('returns warning when POST returns other non-2xx (e.g. 500)', async () => {
921
+ globalThis.fetch = createFetchImplementation(async () => new Response('Internal Server Error', {status: 500}))
922
+
923
+ const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
924
+
925
+ expect(result.level).toBe('warning')
926
+ expect(result.summary).toContain('500')
927
+ })
928
+
929
+ it('returns warning (not error) when fetch throws (network/timeout)', async () => {
930
+ globalThis.fetch = createFetchImplementation(async () => {
931
+ throw new Error('The operation was aborted due to timeout')
932
+ })
933
+
934
+ const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
935
+
936
+ expect(result.level).toBe('warning')
937
+ expect(result.summary).toContain('Anthropic probe failed')
938
+ expect(result.summary).toContain('aborted')
939
+ })
940
+
941
+ it('never includes the apiKey in summary or details', async () => {
942
+ const secretKey = 'super-secret-api-key-do-not-leak'
943
+
944
+ globalThis.fetch = createFetchImplementation(async () => new Response('Unauthorized', {status: 401}))
945
+
946
+ const result = await checkProviderAuth('https://cliproxy.example.com', secretKey)
947
+
948
+ expect(result.summary).not.toContain(secretKey)
949
+ const detailsText = (result.details ?? []).join('\n')
950
+ expect(detailsText).not.toContain(secretKey)
951
+ })
952
+
953
+ it('uses the default model claude-sonnet-4-6 when no model override given', async () => {
954
+ let capturedBody = ''
955
+
956
+ globalThis.fetch = createFetchImplementation(async (_url, init) => {
957
+ capturedBody = typeof init?.body === 'string' ? init.body : ''
958
+ return new Response(JSON.stringify({choices: []}), {
959
+ status: 200,
960
+ headers: {'content-type': 'application/json'},
961
+ })
962
+ })
963
+
964
+ await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
965
+
966
+ expect(capturedBody).toContain('claude-sonnet-4-6')
967
+ })
968
+
969
+ it('uses the model override when options.model is provided', async () => {
970
+ let capturedBody = ''
971
+
972
+ globalThis.fetch = createFetchImplementation(async (_url, init) => {
973
+ capturedBody = typeof init?.body === 'string' ? init.body : ''
974
+ return new Response(JSON.stringify({choices: []}), {
975
+ status: 200,
976
+ headers: {'content-type': 'application/json'},
977
+ })
978
+ })
979
+
980
+ await checkProviderAuth('https://cliproxy.example.com', 'test-api-key', {model: 'claude-opus-4-5'})
981
+
982
+ expect(capturedBody).toContain('claude-opus-4-5')
983
+ expect(capturedBody).not.toContain('claude-sonnet-4-6')
984
+ })
985
+
986
+ it('includes verbose details (URL, model, status) when verbose=true', async () => {
987
+ globalThis.fetch = createFetchImplementation(
988
+ async () =>
989
+ new Response(JSON.stringify({choices: []}), {
990
+ status: 200,
991
+ headers: {'content-type': 'application/json'},
992
+ }),
993
+ )
994
+
995
+ const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key', {verbose: true})
996
+
997
+ expect(result.details).toBeDefined()
998
+ const detailsText = (result.details ?? []).join('\n')
999
+ expect(detailsText).toContain('https://cliproxy.example.com')
1000
+ expect(detailsText).toContain('claude-sonnet-4-6')
1001
+ })
1002
+ })
1003
+
1004
+ // ─── cliproxyStatusAction — provider auth wiring ─────────────────────────────
1005
+
1006
+ describe('cliproxyStatusAction — provider auth probe wiring', () => {
1007
+ let originalEnv: Record<string, string | undefined>
1008
+
1009
+ beforeEach(() => {
1010
+ originalEnv = {
1011
+ CLIPROXY_URL: process.env.CLIPROXY_URL,
1012
+ CLIPROXY_MANAGEMENT_KEY: process.env.CLIPROXY_MANAGEMENT_KEY,
1013
+ CLIPROXY_API_KEY: process.env.CLIPROXY_API_KEY,
1014
+ }
1015
+ delete process.env.CLIPROXY_URL
1016
+ delete process.env.CLIPROXY_MANAGEMENT_KEY
1017
+ delete process.env.CLIPROXY_API_KEY
1018
+ })
1019
+
1020
+ afterEach(() => {
1021
+ for (const [k, v] of Object.entries(originalEnv)) {
1022
+ if (v === undefined) {
1023
+ delete process.env[k]
1024
+ } else {
1025
+ process.env[k] = v
1026
+ }
1027
+ }
1028
+ globalThis.fetch = originalFetch
1029
+ })
1030
+
1031
+ it('shows a warning (not error) when no provider key is available', async () => {
1032
+ globalThis.fetch = createFetchImplementation(async url => {
1033
+ if (url.includes('/healthz')) return new Response('ok', {status: 200})
1034
+ throw new Error(`Unexpected fetch: ${url}`)
1035
+ })
1036
+
1037
+ const {ctx, captured} = createCapturedCtx()
1038
+ await cliproxyStatusAction({url: 'https://cliproxy.example.com'}, ctx)
1039
+
1040
+ const output = [...captured.stdout, ...captured.stderr].join('\n')
1041
+ expect(output).toMatch(/CLIPROXY_API_KEY|skipping upstream provider probe/i)
1042
+ // Must be a warning, not an error — no exit(1) for missing key
1043
+ expect(captured.exit).toBeNull()
1044
+ })
1045
+
1046
+ it('runs the provider auth probe when --api-key is provided', async () => {
1047
+ const fetchedUrls: string[] = []
1048
+
1049
+ globalThis.fetch = createFetchImplementation(async url => {
1050
+ fetchedUrls.push(url)
1051
+ if (url.includes('/healthz')) return new Response('ok', {status: 200})
1052
+ if (url.includes('/v1/chat/completions'))
1053
+ return new Response(JSON.stringify({choices: []}), {
1054
+ status: 200,
1055
+ headers: {'content-type': 'application/json'},
1056
+ })
1057
+ throw new Error(`Unexpected fetch: ${url}`)
1058
+ })
1059
+
1060
+ const {ctx, captured} = createCapturedCtx()
1061
+ await cliproxyStatusAction({url: 'https://cliproxy.example.com', apiKey: 'my-api-key'}, ctx)
1062
+
1063
+ expect(fetchedUrls.some(u => u.includes('/v1/chat/completions'))).toBe(true)
1064
+ const output = [...captured.stdout, ...captured.stderr].join('\n')
1065
+ expect(output).toContain('Upstream provider auth')
1066
+ })
1067
+
1068
+ it('does NOT forward ambient CLIPROXY_API_KEY to an explicit --url override', async () => {
1069
+ process.env.CLIPROXY_API_KEY = 'ambient-api-key-secret'
1070
+
1071
+ const capturedAuthHeaders: string[] = []
1072
+
1073
+ globalThis.fetch = createFetchImplementation(async (url, init) => {
1074
+ const hdrs = init?.headers
1075
+ let auth: string | null = null
1076
+ if (hdrs instanceof Headers) {
1077
+ auth = hdrs.get('authorization')
1078
+ } else if (hdrs !== null && hdrs !== undefined && typeof hdrs === 'object') {
1079
+ const hdrsObj = hdrs as Record<string, string>
1080
+ auth = hdrsObj.authorization ?? hdrsObj.Authorization ?? null
1081
+ }
1082
+ if (auth) capturedAuthHeaders.push(auth)
1083
+
1084
+ if (url.includes('/healthz')) return new Response('ok', {status: 200})
1085
+ if (url.includes('/v1/chat/completions')) return new Response(JSON.stringify({choices: []}), {status: 200})
1086
+ return new Response('ok', {status: 200})
1087
+ })
1088
+
1089
+ const {ctx} = createCapturedCtx()
1090
+ // Explicit --url override to a non-trusted host — ambient key must NOT follow
1091
+ await cliproxyStatusAction({url: 'https://evil.example.com'}, ctx)
1092
+
1093
+ // The ambient API key must not appear in any Authorization header sent to evil host
1094
+ expect(capturedAuthHeaders.some(h => h.includes('ambient-api-key-secret'))).toBe(false)
1095
+ })
1096
+
1097
+ it('uses ambient CLIPROXY_API_KEY when URL is the trusted default', async () => {
1098
+ process.env.CLIPROXY_API_KEY = 'ambient-api-key-trusted'
1099
+
1100
+ const capturedAuthHeaders: string[] = []
1101
+
1102
+ globalThis.fetch = createFetchImplementation(async (url, init) => {
1103
+ const hdrs = init?.headers
1104
+ let auth: string | null = null
1105
+ if (hdrs instanceof Headers) {
1106
+ auth = hdrs.get('authorization')
1107
+ } else if (hdrs !== null && hdrs !== undefined && typeof hdrs === 'object') {
1108
+ const hdrsObj = hdrs as Record<string, string>
1109
+ auth = hdrsObj.authorization ?? hdrsObj.Authorization ?? null
1110
+ }
1111
+ if (auth) capturedAuthHeaders.push(auth)
1112
+
1113
+ if (url.includes('/healthz')) return new Response('ok', {status: 200})
1114
+ if (url.includes('/v1/chat/completions'))
1115
+ return new Response(JSON.stringify({choices: []}), {
1116
+ status: 200,
1117
+ headers: {'content-type': 'application/json'},
1118
+ })
1119
+ // Management endpoints — return ok so management checks don't block
1120
+ if (url.includes('/v0/management/config'))
1121
+ return new Response('{}', {status: 200, headers: {'content-type': 'application/json'}})
1122
+ if (url.includes('/v0/management/usage-queue'))
1123
+ return new Response('[]', {status: 200, headers: {'content-type': 'application/json'}})
1124
+ if (url.includes('/v0/management/latest-version'))
1125
+ return new Response(JSON.stringify({'latest-version': 'v1.0.0'}), {
1126
+ status: 200,
1127
+ headers: {'content-type': 'application/json'},
1128
+ })
1129
+ return new Response('ok', {status: 200})
1130
+ })
1131
+
1132
+ const {ctx} = createCapturedCtx()
1133
+ // Default trusted URL — ambient key SHOULD be used
1134
+ await cliproxyStatusAction({url: 'https://cliproxy.fro.bot'}, ctx)
1135
+
1136
+ expect(capturedAuthHeaders.some(h => h.includes('ambient-api-key-trusted'))).toBe(true)
1137
+ })
1138
+
1139
+ it('provider auth error (401) causes exit(1) — dead upstream is a real error', async () => {
1140
+ globalThis.fetch = createFetchImplementation(async url => {
1141
+ if (url.includes('/healthz')) return new Response('ok', {status: 200})
1142
+ if (url.includes('/v1/chat/completions')) return new Response('Unauthorized', {status: 401})
1143
+ throw new Error(`Unexpected fetch: ${url}`)
1144
+ })
1145
+
1146
+ const {ctx, captured} = createCapturedCtx()
1147
+ let threw: unknown
1148
+ try {
1149
+ await cliproxyStatusAction({url: 'https://cliproxy.example.com', apiKey: 'my-api-key'}, ctx)
1150
+ } catch (error) {
1151
+ threw = error
1152
+ }
1153
+
1154
+ expect(threw).toBeInstanceOf(MockProcessExit)
1155
+ expect(captured.exit?.code).toBe(1)
1156
+ })
1157
+ })
@@ -226,6 +226,113 @@ export async function checkVersion(baseUrl: string, key: string): Promise<CheckR
226
226
  }
227
227
  }
228
228
 
229
+ const DEFAULT_PROVIDER_MODEL = 'claude-sonnet-4-6'
230
+
231
+ export interface ProviderAuthOptions {
232
+ model?: string
233
+ verbose?: boolean
234
+ }
235
+
236
+ /**
237
+ * Probe the upstream provider auth by sending a minimal chat completion request.
238
+ * Returns a CheckResult — never throws.
239
+ *
240
+ * - 200 → ok
241
+ * - 401 OR (503 with auth-unavailable body markers) → error with remediation hint
242
+ * - other non-2xx → warning (don't fail status on unrelated upstream issues)
243
+ * - fetch throw (timeout/network) → warning (flaky probe should not fail status)
244
+ *
245
+ * The apiKey is NEVER included in summary or details output.
246
+ */
247
+ export async function checkProviderAuth(
248
+ baseUrl: string,
249
+ apiKey: string,
250
+ options?: ProviderAuthOptions,
251
+ ): Promise<CheckResult> {
252
+ const model = options?.model ?? DEFAULT_PROVIDER_MODEL
253
+ const verbose = options?.verbose === true
254
+ const endpoint = `${baseUrl}/v1/chat/completions`
255
+
256
+ try {
257
+ const response = await fetch(endpoint, {
258
+ method: 'POST',
259
+ headers: {
260
+ authorization: `Bearer ${apiKey}`,
261
+ 'content-type': 'application/json',
262
+ },
263
+ body: JSON.stringify({
264
+ model,
265
+ messages: [{role: 'user', content: 'ping'}],
266
+ max_tokens: 1,
267
+ }),
268
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
269
+ })
270
+
271
+ const details: string[] = []
272
+ if (verbose) {
273
+ details.push(`URL: ${endpoint}`)
274
+ details.push(`Model: ${model}`)
275
+ details.push(`Status: ${response.status}`)
276
+ }
277
+
278
+ if (response.ok) {
279
+ return {
280
+ title: 'Upstream provider auth (anthropic)',
281
+ level: 'ok',
282
+ summary: `anthropic route OK (${model})`,
283
+ details: details.length > 0 ? details : undefined,
284
+ }
285
+ }
286
+
287
+ // 401 is always an auth failure
288
+ if (response.status === 401) {
289
+ return {
290
+ title: 'Upstream provider auth (anthropic)',
291
+ level: 'error',
292
+ summary: `Anthropic upstream auth unavailable (${response.status}) — run: cliproxy login claude`,
293
+ details: details.length > 0 ? details : undefined,
294
+ }
295
+ }
296
+
297
+ // 503 may be auth_unavailable — check body for known markers
298
+ if (response.status === 503) {
299
+ let bodyText = ''
300
+ try {
301
+ bodyText = await response.text()
302
+ } catch {
303
+ // ignore body read failure
304
+ }
305
+
306
+ const isAuthUnavailable = /auth_unavailable|no auth available|providers=claude/i.test(bodyText)
307
+
308
+ if (isAuthUnavailable) {
309
+ return {
310
+ title: 'Upstream provider auth (anthropic)',
311
+ level: 'error',
312
+ summary: `Anthropic upstream auth unavailable (${response.status}) — run: cliproxy login claude`,
313
+ details: details.length > 0 ? details : undefined,
314
+ }
315
+ }
316
+ }
317
+
318
+ // Any other non-2xx: warning (don't fail status on unrelated upstream issues)
319
+ return {
320
+ title: 'Upstream provider auth (anthropic)',
321
+ level: 'warning',
322
+ summary: `Anthropic probe returned HTTP ${response.status}`,
323
+ details: details.length > 0 ? details : undefined,
324
+ }
325
+ } catch (error) {
326
+ const message = error instanceof Error ? error.message : String(error)
327
+ return {
328
+ title: 'Upstream provider auth (anthropic)',
329
+ level: 'warning',
330
+ summary: `Anthropic probe failed: ${message}`,
331
+ details: verbose ? [`URL: ${endpoint}`, `Model: ${model}`] : undefined,
332
+ }
333
+ }
334
+ }
335
+
229
336
  /**
230
337
  * Probe the management API with a cheap auth check before issuing parallel calls.
231
338
  * Returns null on success, or a CheckResult describing the auth failure.
@@ -377,6 +484,8 @@ export async function getCliproxyStatusSummary(baseUrl: string, key: string, ver
377
484
  export interface StatusOptions {
378
485
  url?: string
379
486
  key?: string
487
+ /** Downstream API key (bearer) for the upstream provider-auth probe. Maps from --api-key CLI flag. */
488
+ apiKey?: string
380
489
  verbose?: boolean
381
490
  }
382
491
 
@@ -396,6 +505,10 @@ export async function cliproxyStatusAction(options: StatusOptions, ctx: ActionCt
396
505
  // An ambient env key is only forwarded when the resolved baseUrl is trusted.
397
506
  const managementKey = options.key ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_MANAGEMENT_KEY)
398
507
 
508
+ // Provider (downstream) API key for the upstream provider-auth probe.
509
+ // Same trusted-URL guard: ambient CLIPROXY_API_KEY only follows to the trusted URL.
510
+ const providerKey = options.apiKey ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_API_KEY)
511
+
399
512
  ctx.console.log('CLIProxyAPI status')
400
513
  ctx.console.log('')
401
514
 
@@ -419,6 +532,17 @@ export async function cliproxyStatusAction(options: StatusOptions, ctx: ActionCt
419
532
  results.push(mgmt.usage, mgmt.version)
420
533
  }
421
534
 
535
+ // Upstream provider-auth probe — only when a downstream API key is available.
536
+ if (providerKey) {
537
+ results.push(await checkProviderAuth(baseUrl, providerKey, {verbose}))
538
+ } else {
539
+ results.push({
540
+ title: 'Upstream provider auth',
541
+ level: 'warning',
542
+ summary: 'CLIPROXY_API_KEY not set — skipping upstream provider probe.',
543
+ })
544
+ }
545
+
422
546
  for (const result of results) {
423
547
  printCheckResult(result, ctx)
424
548
  ctx.console.log('')
@@ -460,6 +584,14 @@ export function registerCliproxyStatus(cli: ReturnType<typeof goke>): void {
460
584
  '--key [key]',
461
585
  z.string().describe('Management API key. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.'),
462
586
  )
587
+ .option(
588
+ '--api-key [key]',
589
+ z
590
+ .string()
591
+ .describe(
592
+ 'Downstream API key (bearer) for the upstream provider-auth probe. Falls back to CLIPROXY_API_KEY. Skipped when absent.',
593
+ ),
594
+ )
463
595
  .option('--verbose', 'Enable verbose output for all commands')
464
596
  .action(cliproxyStatusAction)
465
597
  }