@marcusrbrown/infra 0.12.2 → 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
|
@@ -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
|
}
|
|
@@ -68,6 +68,8 @@ export function getGatewayDeployEnv(): Record<string, string> {
|
|
|
68
68
|
GATEWAY_OPERATOR_OAUTH_ALLOWED_RETURN_PATHS: process.env.GATEWAY_OPERATOR_OAUTH_ALLOWED_RETURN_PATHS ?? '',
|
|
69
69
|
GATEWAY_OPERATOR_OAUTH_STATE_TTL_MS: process.env.GATEWAY_OPERATOR_OAUTH_STATE_TTL_MS ?? '',
|
|
70
70
|
GATEWAY_OPERATOR_OAUTH_MAX_OUTSTANDING_ATTEMPTS: process.env.GATEWAY_OPERATOR_OAUTH_MAX_OUTSTANDING_ATTEMPTS ?? '',
|
|
71
|
+
GATEWAY_VPC_IP: process.env.GATEWAY_VPC_IP ?? '',
|
|
72
|
+
DASHBOARD_VPC_IP: process.env.DASHBOARD_VPC_IP ?? '',
|
|
71
73
|
GATEWAY_IMAGE_DIGEST: process.env.GATEWAY_IMAGE_DIGEST ?? '',
|
|
72
74
|
WORKSPACE_IMAGE_DIGEST: process.env.WORKSPACE_IMAGE_DIGEST ?? '',
|
|
73
75
|
}
|
package/src/conventions.test.ts
CHANGED
|
@@ -440,6 +440,35 @@ describe('per-app invariants', () => {
|
|
|
440
440
|
// SHA verification is wired in and was not accidentally stripped.
|
|
441
441
|
expect(buildTs).toMatch(/const\s+EXPECTED_SHA256\s*=/)
|
|
442
442
|
})
|
|
443
|
+
|
|
444
|
+
it('deploy-gateway.yaml does NOT install doctl (firewall is provisioning-only, not deploy-time)', async () => {
|
|
445
|
+
const text = await Bun.file(resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')).text()
|
|
446
|
+
// The DO Cloud Firewall is created in provisioning (provision-droplet.ts), not in the deploy hot path.
|
|
447
|
+
// The deploy job must NOT install doctl — it no longer needs it.
|
|
448
|
+
expect(text).not.toMatch(/uses:\s+digitalocean\/action-doctl@[a-f0-9]{40}/)
|
|
449
|
+
expect(text).not.toContain('digitalocean/action-doctl')
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('deploy-gateway.yaml does NOT forward DIGITALOCEAN_ACCESS_TOKEN to the deploy step', async () => {
|
|
453
|
+
const text = await Bun.file(resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')).text()
|
|
454
|
+
// DIGITALOCEAN_ACCESS_TOKEN is only needed for provisioning, not for the deploy hot path.
|
|
455
|
+
// Verify it is not forwarded to the deploy step env block.
|
|
456
|
+
const deployStepIndex = text.indexOf('name: Deploy gateway')
|
|
457
|
+
expect(deployStepIndex).toBeGreaterThan(-1)
|
|
458
|
+
const afterDeployStep = text.slice(deployStepIndex)
|
|
459
|
+
expect(afterDeployStep).not.toContain('DIGITALOCEAN_ACCESS_TOKEN')
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('deploy-gateway.yaml still forwards GATEWAY_VPC_IP and DASHBOARD_VPC_IP to the deploy step', async () => {
|
|
463
|
+
const text = await Bun.file(resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')).text()
|
|
464
|
+
// GATEWAY_VPC_IP and DASHBOARD_VPC_IP are still needed by deploy.ts for the compose VPC-IP
|
|
465
|
+
// publish and the DOCKER-USER iptables rule.
|
|
466
|
+
const deployStepIndex = text.indexOf('name: Deploy gateway')
|
|
467
|
+
expect(deployStepIndex).toBeGreaterThan(-1)
|
|
468
|
+
const afterDeployStep = text.slice(deployStepIndex)
|
|
469
|
+
expect(afterDeployStep).toContain('GATEWAY_VPC_IP')
|
|
470
|
+
expect(afterDeployStep).toContain('DASHBOARD_VPC_IP')
|
|
471
|
+
})
|
|
443
472
|
})
|
|
444
473
|
|
|
445
474
|
describe('findCrossOrgSecretsInherit', () => {
|
|
@@ -645,10 +674,9 @@ describe('dorny/paths-filter quantifier guard', () => {
|
|
|
645
674
|
})
|
|
646
675
|
})
|
|
647
676
|
|
|
648
|
-
// ───
|
|
677
|
+
// ─── operator auth/config secrets and tuning vars ────────────────────────────
|
|
649
678
|
//
|
|
650
|
-
//
|
|
651
|
-
// These assert the four new auth/config secrets and three optional tuning vars are
|
|
679
|
+
// These assert the four operator auth/config secrets and three optional tuning vars are
|
|
652
680
|
// wired through deploy-gateway.yaml (workflow_call.secrets + inputs + deploy step env),
|
|
653
681
|
// deploy.yaml (fan-out secrets + with inputs), and the CLI passthrough.
|
|
654
682
|
|
|
@@ -843,6 +871,24 @@ describe('CLI getGatewayDeployEnv: operator auth/config passthrough', () => {
|
|
|
843
871
|
Object.assign(process.env, origEnv)
|
|
844
872
|
}
|
|
845
873
|
})
|
|
874
|
+
|
|
875
|
+
it('getGatewayDeployEnv includes GATEWAY_VPC_IP and DASHBOARD_VPC_IP (but NOT DIGITALOCEAN_ACCESS_TOKEN)', async () => {
|
|
876
|
+
const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
|
|
877
|
+
const origEnv = {...process.env}
|
|
878
|
+
process.env.PATH = '/usr/bin'
|
|
879
|
+
process.env.HOME = '/home/test'
|
|
880
|
+
process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
|
|
881
|
+
try {
|
|
882
|
+
const env = getGatewayDeployEnv()
|
|
883
|
+
// VPC IPs are still needed by deploy.ts for compose publish + DOCKER-USER rule
|
|
884
|
+
expect(env).toHaveProperty('GATEWAY_VPC_IP')
|
|
885
|
+
expect(env).toHaveProperty('DASHBOARD_VPC_IP')
|
|
886
|
+
// DIGITALOCEAN_ACCESS_TOKEN is provisioning-only — must NOT be in the deploy env
|
|
887
|
+
expect(env).not.toHaveProperty('DIGITALOCEAN_ACCESS_TOKEN')
|
|
888
|
+
} finally {
|
|
889
|
+
Object.assign(process.env, origEnv)
|
|
890
|
+
}
|
|
891
|
+
})
|
|
846
892
|
})
|
|
847
893
|
|
|
848
894
|
// ─── deploy.yaml: aggregate router passes operator secrets to deploy-gateway ──
|
|
@@ -988,3 +1034,147 @@ describe('deploy-gateway.yaml: optional operator secret declarations (issue 1)',
|
|
|
988
1034
|
expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
|
|
989
1035
|
})
|
|
990
1036
|
})
|
|
1037
|
+
|
|
1038
|
+
// ─── VPC bridge secrets: CI-vs-local parity ──────────────────────────────────
|
|
1039
|
+
//
|
|
1040
|
+
// GATEWAY_VPC_IP and DASHBOARD_VPC_IP are optional (all-or-none via getOperatorVpcState)
|
|
1041
|
+
// but must be forwarded through CI so the operator VPC bridge is not silently disabled
|
|
1042
|
+
// when the secrets are set. DIGITALOCEAN_ACCESS_TOKEN is provisioning-only and must NOT
|
|
1043
|
+
// be forwarded to the deploy step.
|
|
1044
|
+
// These tests assert the end-to-end wiring without adding them to the required
|
|
1045
|
+
// validation preflight (they are opt-in, like the operator listener vars).
|
|
1046
|
+
|
|
1047
|
+
const VPC_GATEWAY_SECRETS = ['GATEWAY_VPC_IP', 'DASHBOARD_VPC_IP'] as const
|
|
1048
|
+
const VPC_DASHBOARD_SECRETS = ['GATEWAY_VPC_IP'] as const
|
|
1049
|
+
|
|
1050
|
+
describe('deploy-gateway.yaml: VPC bridge secrets wired end-to-end', () => {
|
|
1051
|
+
const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
|
|
1052
|
+
|
|
1053
|
+
for (const secret of VPC_GATEWAY_SECRETS) {
|
|
1054
|
+
it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
|
|
1055
|
+
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
1056
|
+
const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
|
|
1057
|
+
const secrets = parsed?.on?.workflow_call?.secrets ?? {}
|
|
1058
|
+
expect(secrets).toHaveProperty(secret)
|
|
1059
|
+
expect(secrets[secret]?.required).toBe(false)
|
|
1060
|
+
})
|
|
1061
|
+
|
|
1062
|
+
it(`Deploy gateway step env forwards ${secret} from secrets context`, async () => {
|
|
1063
|
+
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
1064
|
+
const parsed = parseYaml(text) as {
|
|
1065
|
+
jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
|
|
1066
|
+
}
|
|
1067
|
+
const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
|
|
1068
|
+
const deployStep = steps.find(s => s.name === 'Deploy gateway')
|
|
1069
|
+
expect(deployStep).toBeDefined()
|
|
1070
|
+
expect(deployStep?.env).toHaveProperty(secret)
|
|
1071
|
+
expect(deployStep?.env?.[secret]).toMatch(/\$\{\{\s*secrets\./)
|
|
1072
|
+
})
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
it('VPC bridge secrets are NOT in the required-secret validation step env', async () => {
|
|
1076
|
+
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
1077
|
+
const parsed = parseYaml(text) as {
|
|
1078
|
+
jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>; run?: string}[]}}
|
|
1079
|
+
}
|
|
1080
|
+
const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
|
|
1081
|
+
const validateStep = steps.find(s => s.name === 'Validate required secrets')
|
|
1082
|
+
expect(validateStep).toBeDefined()
|
|
1083
|
+
for (const secret of VPC_GATEWAY_SECRETS) {
|
|
1084
|
+
expect(validateStep?.env ?? {}).not.toHaveProperty(secret)
|
|
1085
|
+
}
|
|
1086
|
+
})
|
|
1087
|
+
})
|
|
1088
|
+
|
|
1089
|
+
describe('deploy-dashboard.yaml: VPC bridge secret wired end-to-end', () => {
|
|
1090
|
+
const DEPLOY_DASHBOARD_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-dashboard.yaml')
|
|
1091
|
+
|
|
1092
|
+
for (const secret of VPC_DASHBOARD_SECRETS) {
|
|
1093
|
+
it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
|
|
1094
|
+
const text = await Bun.file(DEPLOY_DASHBOARD_WORKFLOW).text()
|
|
1095
|
+
const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
|
|
1096
|
+
const secrets = parsed?.on?.workflow_call?.secrets ?? {}
|
|
1097
|
+
expect(secrets).toHaveProperty(secret)
|
|
1098
|
+
expect(secrets[secret]?.required).toBe(false)
|
|
1099
|
+
})
|
|
1100
|
+
|
|
1101
|
+
it(`Deploy step env forwards ${secret} from secrets context`, async () => {
|
|
1102
|
+
const text = await Bun.file(DEPLOY_DASHBOARD_WORKFLOW).text()
|
|
1103
|
+
const parsed = parseYaml(text) as {
|
|
1104
|
+
jobs?: {'deploy-dashboard'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
|
|
1105
|
+
}
|
|
1106
|
+
const steps = parsed?.jobs?.['deploy-dashboard']?.steps ?? []
|
|
1107
|
+
const deployStep = steps.find(s => s.name === 'Deploy')
|
|
1108
|
+
expect(deployStep).toBeDefined()
|
|
1109
|
+
expect(deployStep?.env).toHaveProperty(secret)
|
|
1110
|
+
expect(deployStep?.env?.[secret]).toMatch(/\$\{\{\s*secrets\./)
|
|
1111
|
+
})
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
it('GATEWAY_VPC_IP is NOT in the required-secret validation step env', async () => {
|
|
1115
|
+
const text = await Bun.file(DEPLOY_DASHBOARD_WORKFLOW).text()
|
|
1116
|
+
const parsed = parseYaml(text) as {
|
|
1117
|
+
jobs?: {'deploy-dashboard'?: {steps?: {name?: string; env?: Record<string, string>; run?: string}[]}}
|
|
1118
|
+
}
|
|
1119
|
+
const steps = parsed?.jobs?.['deploy-dashboard']?.steps ?? []
|
|
1120
|
+
const validateStep = steps.find(s => s.name === 'Validate required secrets')
|
|
1121
|
+
expect(validateStep).toBeDefined()
|
|
1122
|
+
expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_VPC_IP')
|
|
1123
|
+
})
|
|
1124
|
+
})
|
|
1125
|
+
|
|
1126
|
+
describe('deploy.yaml: aggregate router forwards VPC bridge secrets', () => {
|
|
1127
|
+
const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
|
|
1128
|
+
|
|
1129
|
+
for (const secret of VPC_GATEWAY_SECRETS) {
|
|
1130
|
+
it(`deploy-gateway job secrets block passes ${secret}`, async () => {
|
|
1131
|
+
const text = await Bun.file(DEPLOY_WORKFLOW).text()
|
|
1132
|
+
const parsed = parseYaml(text) as {
|
|
1133
|
+
jobs?: {'deploy-gateway'?: {secrets?: Record<string, string>}}
|
|
1134
|
+
}
|
|
1135
|
+
const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
|
|
1136
|
+
expect(secrets).toHaveProperty(secret)
|
|
1137
|
+
expect(secrets[secret]).toContain(secret)
|
|
1138
|
+
})
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
for (const secret of VPC_DASHBOARD_SECRETS) {
|
|
1142
|
+
it(`deploy-dashboard job secrets block passes ${secret}`, async () => {
|
|
1143
|
+
const text = await Bun.file(DEPLOY_WORKFLOW).text()
|
|
1144
|
+
const parsed = parseYaml(text) as {
|
|
1145
|
+
jobs?: {'deploy-dashboard'?: {secrets?: Record<string, string>}}
|
|
1146
|
+
}
|
|
1147
|
+
const secrets = parsed?.jobs?.['deploy-dashboard']?.secrets ?? {}
|
|
1148
|
+
expect(secrets).toHaveProperty(secret)
|
|
1149
|
+
expect(secrets[secret]).toContain(secret)
|
|
1150
|
+
})
|
|
1151
|
+
}
|
|
1152
|
+
})
|
|
1153
|
+
|
|
1154
|
+
// ─── deploy.yaml: deploy-dashboard is decoupled from deploy-gateway ──────────
|
|
1155
|
+
//
|
|
1156
|
+
// The dashboard is a standalone deployment. A gateway failure or slowness must
|
|
1157
|
+
// not block dashboard deploys. deploy-dashboard must NOT declare needs: deploy-gateway,
|
|
1158
|
+
// and its if: must NOT reference needs.deploy-gateway — locking in the decoupling
|
|
1159
|
+
// so it cannot silently regress.
|
|
1160
|
+
|
|
1161
|
+
describe('deploy.yaml: deploy-dashboard is decoupled from deploy-gateway', () => {
|
|
1162
|
+
const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
|
|
1163
|
+
|
|
1164
|
+
it('deploy-dashboard needs: does NOT include deploy-gateway (standalone job)', async () => {
|
|
1165
|
+
const text = await Bun.file(DEPLOY_WORKFLOW).text()
|
|
1166
|
+
const parsed = parseYaml(text) as {
|
|
1167
|
+
jobs?: {'deploy-dashboard'?: {needs?: string | string[]}}
|
|
1168
|
+
}
|
|
1169
|
+
const needs = parsed?.jobs?.['deploy-dashboard']?.needs ?? []
|
|
1170
|
+
const needsArr = Array.isArray(needs) ? needs : [needs]
|
|
1171
|
+
expect(needsArr).not.toContain('deploy-gateway')
|
|
1172
|
+
})
|
|
1173
|
+
|
|
1174
|
+
it('deploy-dashboard if: does NOT reference needs.deploy-gateway (no gateway coupling)', async () => {
|
|
1175
|
+
const text = await Bun.file(DEPLOY_WORKFLOW).text()
|
|
1176
|
+
// The if: expression must not reference needs.deploy-gateway in any form —
|
|
1177
|
+
// gateway state must not gate the dashboard deploy.
|
|
1178
|
+
expect(text).not.toMatch(/needs\.deploy-gateway/)
|
|
1179
|
+
})
|
|
1180
|
+
})
|