okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Dylan Shepardfix(providers): harden cloud credentials and Vertex wire semantics794bda6

main
18.8 KiB527 linesraw
1// Bedrock's SigV4 header construction, the Anthropic-shaped request body it
2// shares with bedrock-mantle, and the AWS config/credentials file resolution
3// have edge cases the shared OpenAI E2E fake cannot exercise through the
4// fixed AWS endpoint policy. Native fixtures use temporary files/helpers,
5// never real AWS credentials or network.
6
7import albedo/harness/extension as harness_extension
8import albedo/harness/extensions/bedrock/aws_profile
9import albedo/harness/extensions/bedrock/extension
10import albedo/harness/extensions/bedrock/sigv4
11import albedo/harness/extensions/bedrock/wire
12import albedo/harness/extensions/claude/stream as claude_stream
13import albedo/openai_api
14import albedo/openai_api/stream as reducer
15import albedo/openai_api/types
16import gleam/bit_array
17import gleam/crypto
18import gleam/dict.{type Dict}
19import gleam/dynamic.{type Dynamic}
20import gleam/dynamic/decode
21import gleam/json
22import gleam/list
23import gleam/option.{None, Some}
24import gleam/result
25import gleam/string
26import gleam/string_tree
27
28pub fn effective_base_url_defaults_to_runtime_in_the_given_region_test() -> Nil {
29  assert extension.effective_base_url("", "us-east-1")
30    == Ok("https://bedrock-runtime.us-east-1.amazonaws.com")
31}
32
33pub fn effective_base_url_keeps_an_explicit_url_regardless_of_region_test() -> Nil {
34  let mantle = "https://bedrock-mantle.eu-west-1.api.aws"
35  assert extension.effective_base_url(mantle, "us-east-1") == Ok(mantle)
36  assert extension.effective_base_url(mantle, "") == Ok(mantle)
37}
38
39pub fn effective_base_url_fails_with_neither_a_url_nor_a_region_test() -> Nil {
40  let assert Error(message) = extension.effective_base_url("", "")
41  assert string.contains(message, "AWS_REGION")
42}
43
44// An arbitrary fixed instant; independently cross-checked against the OS
45// calendar (`date -u -r 1700000000`), not derived from this code.
46const epoch = 1_700_000_000
47
48const runtime_host = "bedrock-runtime.us-east-1.amazonaws.com"
49
50const runtime_url = "https://bedrock-runtime.us-east-1.amazonaws.com"
51
52fn creds() -> sigv4.Credentials {
53  sigv4.Credentials("AKIATEST", "topsecret", None)
54}
55
56fn header(headers: List(#(String, String)), name: String) -> String {
57  let assert Ok(#(_, value)) = list.find(headers, fn(h) { h.0 == name })
58  value
59}
60
61fn sha256_hex(text: String) -> String {
62  crypto.hash(crypto.Sha256, bit_array.from_string(text))
63  |> bit_array.base16_encode
64  |> string.lowercase
65}
66
67pub fn sigv4_dates_the_request_and_lists_signed_headers_in_order_test() -> Result(
68  #(String, String),
69  Nil,
70) {
71  let headers =
72    sigv4.headers(creds(), "us-east-1", "bedrock", runtime_host, epoch, "{}")
73  assert header(headers, "x-amz-date") == "20231114T221320Z"
74  assert header(headers, "x-amz-content-sha256") == sha256_hex("{}")
75  let authorization = header(headers, "authorization")
76  assert string.starts_with(
77    authorization,
78    "AWS4-HMAC-SHA256 Credential=AKIATEST/20231114/us-east-1/bedrock/aws4_request, SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date, Signature=",
79  )
80  let assert Error(Nil) =
81    list.find(headers, fn(h) { h.0 == "x-amz-security-token" })
82}
83
84pub fn sigv4_adds_the_security_token_header_only_when_one_is_given_test() -> Nil {
85  let with_token =
86    sigv4.headers(
87      sigv4.Credentials("ak", "sk", Some("tok")),
88      "us-east-1",
89      "bedrock",
90      runtime_host,
91      epoch,
92      "",
93    )
94  assert header(with_token, "x-amz-security-token") == "tok"
95  assert string.contains(
96    header(with_token, "authorization"),
97    "x-amz-security-token",
98  )
99  let without =
100    sigv4.headers(
101      sigv4.Credentials("ak", "sk", None),
102      "us-east-1",
103      "bedrock",
104      runtime_host,
105      epoch,
106      "",
107    )
108  assert !string.contains(
109    header(without, "authorization"),
110    "x-amz-security-token",
111  )
112  assert list.find(without, fn(h) { h.0 == "x-amz-security-token" })
113    == Error(Nil)
114}
115
116pub fn sigv4_signature_is_sensitive_to_the_body_and_the_secret_test() -> Nil {
117  let base =
118    sigv4.headers(creds(), "us-east-1", "bedrock", runtime_host, epoch, "one")
119  let different_body =
120    sigv4.headers(creds(), "us-east-1", "bedrock", runtime_host, epoch, "two")
121  let different_secret =
122    sigv4.headers(
123      sigv4.Credentials("AKIATEST", "othersecret", None),
124      "us-east-1",
125      "bedrock",
126      runtime_host,
127      epoch,
128      "one",
129    )
130  assert header(base, "authorization")
131    != header(different_body, "authorization")
132  assert header(base, "authorization")
133    != header(different_secret, "authorization")
134  assert header(base, "x-amz-content-sha256")
135    != header(different_body, "x-amz-content-sha256")
136}
137
138fn request(model: String, input: List(types.Input)) -> types.Request {
139  types.Request(model, None, input, [], Some(100), types.defaults)
140}
141
142fn exchange(
143  auth: wire.Auth,
144  base_url: String,
145  input: List(types.Input),
146) -> Result(openai_api.Exchange, types.Error) {
147  wire.encode(
148    auth,
149    base_url,
150    epoch,
151    request("anthropic.claude-sonnet-5", input),
152  )
153}
154
155fn body(input: List(types.Input)) -> Dynamic {
156  let assert Ok(openai_api.Exchange(body: body, ..)) =
157    exchange(wire.Bearer("key"), runtime_url, input)
158  let assert Ok(value) = json.parse(string_tree.to_string(body), decode.dynamic)
159  value
160}
161
162fn at(value: Dynamic, path: List(String), decoder: decode.Decoder(a)) -> a {
163  let assert Ok(found) = decode.run(value, decode.at(path, decoder))
164  found
165}
166
167pub fn encode_rejects_a_host_that_is_not_bedrock_runtime_or_mantle_test() -> Nil {
168  let assert Error(types.InvalidRequest(message)) =
169    exchange(wire.Bearer("key"), "https://example.com", [types.User("hi")])
170  assert string.contains(message, "bedrock-runtime")
171}
172
173pub fn encode_accepts_both_bedrock_endpoints_and_keeps_their_region_test() -> Nil {
174  let assert Ok(openai_api.Exchange(url: on_runtime, ..)) =
175    exchange(wire.Bearer("key"), runtime_url, [types.User("hi")])
176  assert on_runtime == runtime_url <> "/anthropic/v1/messages"
177  let assert Ok(openai_api.Exchange(url: on_mantle, ..)) =
178    exchange(wire.Bearer("key"), "https://bedrock-mantle.eu-west-1.api.aws", [
179      types.User("hi"),
180    ])
181  assert on_mantle
182    == "https://bedrock-mantle.eu-west-1.api.aws/anthropic/v1/messages"
183}
184
185pub fn encode_rejects_models_outside_the_claude_family_test() -> Nil {
186  let assert Error(types.InvalidRequest(message)) =
187    wire.encode(
188      wire.Bearer("key"),
189      runtime_url,
190      epoch,
191      request("meta.llama4-maverick-17b-instruct-v1:0", [types.User("hi")]),
192    )
193  assert string.contains(message, "Claude")
194}
195
196pub fn encode_sends_the_bearer_key_as_x_api_key_and_signs_with_sigv4_otherwise_test() -> Nil {
197  let assert Ok(openai_api.Exchange(headers: bearer_headers, ..)) =
198    exchange(wire.Bearer("my-key"), runtime_url, [types.User("hi")])
199  assert header(bearer_headers, "x-api-key") == "my-key"
200  assert list.find(bearer_headers, fn(h) { h.0 == "authorization" })
201    == Error(Nil)
202  let assert Ok(openai_api.Exchange(headers: sigv4_headers, ..)) =
203    exchange(wire.SigV4(creds()), runtime_url, [types.User("hi")])
204  assert string.starts_with(
205    header(sigv4_headers, "authorization"),
206    "AWS4-HMAC-SHA256",
207  )
208}
209
210pub fn adjacent_same_role_turns_merge_into_one_message_test() -> Nil {
211  let value =
212    body([
213      types.User("a"),
214      types.Assistant("b"),
215      types.ToolOutput("call-1", "ok", []),
216      types.ToolOutput("call-2", "ok2", []),
217    ])
218  let assert [first, second, third] =
219    at(value, ["messages"], decode.list(decode.dynamic))
220  assert at(first, ["role"], decode.string) == "user"
221  assert at(second, ["role"], decode.string) == "assistant"
222  assert at(third, ["role"], decode.string) == "user"
223  let assert [block_one, block_two] =
224    at(third, ["content"], decode.list(decode.dynamic))
225  assert at(block_one, ["tool_use_id"], decode.string) == "call-1"
226  assert at(block_two, ["tool_use_id"], decode.string) == "call-2"
227}
228
229pub fn user_image_with_no_caption_carries_no_text_block_test() -> Nil {
230  let assert Ok(image) = types.image("image/png", "aGk=", 2, 3, 2)
231  let value = body([types.UserImage("", [image])])
232  let assert [message] = at(value, ["messages"], decode.list(decode.dynamic))
233  let assert [block] = at(message, ["content"], decode.list(decode.dynamic))
234  assert at(block, ["type"], decode.string) == "image"
235  assert at(block, ["source", "media_type"], decode.string) == "image/png"
236}
237
238pub fn replay_keeps_text_and_tool_calls_but_drops_thinking_test() -> Nil {
239  let assert Ok(item) =
240    json.parse(
241      "{\"role\":\"assistant\",\"content\":\"done\",\"reasoning_content\":\"secret thoughts\",\"tool_calls\":[{\"id\":\"call-1\",\"type\":\"function\",\"function\":{\"name\":\"bash\",\"arguments\":\"{\\\"cmd\\\":\\\"ls\\\"}\"}}]}",
242      types.replay_decoder(types.ChatCompletions),
243    )
244  let value = body([types.User("go"), types.Replay(item)])
245  let assert [_, assistant] =
246    at(value, ["messages"], decode.list(decode.dynamic))
247  let assert [text, call] =
248    at(assistant, ["content"], decode.list(decode.dynamic))
249  assert at(text, ["text"], decode.string) == "done"
250  assert at(call, ["type"], decode.string) == "tool_use"
251  assert at(call, ["id"], decode.string) == "call-1"
252  assert at(call, ["name"], decode.string) == "bash"
253  assert at(call, ["input", "cmd"], decode.string) == "ls"
254  let assert Ok(openai_api.Exchange(body: raw, ..)) =
255    exchange(wire.Bearer("key"), runtime_url, [
256      types.User("go"),
257      types.Replay(item),
258    ])
259  assert !string.contains(string_tree.to_string(raw), "secret thoughts")
260}
261
262pub fn tool_choice_and_effort_encode_as_documented_fields_test() -> Nil {
263  let tool =
264    types.Tool(
265      "bash",
266      "run",
267      json.object([#("type", json.string("object"))]),
268      False,
269    )
270  let configured =
271    types.Request(
272      "anthropic.claude-sonnet-5",
273      Some("be nice"),
274      [types.User("hi")],
275      [tool],
276      Some(100),
277      types.Options(
278        ..types.defaults,
279        tool_choice: Some(types.NamedTool("bash")),
280        effort: Some("high"),
281      ),
282    )
283  let assert Ok(openai_api.Exchange(body: raw, ..)) =
284    wire.encode(wire.Bearer("key"), runtime_url, epoch, configured)
285  let assert Ok(value) = json.parse(string_tree.to_string(raw), decode.dynamic)
286  assert at(value, ["system"], decode.string) == "be nice"
287  assert at(value, ["tool_choice", "type"], decode.string) == "tool"
288  assert at(value, ["tool_choice", "name"], decode.string) == "bash"
289  assert at(value, ["output_config", "effort"], decode.string) == "high"
290  let assert [tool_entry] = at(value, ["tools"], decode.list(decode.dynamic))
291  assert at(tool_entry, ["name"], decode.string) == "bash"
292}
293
294pub fn tool_schemas_reuse_claudes_combinator_flattening_test() -> Nil {
295  let assert Ok(schema) =
296    json.parse(
297      "{\"oneOf\":[{\"required\":[\"a\"]},{\"required\":[\"b\"]}],\"properties\":{\"a\":{\"type\":\"string\"},\"b\":{\"type\":\"string\"}}}",
298      decode.dynamic,
299    )
300  let tool =
301    types.Tool("pick", "pick a or b", types.encode_value(schema), False)
302  let configured =
303    types.Request(
304      "anthropic.claude-sonnet-5",
305      None,
306      [types.User("hi")],
307      [tool],
308      Some(100),
309      types.defaults,
310    )
311  let assert Ok(openai_api.Exchange(body: raw, ..)) =
312    wire.encode(wire.Bearer("key"), runtime_url, epoch, configured)
313  let assert Ok(parsed) = json.parse(string_tree.to_string(raw), decode.dynamic)
314  let assert [tool_entry] = at(parsed, ["tools"], decode.list(decode.dynamic))
315  assert at(tool_entry, ["input_schema", "type"], decode.string) == "object"
316  assert result.is_error(decode.run(
317    tool_entry,
318    decode.at(["input_schema", "oneOf"], decode.dynamic),
319  ))
320}
321
322fn feed_all(
323  state: reducer.Reducer,
324  chunks: List(String),
325) -> Result(types.Turn, types.Error) {
326  case chunks {
327    [] -> Error(types.UnexpectedEnd)
328    [chunk, ..rest] -> {
329      use #(next, _, turn) <- result.try(state.feed(chunk))
330      case turn {
331        Some(turn) -> Ok(turn)
332        None -> feed_all(next, rest)
333      }
334    }
335  }
336}
337
338pub fn a_bedrock_turn_replays_through_claudes_own_reducer_test() -> Nil {
339  let chunks = [
340    "{\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}",
341    "{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}",
342    "{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"pong\"}}",
343    "{\"type\":\"content_block_stop\",\"index\":0}",
344    "{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}",
345    "{\"type\":\"message_stop\"}",
346  ]
347  let assert Ok(turn) =
348    feed_all(claude_stream.reducer("anthropic.claude-sonnet-5", []), chunks)
349  assert turn.finish == types.Complete
350  let assert [item] = turn.output
351  let value = body([types.User("ping"), types.Replay(item)])
352  let assert [_, assistant] =
353    at(value, ["messages"], decode.list(decode.dynamic))
354  let assert [text] = at(assistant, ["content"], decode.list(decode.dynamic))
355  assert at(text, ["text"], decode.string) == "pong"
356}
357
358pub fn parse_section_finds_default_and_named_profile_headers_test() -> Result(
359  Dict(String, String),
360  Nil,
361) {
362  let text =
363    "[default]\nregion = us-east-1\n\n[profile other]\ncredential_process = /bin/echo hi\n"
364  let assert Ok(default_section) = aws_profile.parse_section(text, "default")
365  assert dict.get(default_section, "region") == Ok("us-east-1")
366  let assert Ok(other) = aws_profile.parse_section(text, "profile other")
367  assert dict.get(other, "credential_process") == Ok("/bin/echo hi")
368  aws_profile.parse_section(text, "profile missing")
369}
370
371pub fn parse_section_keeps_equals_signs_inside_values_test() -> Nil {
372  let text = "[default]\naws_session_token = abc=def==\n"
373  let assert Ok(section) = aws_profile.parse_section(text, "default")
374  assert dict.get(section, "aws_session_token") == Ok("abc=def==")
375}
376
377pub fn config_header_only_prefixes_named_profiles_test() -> Nil {
378  assert aws_profile.config_header("default") == "default"
379  assert aws_profile.config_header("work") == "profile work"
380}
381
382pub fn credentials_from_section_names_the_missing_key_test() -> Nil {
383  let assert Error(message) =
384    aws_profile.credentials_from_section(dict.new(), "work")
385  assert string.contains(message, "aws_access_key_id")
386  let partial = dict.from_list([#("aws_access_key_id", "ak")])
387  let assert Error(message) =
388    aws_profile.credentials_from_section(partial, "work")
389  assert string.contains(message, "aws_secret_access_key")
390  let complete =
391    dict.from_list([
392      #("aws_access_key_id", "ak"),
393      #("aws_secret_access_key", "sk"),
394      #("aws_session_token", "tok"),
395    ])
396  assert aws_profile.credentials_from_section(complete, "work")
397    == Ok(sigv4.Credentials("ak", "sk", Some("tok")))
398}
399
400pub fn credentials_from_process_output_decodes_the_documented_json_test() -> Nil {
401  assert aws_profile.credentials_from_process_output(
402      "{\"Version\":1,\"AccessKeyId\":\"AKIATEST\",\"SecretAccessKey\":\"topsecret\",\"SessionToken\":\"tok\"}",
403    )
404    == Ok(sigv4.Credentials("AKIATEST", "topsecret", Some("tok")))
405  let assert Ok(sigv4.Credentials(_, _, None)) =
406    aws_profile.credentials_from_process_output(
407      "{\"Version\":1,\"AccessKeyId\":\"a\",\"SecretAccessKey\":\"b\"}",
408    )
409  assert result.is_error(aws_profile.credentials_from_process_output("not json"))
410}
411
412pub fn credential_process_argv_preserves_quoted_paths_arguments_and_literal_shell_text_test() -> Nil {
413  assert aws_profile.command_argv(
414      "\"/opt/my helpers/aws\"\t--name \"team account\" escaped\\ space '' '$HOME;echo'",
415    )
416    == Ok([
417      "/opt/my helpers/aws",
418      "--name",
419      "team account",
420      "escaped space",
421      "",
422      "$HOME;echo",
423    ])
424  assert aws_profile.command_argv("helper \"foo\\bar\"")
425    == Ok(["helper", "foo\\bar"])
426  assert result.is_error(aws_profile.command_argv("helper \"unfinished"))
427  assert result.is_error(aws_profile.command_argv("helper trailing\\"))
428}
429
430pub fn endpoint_validation_rejects_credential_disclosure_and_malformed_urls_test() -> Nil {
431  list.each(
432    [
433      "http://bedrock-runtime.us-east-1.amazonaws.com",
434      "https://bedrock-runtime.us-east-1.attacker.example",
435      "https://key@bedrock-runtime.us-east-1.amazonaws.com",
436      "https://bedrock-runtime.us-east-1.amazonaws.com:443",
437      "https://bedrock-runtime.us-east-1.amazonaws.com/path",
438      "https://bedrock-runtime.us-east-1.amazonaws.com?query",
439      "https://bedrock-runtime.us-east-1.amazonaws.com#fragment",
440      "https://bedrock-runtime..amazonaws.com",
441      "https://bedrock-runtime.us-east-1.amazonaws.com:bad",
442    ],
443    fn(url) {
444      assert result.is_error(wire.target(url))
445    },
446  )
447  list.each(
448    [
449      "https://bedrock-runtime.cn-north-1.amazonaws.com.cn",
450      "https://bedrock-runtime.us-gov-west-1.amazonaws.com",
451      "https://bedrock-mantle.eu-west-1.api.aws/",
452      "https://bedrock-runtime.us-iso-east-1.c2s.ic.gov",
453    ],
454    fn(url) {
455      assert result.is_ok(wire.target(url))
456    },
457  )
458}
459
460// Scoped native fixtures exercise the real file/process boundary while restoring
461// all changed env; they never read the user's AWS credentials or call a cloud.
462pub fn aws_profile_files_helpers_and_environment_regression_test() -> Nil {
463  assert profile_fixtures()
464}
465
466@external(erlang, "albedo_bedrock_test_support", "profile_fixtures")
467fn profile_fixtures() -> Bool
468
469// Known answers independently generated by botocore 1.43.108 SigV4Auth;
470// no botocore dependency or cloud credential is needed to execute this test.
471pub fn sigv4_matches_independent_botocore_vectors_test() -> Nil {
472  list.each(
473    [
474      #(
475        None,
476        "0b40d8c99ea39128fe28e151463083ce5861037d881135c08ec4b8e364c542ac",
477      ),
478      #(
479        Some("tok"),
480        "8e48825d1fe3ee6e3d9beae49a80d460d21bcc1c14a501b2cc2de307e891b53d",
481      ),
482    ],
483    fn(vector) {
484      let headers =
485        sigv4.headers(
486          sigv4.Credentials("AKIATEST", "secret", vector.0),
487          "us-east-1",
488          "bedrock",
489          runtime_host,
490          epoch,
491          "{}",
492        )
493      assert string.ends_with(
494        header(headers, "authorization"),
495        "Signature=" <> vector.1,
496      )
497    },
498  )
499}
500
501// Called within the native fixture's scoped temporary home and AWS env.
502pub fn reused_upstream_refreshes(
503  home: String,
504  remove_credentials: fn() -> Nil,
505) -> Nil {
506  let assert [harness_extension.ModelProviderPlugin(provider)] =
507    extension.extension().plugins
508  let assert Some(Ok(upstream)) =
509    provider.resolve(harness_extension.ModelContext(
510      home,
511      "test-session",
512      "bedrock",
513      "bedrock",
514      "not-claude",
515      types.ChatCompletions,
516      None,
517    ))
518  // Invalid model deliberately stops before HTTP, after authentication.
519  let invalid = request("llama", [types.User("hello")])
520  let assert Error(types.InvalidRequest(first)) =
521    upstream.stream(invalid, fn(_) { types.Continue })
522  assert string.contains(first, "Claude models")
523  remove_credentials()
524  let assert Error(types.InvalidRequest(second)) =
525    upstream.stream(invalid, fn(_) { types.Continue })
526  assert string.contains(second, "Bedrock credentials missing")
527}