Current section
Files
Jump to
Current section
Files
src/developer/adk_dev_router.erl
%% @doc Cowboy route bundle for the opt-in local ADK developer platform.
-module(adk_dev_router).
-export([routes/1, routes_validated/1, compile/1, validate_config/1]).
-define(DEFAULT_MAX_BODY_BYTES, 65536).
-define(DEFAULT_MAX_FIELD_BYTES, 4096).
-define(DEFAULT_HEARTBEAT_MS, 15000).
-define(DEFAULT_SSE_MAX_EVENTS, 128).
-define(DEFAULT_SSE_MAX_BYTES, 1048576).
-define(DEFAULT_SSE_MAX_DURATION_MS, 300000).
-define(MAX_SSE_EVENTS, 10000).
-define(MAX_SSE_BYTES, 16777216).
-define(MAX_SSE_DURATION_MS, 3600000).
-define(DEFAULT_MAX_SESSION_RESULTS, 100).
-define(DEFAULT_MAX_RESOURCE_RESULTS, 100).
-define(DEFAULT_DIAGNOSTIC_TIMEOUT_MS, 5000).
-define(MAX_RESOURCE_RESULTS, 1000).
-define(MAX_DIAGNOSTIC_TIMEOUT_MS, 300000).
-spec compile(map()) -> term().
compile(Config) ->
cowboy_router:compile([{'_', routes(Config)}]).
-spec routes(map()) -> [term()].
routes(Config0) ->
{ok, Config} = validate_config(Config0),
route_list(Config).
%% @doc Build routes from the already-sanitized configuration returned by
%% validate_config/1. The application HTTP owner uses this form so the raw
%% bearer token never has to be retained or reintroduced after validation.
-spec routes_validated(map()) -> [term()].
routes_validated(Config) ->
case valid_sanitized_config(Config) of
true -> route_list(Config);
false -> erlang:error(invalid_dev_platform_config)
end.
route_list(Config) ->
[
{"/dev", adk_dev_handler, Config#{endpoint => ui}},
{"/dev/", adk_dev_handler, Config#{endpoint => ui}},
{"/dev/v1/agents", adk_dev_handler, Config#{endpoint => agents}},
{"/dev/v1/diagnostics", adk_dev_handler,
Config#{endpoint => diagnostics}},
{"/dev/v1/observability", adk_dev_handler,
Config#{endpoint => observability}},
{"/dev/v1/evaluation/render", adk_dev_handler,
Config#{endpoint => evaluation_render}},
{"/dev/v1/evaluation/compare", adk_dev_handler,
Config#{endpoint => evaluation_compare}},
{"/dev/v1/live/sessions/:session_id/events", adk_dev_handler,
Config#{endpoint => live_session_events}},
{"/dev/v1/live/sessions/:session_id/text", adk_dev_handler,
Config#{endpoint => live_session_text}},
{"/dev/v1/live/sessions", adk_dev_handler,
Config#{endpoint => live_sessions}},
{"/dev/v1/context/:app_name/:user_id/:session_id/lifecycle",
adk_dev_handler, Config#{endpoint => context_lifecycle}},
{"/dev/v1/context/:app_name/:user_id/:session_id/cache/invalidate",
adk_dev_handler, Config#{endpoint => context_cache_invalidate}},
{"/dev/v1/context/:app_name/:user_id/:session_id",
adk_dev_handler, Config#{endpoint => context_diagnostic}},
{"/dev/v1/artifacts/:app_name/:user_id/:session_id/versions",
adk_dev_handler, Config#{endpoint => artifact_versions}},
{"/dev/v1/artifacts/:app_name/:user_id/:session_id/delete",
adk_dev_handler, Config#{endpoint => artifact_delete}},
{"/dev/v1/artifacts/:app_name/:user_id/:session_id",
adk_dev_handler, Config#{endpoint => artifacts}},
{"/dev/v1/memory/:app_name/:user_id/search",
adk_dev_handler, Config#{endpoint => memory_search}},
{"/dev/v1/memory/:app_name/:user_id/erase",
adk_dev_handler, Config#{endpoint => memory_erase}},
{"/dev/v1/memory/:app_name/:user_id",
adk_dev_handler, Config#{endpoint => memory_status}},
{"/dev/v1/runs", adk_dev_handler, Config#{endpoint => runs}},
{"/dev/v1/runs/:run_id/events", adk_dev_handler,
Config#{endpoint => run_events}},
{"/dev/v1/runs/:run_id/resume", adk_dev_handler,
Config#{endpoint => run_resume}},
{"/dev/v1/runs/:run_id", adk_dev_handler,
Config#{endpoint => run}},
{"/dev/v1/sessions/:app_name/:user_id",
adk_dev_handler, Config#{endpoint => sessions}},
{"/dev/v1/sessions/:app_name/:user_id/:session_id/state",
adk_dev_handler, Config#{endpoint => session_state}},
{"/dev/v1/sessions/:app_name/:user_id/:session_id",
adk_dev_handler, Config#{endpoint => session}}
].
valid_sanitized_config(Config) when is_map(Config) ->
Digest = maps:get(auth_token_digest, Config, undefined),
MaxBody = maps:get(max_body_bytes, Config, undefined),
MaxField = maps:get(max_field_bytes, Config, undefined),
Heartbeat = maps:get(sse_heartbeat_ms, Config, undefined),
SseMaxEvents = maps:get(sse_max_events, Config, undefined),
SseMaxBytes = maps:get(sse_max_bytes, Config, undefined),
SseMaxDuration = maps:get(sse_max_duration_ms, Config, undefined),
MaxSessionResults = maps:get(max_session_results, Config, undefined),
MaxResourceResults = maps:get(max_resource_results, Config, undefined),
DiagnosticTimeout = maps:get(diagnostic_timeout_ms, Config, undefined),
DiagnosticPolicy = maps:get(diagnostic_context_policy, Config, undefined),
ResourceProvider = maps:get(resource_provider, Config, undefined),
SessionService = maps:get(session_service, Config, undefined),
LivePrincipal = maps:get(live_principal, Config, undefined),
LiveCredit = maps:get(live_credit, Config, undefined),
is_binary(Digest) andalso byte_size(Digest) =:= 32 andalso
is_integer(MaxBody) andalso MaxBody > 0 andalso
is_integer(MaxField) andalso MaxField > 0 andalso MaxField =< MaxBody andalso
is_integer(Heartbeat) andalso Heartbeat > 0 andalso
is_integer(SseMaxEvents) andalso SseMaxEvents > 0 andalso
SseMaxEvents =< ?MAX_SSE_EVENTS andalso
is_integer(SseMaxBytes) andalso SseMaxBytes > 0 andalso
SseMaxBytes =< ?MAX_SSE_BYTES andalso
is_integer(SseMaxDuration) andalso SseMaxDuration > 0 andalso
SseMaxDuration =< ?MAX_SSE_DURATION_MS andalso
is_integer(MaxSessionResults) andalso MaxSessionResults > 0 andalso
MaxSessionResults =< 1000 andalso
is_integer(MaxResourceResults) andalso MaxResourceResults > 0 andalso
MaxResourceResults =< ?MAX_RESOURCE_RESULTS andalso
is_integer(DiagnosticTimeout) andalso DiagnosticTimeout > 0 andalso
DiagnosticTimeout =< ?MAX_DIAGNOSTIC_TIMEOUT_MS andalso
is_map(DiagnosticPolicy) andalso
valid_resource_provider(ResourceProvider) andalso
valid_live_principal(LivePrincipal, MaxField) andalso
valid_live_credit(LiveCredit) andalso
is_atom(SessionService) andalso
is_map(maps:get(runner_options, Config, undefined)) andalso
is_map(maps:get(run_options, Config, undefined)) andalso
not maps:is_key(auth_token, Config);
valid_sanitized_config(_Config) ->
false.
-spec validate_config(map()) -> {ok, map()} | {error, term()}.
validate_config(Config) when is_map(Config) ->
Token = maps:get(auth_token, Config, undefined),
MaxBody = maps:get(max_body_bytes, Config, ?DEFAULT_MAX_BODY_BYTES),
MaxField = maps:get(max_field_bytes, Config, ?DEFAULT_MAX_FIELD_BYTES),
Heartbeat = maps:get(sse_heartbeat_ms, Config, ?DEFAULT_HEARTBEAT_MS),
SseMaxEvents = maps:get(sse_max_events, Config,
?DEFAULT_SSE_MAX_EVENTS),
SseMaxBytes = maps:get(sse_max_bytes, Config, ?DEFAULT_SSE_MAX_BYTES),
SseMaxDuration = maps:get(sse_max_duration_ms, Config,
?DEFAULT_SSE_MAX_DURATION_MS),
MaxSessionResults = maps:get(max_session_results, Config,
?DEFAULT_MAX_SESSION_RESULTS),
MaxResourceResults = maps:get(max_resource_results, Config,
?DEFAULT_MAX_RESOURCE_RESULTS),
DiagnosticTimeout = maps:get(diagnostic_timeout_ms, Config,
?DEFAULT_DIAGNOSTIC_TIMEOUT_MS),
DiagnosticPolicy = maps:get(
diagnostic_context_policy, Config,
#{max_bytes => 1048576,
max_tokens => 262144,
overflow => truncate}),
ResourceProvider = maps:get(resource_provider, Config, undefined),
SessionService = maps:get(session_service, Config,
erlang_adk_session),
LivePrincipal = maps:get(live_principal, Config, undefined),
LiveCredit = maps:get(live_credit, Config,
#{messages => 16, bytes => 4194304}),
RunnerOptions = maps:get(runner_options, Config, #{}),
RunOptions = maps:get(run_options, Config, #{}),
case is_binary(Token) andalso byte_size(Token) >= 16 andalso
is_integer(MaxBody) andalso MaxBody > 0 andalso
is_integer(MaxField) andalso MaxField > 0 andalso
MaxField =< MaxBody andalso
is_integer(Heartbeat) andalso Heartbeat > 0 andalso
is_integer(SseMaxEvents) andalso SseMaxEvents > 0 andalso
SseMaxEvents =< ?MAX_SSE_EVENTS andalso
is_integer(SseMaxBytes) andalso SseMaxBytes > 0 andalso
SseMaxBytes =< ?MAX_SSE_BYTES andalso
is_integer(SseMaxDuration) andalso SseMaxDuration > 0 andalso
SseMaxDuration =< ?MAX_SSE_DURATION_MS andalso
is_integer(MaxSessionResults) andalso MaxSessionResults > 0 andalso
MaxSessionResults =< 1000 andalso
is_integer(MaxResourceResults) andalso MaxResourceResults > 0 andalso
MaxResourceResults =< ?MAX_RESOURCE_RESULTS andalso
is_integer(DiagnosticTimeout) andalso DiagnosticTimeout > 0 andalso
DiagnosticTimeout =< ?MAX_DIAGNOSTIC_TIMEOUT_MS andalso
valid_diagnostic_policy(DiagnosticPolicy) andalso
valid_resource_provider(ResourceProvider) andalso
valid_live_principal(LivePrincipal, MaxField) andalso
valid_live_credit(LiveCredit) andalso
is_atom(SessionService) andalso
is_map(RunnerOptions) andalso is_map(RunOptions) of
true ->
%% Cowboy keeps route state for the listener lifetime. Retain only
%% a fixed-size digest there so the bearer token cannot appear in
%% supervisor status, crash reports, or route introspection.
TokenDigest = crypto:hash(sha256, Token),
SafeConfig = maps:remove(auth_token, Config),
{ok, SafeConfig#{auth_token_digest => TokenDigest,
max_body_bytes => MaxBody,
max_field_bytes => MaxField,
sse_heartbeat_ms => Heartbeat,
sse_max_events => SseMaxEvents,
sse_max_bytes => SseMaxBytes,
sse_max_duration_ms => SseMaxDuration,
max_session_results => MaxSessionResults,
max_resource_results => MaxResourceResults,
diagnostic_timeout_ms => DiagnosticTimeout,
diagnostic_context_policy => DiagnosticPolicy,
resource_provider => ResourceProvider,
live_principal => LivePrincipal,
live_credit => LiveCredit,
session_service => SessionService,
runner_options => RunnerOptions,
run_options => RunOptions}};
false ->
{error, invalid_dev_platform_config}
end;
validate_config(_Config) ->
{error, invalid_dev_platform_config}.
valid_diagnostic_policy(Policy) when is_map(Policy) ->
case adk_context_policy:build([], Policy) of
{ok, _} -> true;
{error, _} -> false
end;
valid_diagnostic_policy(_) ->
false.
%% Resource discovery is deliberately explicit. A provider receives the exact
%% requested scope and can deny it; the developer handler never searches a
%% registry or publishes the returned service handle.
valid_resource_provider(undefined) ->
true;
valid_resource_provider({Module, Handle})
when is_atom(Module), Handle =/= undefined ->
case code:ensure_loaded(Module) of
{module, Module} -> erlang:function_exported(Module, resolve, 3);
_ -> false
end;
valid_resource_provider(_) ->
false.
%% A developer bearer may inspect/control only Live sessions owned by this
%% exact principal. There is no administrator bypass in the Live process.
valid_live_principal(undefined, _MaxField) -> true;
valid_live_principal(Principal, MaxField) ->
is_binary(Principal) andalso byte_size(Principal) > 0 andalso
byte_size(Principal) =< MaxField andalso
case unicode:characters_to_binary(Principal, utf8, utf8) of
Principal -> true;
_ -> false
end.
valid_live_credit(#{messages := Messages, bytes := Bytes} = Credit) ->
lists:sort(maps:keys(Credit)) =:= [bytes, messages] andalso
is_integer(Messages) andalso Messages > 0 andalso Messages =< 256 andalso
is_integer(Bytes) andalso Bytes > 0 andalso Bytes =< 8388608;
valid_live_credit(_) -> false.