cjerzak commited on
Commit
1dd1e45
·
1 Parent(s): b6b4159

improvements

Browse files
Dockerfile CHANGED
@@ -3,9 +3,7 @@ FROM rocker/r2u:24.04
3
  ENV DEBIAN_FRONTEND=noninteractive \
4
  TZ=Etc/UTC \
5
  PORT=7860 \
6
- ASA_PROXY=socks5h://127.0.0.1:9050 \
7
- TOR_CONTROL_PORT=9051 \
8
- ASA_TOR_CONTROL_COOKIE=/tmp/tor/control.authcookie \
9
  RETICULATE_MINICONDA_PATH=/opt/conda \
10
  RETICULATE_CONDA=/opt/conda/bin/conda \
11
  RETICULATE_PYTHON=/opt/conda/envs/asa_env/bin/python \
@@ -41,6 +39,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
41
  r-cran-jsonlite \
42
  r-cran-reticulate \
43
  r-cran-remotes \
 
44
  && rm -rf /var/lib/apt/lists/*
45
 
46
  ARG DOCKERFILE_REV=2026-03-10-openssl-hotfix-1
@@ -63,7 +62,7 @@ RUN echo "asa-api docker revision: ${DOCKERFILE_REV}"; \
63
  /opt/conda/bin/conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r; \
64
  /opt/conda/bin/conda update -n base -c defaults conda
65
 
66
- RUN R -q -e "gate <- c('remotes','plumber','jsonlite','reticulate'); missing <- gate[!vapply(gate, function(p) requireNamespace(p, quietly = TRUE), logical(1))]; if (length(missing)) stop(sprintf('Binary package gate failed; missing: %s', paste(missing, collapse = ', ')), call. = FALSE); cat('Binary R package gate passed:', paste(gate, collapse = ', '), '\n'); cat('R library paths:', paste(.libPaths(), collapse = ' | '), '\n'); cat('plumber version:', as.character(packageVersion('plumber')), '\n')"
67
 
68
  ARG ASA_SOFTWARE_REPO=https://github.com/cjerzak/asa-software
69
  ARG ASA_SOFTWARE_REF=main
@@ -74,7 +73,7 @@ RUN git clone --depth 1 --branch "${ASA_SOFTWARE_REF}" "${ASA_SOFTWARE_REPO}" /o
74
  && R -q -e "asa::build_backend(conda_env='asa_env', python_version='3.12', force=FALSE, check_browser=FALSE, fix_browser=FALSE)" \
75
  && /opt/conda/bin/conda run -n asa_env python -c "import ssl, sys; print(sys.version); print(ssl.OPENSSL_VERSION)" \
76
  && R -q -e "reticulate::use_condaenv('asa_env', required = TRUE); cfg <- reticulate::py_config(); cat('reticulate py_config python:', cfg[['python']], '\n'); reticulate::py_run_string('import ssl, sys; print(sys.version); print(ssl.OPENSSL_VERSION)')" \
77
- && R -q -e "gate <- c('asa','plumber','jsonlite','reticulate'); missing <- gate[!vapply(gate, function(p) requireNamespace(p, quietly = TRUE), logical(1))]; if (length(missing)) stop(sprintf('Post-asa gate failed; missing: %s', paste(missing, collapse = ', ')), call. = FALSE); cat('Post-asa package gate passed\n')" \
78
  && rm -rf /opt/asa-software/.git
79
 
80
  WORKDIR /app
 
3
  ENV DEBIAN_FRONTEND=noninteractive \
4
  TZ=Etc/UTC \
5
  PORT=7860 \
6
+ ASA_ENABLE_TOR=false \
 
 
7
  RETICULATE_MINICONDA_PATH=/opt/conda \
8
  RETICULATE_CONDA=/opt/conda/bin/conda \
9
  RETICULATE_PYTHON=/opt/conda/envs/asa_env/bin/python \
 
39
  r-cran-jsonlite \
40
  r-cran-reticulate \
41
  r-cran-remotes \
42
+ r-cran-sodium \
43
  && rm -rf /var/lib/apt/lists/*
44
 
45
  ARG DOCKERFILE_REV=2026-03-10-openssl-hotfix-1
 
62
  /opt/conda/bin/conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r; \
63
  /opt/conda/bin/conda update -n base -c defaults conda
64
 
65
+ RUN R -q -e "gate <- c('remotes','plumber','jsonlite','reticulate','sodium'); missing <- gate[!vapply(gate, function(p) requireNamespace(p, quietly = TRUE), logical(1))]; if (length(missing)) stop(sprintf('Binary package gate failed; missing: %s', paste(missing, collapse = ', ')), call. = FALSE); cat('Binary R package gate passed:', paste(gate, collapse = ', '), '\n'); cat('R library paths:', paste(.libPaths(), collapse = ' | '), '\n'); cat('plumber version:', as.character(packageVersion('plumber')), '\n')"
66
 
67
  ARG ASA_SOFTWARE_REPO=https://github.com/cjerzak/asa-software
68
  ARG ASA_SOFTWARE_REF=main
 
73
  && R -q -e "asa::build_backend(conda_env='asa_env', python_version='3.12', force=FALSE, check_browser=FALSE, fix_browser=FALSE)" \
74
  && /opt/conda/bin/conda run -n asa_env python -c "import ssl, sys; print(sys.version); print(ssl.OPENSSL_VERSION)" \
75
  && R -q -e "reticulate::use_condaenv('asa_env', required = TRUE); cfg <- reticulate::py_config(); cat('reticulate py_config python:', cfg[['python']], '\n'); reticulate::py_run_string('import ssl, sys; print(sys.version); print(ssl.OPENSSL_VERSION)')" \
76
+ && R -q -e "gate <- c('asa','plumber','jsonlite','reticulate','sodium'); missing <- gate[!vapply(gate, function(p) requireNamespace(p, quietly = TRUE), logical(1))]; if (length(missing)) stop(sprintf('Post-asa gate failed; missing: %s', paste(missing, collapse = ', ')), call. = FALSE); cat('Post-asa package gate passed\n')" \
77
  && rm -rf /opt/asa-software/.git
78
 
79
  WORKDIR /app
R/asa_api_helpers.R CHANGED
@@ -3,6 +3,7 @@
3
  }
4
 
5
  asa_api_default_backend <- "gemini"
 
6
 
7
  asa_api_to_bool <- function(value, default = FALSE) {
8
  if (is.null(value) || length(value) == 0L) {
@@ -260,6 +261,10 @@ asa_api_bootstrap <- function() {
260
  if (!requireNamespace("asa", quietly = TRUE)) {
261
  stop("Package `asa` is not installed in this environment.", call. = FALSE)
262
  }
 
 
 
 
263
  invisible(TRUE)
264
  }
265
 
@@ -282,8 +287,71 @@ asa_api_get_header <- function(req, key) {
282
  asa_api_scalar_chr(value, default = "")
283
  }
284
 
285
- asa_api_required_bearer_token <- function() {
286
- "999"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  }
288
 
289
  asa_api_path_requires_bearer_auth <- function(path) {
@@ -301,12 +369,16 @@ asa_api_extract_bearer_token <- function(req) {
301
  }
302
 
303
  asa_api_has_required_bearer_token <- function(req) {
304
- identical(
305
  asa_api_extract_bearer_token(req),
306
- asa_api_required_bearer_token()
307
  )
308
  }
309
 
 
 
 
 
310
  asa_api_require_prompt <- function(payload) {
311
  prompt <- asa_api_scalar_chr(payload$prompt, default = "")
312
  if (!nzchar(trimws(prompt))) {
 
3
  }
4
 
5
  asa_api_default_backend <- "gemini"
6
+ .asa_api_auth_cache <- new.env(parent = emptyenv())
7
 
8
  asa_api_to_bool <- function(value, default = FALSE) {
9
  if (is.null(value) || length(value) == 0L) {
 
261
  if (!requireNamespace("asa", quietly = TRUE)) {
262
  stop("Package `asa` is not installed in this environment.", call. = FALSE)
263
  }
264
+ if (!requireNamespace("sodium", quietly = TRUE)) {
265
+ stop("Package `sodium` is not installed in this environment.", call. = FALSE)
266
+ }
267
+ asa_api_refresh_auth_cache(force = TRUE)
268
  invisible(TRUE)
269
  }
270
 
 
287
  asa_api_scalar_chr(value, default = "")
288
  }
289
 
290
+ asa_api_clear_auth_cache <- function() {
291
+ cached_keys <- ls(envir = .asa_api_auth_cache, all.names = TRUE)
292
+ if (length(cached_keys)) {
293
+ rm(list = cached_keys, envir = .asa_api_auth_cache)
294
+ }
295
+
296
+ invisible(TRUE)
297
+ }
298
+
299
+ asa_api_secret_env_value <- function(name) {
300
+ trimws(Sys.getenv(asa_api_scalar_chr(name, default = ""), unset = ""))
301
+ }
302
+
303
+ asa_api_missing_auth_env_vars <- function() {
304
+ required <- c("ASA_API_BEARER_TOKEN", "GUI_PASSWORD")
305
+ required[!nzchar(vapply(required, asa_api_secret_env_value, character(1)))]
306
+ }
307
+
308
+ asa_api_refresh_auth_cache <- function(force = FALSE) {
309
+ cached_api_hash <- .asa_api_auth_cache$api_bearer_token_hash %||% ""
310
+ cached_gui_hash <- .asa_api_auth_cache$gui_password_hash %||% ""
311
+ if (!isTRUE(force) &&
312
+ is.character(cached_api_hash) && nzchar(cached_api_hash) &&
313
+ is.character(cached_gui_hash) && nzchar(cached_gui_hash)) {
314
+ return(invisible(TRUE))
315
+ }
316
+
317
+ missing_vars <- asa_api_missing_auth_env_vars()
318
+ if (length(missing_vars)) {
319
+ asa_api_clear_auth_cache()
320
+ stop(
321
+ sprintf(
322
+ "Missing required authentication environment variable(s): %s.",
323
+ paste(sprintf("`%s`", missing_vars), collapse = ", ")
324
+ ),
325
+ call. = FALSE
326
+ )
327
+ }
328
+
329
+ .asa_api_auth_cache$api_bearer_token_hash <- sodium::password_store(
330
+ asa_api_secret_env_value("ASA_API_BEARER_TOKEN")
331
+ )
332
+ .asa_api_auth_cache$gui_password_hash <- sodium::password_store(
333
+ asa_api_secret_env_value("GUI_PASSWORD")
334
+ )
335
+
336
+ invisible(TRUE)
337
+ }
338
+
339
+ asa_api_verify_secret <- function(candidate, cache_key) {
340
+ supplied <- asa_api_scalar_chr(candidate, default = "")
341
+ if (!nzchar(supplied)) {
342
+ return(FALSE)
343
+ }
344
+
345
+ tryCatch(
346
+ {
347
+ asa_api_refresh_auth_cache()
348
+ stored_hash <- .asa_api_auth_cache[[asa_api_scalar_chr(cache_key, default = "")]] %||% ""
349
+ is.character(stored_hash) &&
350
+ nzchar(stored_hash) &&
351
+ isTRUE(sodium::password_verify(stored_hash, supplied))
352
+ },
353
+ error = function(e) FALSE
354
+ )
355
  }
356
 
357
  asa_api_path_requires_bearer_auth <- function(path) {
 
369
  }
370
 
371
  asa_api_has_required_bearer_token <- function(req) {
372
+ asa_api_verify_secret(
373
  asa_api_extract_bearer_token(req),
374
+ "api_bearer_token_hash"
375
  )
376
  }
377
 
378
+ asa_api_has_required_gui_password <- function(password) {
379
+ asa_api_verify_secret(password, "gui_password_hash")
380
+ }
381
+
382
  asa_api_require_prompt <- function(payload) {
383
  prompt <- asa_api_scalar_chr(payload$prompt, default = "")
384
  if (!nzchar(trimws(prompt))) {
R/plumber.R CHANGED
@@ -76,8 +76,12 @@ function(req, res) {
76
  return(plumber::forward())
77
  }
78
 
 
 
 
 
79
  if (!asa_api_has_required_bearer_token(req)) {
80
- return(asa_api_error_payload(res, 401L, "Unauthorized: submit Authorization: Bearer 999."))
81
  }
82
 
83
  plumber::forward()
@@ -105,7 +109,7 @@ function() {
105
  #* @serializer unboxedJSON
106
  function(req, res) {
107
  if (is.character(.asa_api_boot_error) && nzchar(trimws(.asa_api_boot_error))) {
108
- return(asa_api_error_payload(res, 503L, .asa_api_boot_error))
109
  }
110
 
111
  parse_error <- NULL
@@ -134,7 +138,7 @@ function(req, res) {
134
  #* @serializer unboxedJSON
135
  function(req, res) {
136
  if (is.character(.asa_api_boot_error) && nzchar(trimws(.asa_api_boot_error))) {
137
- return(asa_api_error_payload(res, 503L, .asa_api_boot_error))
138
  }
139
 
140
  parse_error <- NULL
@@ -163,7 +167,7 @@ function(req, res) {
163
  #* @serializer unboxedJSON
164
  function(req, res) {
165
  if (is.character(.asa_api_boot_error) && nzchar(trimws(.asa_api_boot_error))) {
166
- return(asa_api_error_payload(res, 503L, .asa_api_boot_error))
167
  }
168
 
169
  parse_error <- NULL
@@ -178,10 +182,8 @@ function(req, res) {
178
  return(asa_api_error_payload(res, 400L, parse_error))
179
  }
180
 
181
- gui_password <- trimws(Sys.getenv("GUI_PASSWORD", unset = "XXX"))
182
- supplied_password <- asa_api_scalar_chr(payload$password, default = "")
183
- if (!identical(supplied_password, gui_password)) {
184
- return(asa_api_error_payload(res, 401L, "Unauthorized: invalid GUI password."))
185
  }
186
 
187
  payload$include_raw_output <- FALSE
 
76
  return(plumber::forward())
77
  }
78
 
79
+ if (is.character(.asa_api_boot_error) && nzchar(trimws(.asa_api_boot_error))) {
80
+ return(asa_api_error_payload(res, 503L, "Service unavailable."))
81
+ }
82
+
83
  if (!asa_api_has_required_bearer_token(req)) {
84
+ return(asa_api_error_payload(res, 401L, "Unauthorized."))
85
  }
86
 
87
  plumber::forward()
 
109
  #* @serializer unboxedJSON
110
  function(req, res) {
111
  if (is.character(.asa_api_boot_error) && nzchar(trimws(.asa_api_boot_error))) {
112
+ return(asa_api_error_payload(res, 503L, "Service unavailable."))
113
  }
114
 
115
  parse_error <- NULL
 
138
  #* @serializer unboxedJSON
139
  function(req, res) {
140
  if (is.character(.asa_api_boot_error) && nzchar(trimws(.asa_api_boot_error))) {
141
+ return(asa_api_error_payload(res, 503L, "Service unavailable."))
142
  }
143
 
144
  parse_error <- NULL
 
167
  #* @serializer unboxedJSON
168
  function(req, res) {
169
  if (is.character(.asa_api_boot_error) && nzchar(trimws(.asa_api_boot_error))) {
170
+ return(asa_api_error_payload(res, 503L, "Service unavailable."))
171
  }
172
 
173
  parse_error <- NULL
 
182
  return(asa_api_error_payload(res, 400L, parse_error))
183
  }
184
 
185
+ if (!asa_api_has_required_gui_password(payload$password)) {
186
+ return(asa_api_error_payload(res, 401L, "Unauthorized."))
 
 
187
  }
188
 
189
  payload$include_raw_output <- FALSE
README.md CHANGED
@@ -29,14 +29,16 @@ It uses:
29
  ## Security Model
30
 
31
  - API bearer auth is required for `/v1/*`:
32
- - Include `Authorization: Bearer 999`
33
  - GUI auth is password-based:
34
- - `/gui/query` checks `GUI_PASSWORD`
 
35
 
36
  ## Required Environment Variables
37
 
38
  Set these when running the container:
39
 
 
40
  - `GOOGLE_API_KEY` (or the provider key you use)
41
  - `GUI_PASSWORD`
42
 
@@ -45,6 +47,7 @@ Optional secrets / vars:
45
  - `ASA_DEFAULT_BACKEND` (defaults to `gemini` if unset; examples: `openai`, `groq`, `anthropic`, `gemini`, `openrouter`)
46
  - `ASA_DEFAULT_MODEL` (example: `gemini-2.5-flash`)
47
  - `ASA_CONDA_ENV` (default: `asa_env`)
 
48
  - `ASA_USE_BROWSER_DEFAULT` (default: `false`, recommended for container stability)
49
  - `CORS_ALLOW_ORIGIN` (default: `*`)
50
 
@@ -70,7 +73,7 @@ curl -s http://localhost:7860/healthz
70
  ```bash
71
  curl -s http://localhost:7860/v1/run \
72
  -H "Content-Type: application/json" \
73
- -H "Authorization: Bearer 999" \
74
  -d '{
75
  "prompt": "What is the population of Tokyo?",
76
  "config": {
@@ -88,7 +91,7 @@ curl -s http://localhost:7860/v1/run \
88
 
89
  ```bash
90
  curl -s http://localhost:7860/v1/run \
91
- -H "Authorization: Bearer 999" \
92
  -H "Content-Type: application/json" \
93
  -d '{
94
  "prompt": "Find Marie Curie birth year and nationality. Return JSON.",
@@ -102,7 +105,7 @@ curl -s http://localhost:7860/v1/run \
102
 
103
  ```bash
104
  curl -s http://localhost:7860/v1/batch \
105
- -H "Authorization: Bearer 999" \
106
  -H "Content-Type: application/json" \
107
  -d '{
108
  "prompts": [
@@ -188,7 +191,7 @@ R dependency strategy in this image:
188
  - This avoids source-compile failures such as `sodium` -> `plumber` install breaks.
189
  - The Docker build pre-creates `asa_env` with pinned `python=3.12.3` and `openssl=3.0.13` before calling `asa::build_backend()`, reducing rebuild drift that can break `reticulate` SSL imports on Ubuntu 24.04 / Hugging Face rebuilds.
190
  - Runtime linker guardrails are set so `reticulate` prefers conda environment libraries (`/opt/conda/envs/asa_env/lib` and `/opt/conda/lib`) to avoid C++ ABI loader mismatches.
191
- - Tor is installed in the image and started before the API. If Tor does not become ready, the container exits instead of serving direct search traffic.
192
 
193
  Build args:
194
 
@@ -207,8 +210,9 @@ Local run:
207
 
208
  ```bash
209
  docker run --rm -p 7860:7860 \
 
210
  -e GOOGLE_API_KEY=... \
211
- -e GUI_PASSWORD=XXX \
212
  asa-api
213
  ```
214
 
@@ -216,9 +220,22 @@ Then open:
216
  - `http://localhost:7860/healthz`
217
  - `http://localhost:7860/`
218
 
219
- ## Tor Deployment Defaults
220
 
221
- The container deploys Tor locally and exports these runtime defaults before starting `plumber`:
 
 
 
 
 
 
 
 
 
 
 
 
 
222
 
223
  - `ASA_PROXY=socks5h://127.0.0.1:9050`
224
  - `TOR_CONTROL_PORT=9051`
@@ -230,11 +247,13 @@ Tor scope is intentionally limited:
230
  - LLM provider API traffic remains direct, matching upstream `asa` behavior.
231
  - Browser/Selenium search remains disabled by default.
232
 
233
- Startup behavior is fail-closed:
234
 
235
  - Tor must answer a probe to `https://check.torproject.org/api/ip` with `IsTor=true`.
236
  - If the SOCKS proxy, ControlPort, or cookie setup never becomes ready, the container exits non-zero.
237
 
 
 
238
  `GET /healthz` now includes Tor readiness fields:
239
 
240
  - `tor_enabled`
@@ -246,6 +265,7 @@ Startup behavior is fail-closed:
246
 
247
  You can override the local proxy defaults if needed:
248
 
 
249
  - `ASA_PROXY`
250
  - `TOR_CONTROL_PORT`
251
  - `ASA_TOR_CONTROL_COOKIE`
 
29
  ## Security Model
30
 
31
  - API bearer auth is required for `/v1/*`:
32
+ - Include `Authorization: Bearer $ASA_API_BEARER_TOKEN`
33
  - GUI auth is password-based:
34
+ - `/gui/query` verifies the submitted password against an in-memory hash derived from `GUI_PASSWORD`
35
+ - The service fails closed if either auth secret env var is missing or blank.
36
 
37
  ## Required Environment Variables
38
 
39
  Set these when running the container:
40
 
41
+ - `ASA_API_BEARER_TOKEN`
42
  - `GOOGLE_API_KEY` (or the provider key you use)
43
  - `GUI_PASSWORD`
44
 
 
47
  - `ASA_DEFAULT_BACKEND` (defaults to `gemini` if unset; examples: `openai`, `groq`, `anthropic`, `gemini`, `openrouter`)
48
  - `ASA_DEFAULT_MODEL` (example: `gemini-2.5-flash`)
49
  - `ASA_CONDA_ENV` (default: `asa_env`)
50
+ - `ASA_ENABLE_TOR` (default: `false`; set to `true` to route ASA search/web traffic through the bundled local Tor proxy)
51
  - `ASA_USE_BROWSER_DEFAULT` (default: `false`, recommended for container stability)
52
  - `CORS_ALLOW_ORIGIN` (default: `*`)
53
 
 
73
  ```bash
74
  curl -s http://localhost:7860/v1/run \
75
  -H "Content-Type: application/json" \
76
+ -H "Authorization: Bearer ${ASA_API_BEARER_TOKEN}" \
77
  -d '{
78
  "prompt": "What is the population of Tokyo?",
79
  "config": {
 
91
 
92
  ```bash
93
  curl -s http://localhost:7860/v1/run \
94
+ -H "Authorization: Bearer ${ASA_API_BEARER_TOKEN}" \
95
  -H "Content-Type: application/json" \
96
  -d '{
97
  "prompt": "Find Marie Curie birth year and nationality. Return JSON.",
 
105
 
106
  ```bash
107
  curl -s http://localhost:7860/v1/batch \
108
+ -H "Authorization: Bearer ${ASA_API_BEARER_TOKEN}" \
109
  -H "Content-Type: application/json" \
110
  -d '{
111
  "prompts": [
 
191
  - This avoids source-compile failures such as `sodium` -> `plumber` install breaks.
192
  - The Docker build pre-creates `asa_env` with pinned `python=3.12.3` and `openssl=3.0.13` before calling `asa::build_backend()`, reducing rebuild drift that can break `reticulate` SSL imports on Ubuntu 24.04 / Hugging Face rebuilds.
193
  - Runtime linker guardrails are set so `reticulate` prefers conda environment libraries (`/opt/conda/envs/asa_env/lib` and `/opt/conda/lib`) to avoid C++ ABI loader mismatches.
194
+ - Tor is installed in the image and remains available for opt-in startup through `ASA_ENABLE_TOR=true`.
195
 
196
  Build args:
197
 
 
210
 
211
  ```bash
212
  docker run --rm -p 7860:7860 \
213
+ -e ASA_API_BEARER_TOKEN=your-api-bearer-token \
214
  -e GOOGLE_API_KEY=... \
215
+ -e GUI_PASSWORD=your-gui-password \
216
  asa-api
217
  ```
218
 
 
220
  - `http://localhost:7860/healthz`
221
  - `http://localhost:7860/`
222
 
223
+ Tor stays off in that default run. To opt in later:
224
 
225
+ ```bash
226
+ docker run --rm -p 7860:7860 \
227
+ -e ASA_API_BEARER_TOKEN=your-api-bearer-token \
228
+ -e GOOGLE_API_KEY=... \
229
+ -e GUI_PASSWORD=your-gui-password \
230
+ -e ASA_ENABLE_TOR=true \
231
+ asa-api
232
+ ```
233
+
234
+ ## Tor Integration
235
+
236
+ The image still includes Tor and the Tor entrypoint integration, but `asa-api` does not activate it unless `ASA_ENABLE_TOR=true`.
237
+
238
+ When Tor is enabled, the container deploys Tor locally and exports these runtime defaults before starting `plumber`:
239
 
240
  - `ASA_PROXY=socks5h://127.0.0.1:9050`
241
  - `TOR_CONTROL_PORT=9051`
 
247
  - LLM provider API traffic remains direct, matching upstream `asa` behavior.
248
  - Browser/Selenium search remains disabled by default.
249
 
250
+ Startup behavior when `ASA_ENABLE_TOR=true` is fail-closed:
251
 
252
  - Tor must answer a probe to `https://check.torproject.org/api/ip` with `IsTor=true`.
253
  - If the SOCKS proxy, ControlPort, or cookie setup never becomes ready, the container exits non-zero.
254
 
255
+ When `ASA_ENABLE_TOR=false`, the startup script skips Tor bootstrap and clears Tor proxy env vars before launching the API, so the agent pipeline runs direct.
256
+
257
  `GET /healthz` now includes Tor readiness fields:
258
 
259
  - `tor_enabled`
 
265
 
266
  You can override the local proxy defaults if needed:
267
 
268
+ - `ASA_ENABLE_TOR`
269
  - `ASA_PROXY`
270
  - `TOR_CONTROL_PORT`
271
  - `ASA_TOR_CONTROL_COOKIE`
Tests/api_contract_smoke.R CHANGED
@@ -2,6 +2,36 @@ suppressPackageStartupMessages({
2
  library(asa)
3
  })
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  args <- commandArgs(trailingOnly = FALSE)
6
  file_arg <- "--file="
7
  script_arg <- args[grepl(paste0("^", file_arg), args)]
@@ -13,6 +43,7 @@ script_path <- if (length(script_arg)) {
13
 
14
  repo_root <- normalizePath(file.path(dirname(script_path), ".."), mustWork = TRUE)
15
  source(file.path(repo_root, "R", "asa_api_helpers.R"))
 
16
 
17
  assert_true <- function(condition, message) {
18
  if (!isTRUE(condition)) {
@@ -136,13 +167,19 @@ assert_true(
136
  "Health and GUI routes should remain outside bearer auth scope."
137
  )
138
  assert_true(
139
- identical(asa_api_required_bearer_token(), "999"),
140
- "Bearer auth should require the fixed token 999."
 
 
 
 
 
 
141
  )
142
  assert_true(
143
  identical(
144
- asa_api_extract_bearer_token(mock_request(authorization = "Bearer 999")),
145
- "999"
146
  ),
147
  "Bearer extraction should accept the required token."
148
  )
@@ -150,31 +187,58 @@ assert_true(
150
  identical(
151
  asa_api_extract_bearer_token(list(
152
  PATH_INFO = "/v1/run",
153
- HEADERS = list(Authorization = "Bearer 999")
154
  )),
155
- "999"
156
  ),
157
  "Bearer extraction should match Authorization headers case-insensitively."
158
  )
159
  assert_true(
160
  identical(
161
- asa_api_extract_bearer_token(mock_request(authorization = "Basic 999")),
162
  ""
163
  ),
164
  "Non-bearer Authorization schemes should not be accepted."
165
  )
166
  assert_true(
167
- isTRUE(asa_api_has_required_bearer_token(mock_request(authorization = "Bearer 999"))),
168
- "Bearer auth should accept Authorization: Bearer 999."
 
 
169
  )
170
  assert_true(
171
  !isTRUE(asa_api_has_required_bearer_token(mock_request(authorization = "Bearer wrong"))),
172
  "Bearer auth should reject the wrong token."
173
  )
174
  assert_true(
175
- !isTRUE(asa_api_has_required_bearer_token(mock_request(x_api_key = "999"))),
176
  "Legacy x-api-key auth should no longer be accepted."
177
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
 
179
  health_payload <- asa_api_health_payload()
180
  assert_true(
@@ -193,6 +257,26 @@ if (isTRUE(health_payload$direct_provider_available)) {
193
  )
194
  }
195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  {
197
  original_asa <- asa_api_run_single_via_asa
198
  original_direct <- asa_api_run_single_via_direct
 
2
  library(asa)
3
  })
4
 
5
+ auth_fixture <- c(
6
+ ASA_API_BEARER_TOKEN = "test-bearer-secret",
7
+ GUI_PASSWORD = "test-gui-secret"
8
+ )
9
+ tor_fixture_names <- c(
10
+ "ASA_ENABLE_TOR",
11
+ "ASA_PROXY",
12
+ "TOR_CONTROL_PORT",
13
+ "ASA_TOR_CONTROL_COOKIE"
14
+ )
15
+ managed_env_names <- c(names(auth_fixture), tor_fixture_names)
16
+ previous_managed_env <- Sys.getenv(managed_env_names, unset = NA_character_)
17
+ restore_managed_env <- function() {
18
+ for (name in names(previous_managed_env)) {
19
+ value <- previous_managed_env[[name]]
20
+ if (is.na(value)) {
21
+ Sys.unsetenv(name)
22
+ } else {
23
+ do.call(Sys.setenv, stats::setNames(list(value), name))
24
+ }
25
+ }
26
+ }
27
+ on.exit({
28
+ restore_managed_env()
29
+ if (exists("asa_api_clear_auth_cache", mode = "function")) {
30
+ asa_api_clear_auth_cache()
31
+ }
32
+ }, add = TRUE)
33
+ do.call(Sys.setenv, as.list(auth_fixture))
34
+
35
  args <- commandArgs(trailingOnly = FALSE)
36
  file_arg <- "--file="
37
  script_arg <- args[grepl(paste0("^", file_arg), args)]
 
43
 
44
  repo_root <- normalizePath(file.path(dirname(script_path), ".."), mustWork = TRUE)
45
  source(file.path(repo_root, "R", "asa_api_helpers.R"))
46
+ asa_api_refresh_auth_cache(force = TRUE)
47
 
48
  assert_true <- function(condition, message) {
49
  if (!isTRUE(condition)) {
 
167
  "Health and GUI routes should remain outside bearer auth scope."
168
  )
169
  assert_true(
170
+ identical(asa_api_missing_auth_env_vars(), character(0)),
171
+ "Auth bootstrap should require explicit bearer-token and GUI-password env vars."
172
+ )
173
+ assert_true(
174
+ is.character(.asa_api_auth_cache$api_bearer_token_hash) &&
175
+ nzchar(.asa_api_auth_cache$api_bearer_token_hash) &&
176
+ !identical(.asa_api_auth_cache$api_bearer_token_hash, auth_fixture[["ASA_API_BEARER_TOKEN"]]),
177
+ "Bearer auth should store a derived hash rather than the raw bearer token."
178
  )
179
  assert_true(
180
  identical(
181
+ asa_api_extract_bearer_token(mock_request(authorization = sprintf("Bearer %s", auth_fixture[["ASA_API_BEARER_TOKEN"]]))),
182
+ auth_fixture[["ASA_API_BEARER_TOKEN"]]
183
  ),
184
  "Bearer extraction should accept the required token."
185
  )
 
187
  identical(
188
  asa_api_extract_bearer_token(list(
189
  PATH_INFO = "/v1/run",
190
+ HEADERS = list(Authorization = sprintf("Bearer %s", auth_fixture[["ASA_API_BEARER_TOKEN"]]))
191
  )),
192
+ auth_fixture[["ASA_API_BEARER_TOKEN"]]
193
  ),
194
  "Bearer extraction should match Authorization headers case-insensitively."
195
  )
196
  assert_true(
197
  identical(
198
+ asa_api_extract_bearer_token(mock_request(authorization = sprintf("Basic %s", auth_fixture[["ASA_API_BEARER_TOKEN"]]))),
199
  ""
200
  ),
201
  "Non-bearer Authorization schemes should not be accepted."
202
  )
203
  assert_true(
204
+ isTRUE(asa_api_has_required_bearer_token(mock_request(
205
+ authorization = sprintf("Bearer %s", auth_fixture[["ASA_API_BEARER_TOKEN"]])
206
+ ))),
207
+ "Bearer auth should accept the configured Authorization header."
208
  )
209
  assert_true(
210
  !isTRUE(asa_api_has_required_bearer_token(mock_request(authorization = "Bearer wrong"))),
211
  "Bearer auth should reject the wrong token."
212
  )
213
  assert_true(
214
+ !isTRUE(asa_api_has_required_bearer_token(mock_request(x_api_key = auth_fixture[["ASA_API_BEARER_TOKEN"]]))),
215
  "Legacy x-api-key auth should no longer be accepted."
216
  )
217
+ assert_true(
218
+ isTRUE(asa_api_has_required_gui_password(auth_fixture[["GUI_PASSWORD"]])),
219
+ "GUI auth should accept the configured password."
220
+ )
221
+ assert_true(
222
+ !isTRUE(asa_api_has_required_gui_password("wrong-password")),
223
+ "GUI auth should reject the wrong password."
224
+ )
225
+
226
+ asa_api_clear_auth_cache()
227
+ Sys.unsetenv("ASA_API_BEARER_TOKEN")
228
+ expect_error_contains(
229
+ asa_api_refresh_auth_cache(force = TRUE),
230
+ "ASA_API_BEARER_TOKEN"
231
+ )
232
+ do.call(Sys.setenv, stats::setNames(list(auth_fixture[["ASA_API_BEARER_TOKEN"]]), "ASA_API_BEARER_TOKEN"))
233
+
234
+ asa_api_clear_auth_cache()
235
+ Sys.unsetenv("GUI_PASSWORD")
236
+ expect_error_contains(
237
+ asa_api_refresh_auth_cache(force = TRUE),
238
+ "GUI_PASSWORD"
239
+ )
240
+ do.call(Sys.setenv, stats::setNames(list(auth_fixture[["GUI_PASSWORD"]]), "GUI_PASSWORD"))
241
+ asa_api_refresh_auth_cache(force = TRUE)
242
 
243
  health_payload <- asa_api_health_payload()
244
  assert_true(
 
257
  )
258
  }
259
 
260
+ Sys.unsetenv(tor_fixture_names)
261
+ tor_health_disabled <- asa_api_tor_health()
262
+ assert_true(
263
+ identical(tor_health_disabled$tor_enabled, FALSE) &&
264
+ identical(tor_health_disabled$tor_ready, FALSE),
265
+ "Tor health should report disabled when the proxy env vars are absent."
266
+ )
267
+ assert_true(
268
+ is.null(tor_health_disabled$tor_proxy) &&
269
+ is.null(tor_health_disabled$tor_control_port),
270
+ "Tor health should omit proxy details when Tor is disabled."
271
+ )
272
+ health_without_tor <- asa_api_health_payload()
273
+ assert_true(
274
+ identical(health_without_tor$status, "ok"),
275
+ "Service health should remain ok when Tor is intentionally disabled."
276
+ )
277
+ do.call(Sys.setenv, as.list(auth_fixture))
278
+ asa_api_refresh_auth_cache(force = TRUE)
279
+
280
  {
281
  original_asa <- asa_api_run_single_via_asa
282
  original_direct <- asa_api_run_single_via_direct
Tests/example_API_call.R CHANGED
@@ -3,11 +3,15 @@ library(httr2)
3
  library(jsonlite)
4
 
5
  base_url <- "http://localhost:7860"
 
 
 
 
6
 
7
  resp <- request(paste0(base_url, "/v1/run")) |>
8
  req_headers(
9
  `Content-Type` = "application/json",
10
- Authorization = "Bearer 999"
11
  ) |>
12
  req_body_json(list(prompt = "Return the single word OK."), auto_unbox = TRUE) |>
13
  req_perform()
 
3
  library(jsonlite)
4
 
5
  base_url <- "http://localhost:7860"
6
+ bearer_token <- trimws(Sys.getenv("ASA_API_BEARER_TOKEN", unset = ""))
7
+ if (!nzchar(bearer_token)) {
8
+ stop("Set ASA_API_BEARER_TOKEN before running this example.", call. = FALSE)
9
+ }
10
 
11
  resp <- request(paste0(base_url, "/v1/run")) |>
12
  req_headers(
13
  `Content-Type` = "application/json",
14
+ Authorization = sprintf("Bearer %s", bearer_token)
15
  ) |>
16
  req_body_json(list(prompt = "Return the single word OK."), auto_unbox = TRUE) |>
17
  req_perform()
Tests/startup_no_tor_smoke.sh ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ repo_root="$(cd "${script_dir}/.." && pwd)"
6
+ tmp_dir="$(mktemp -d)"
7
+ stub_dir="${tmp_dir}/bin"
8
+ env_capture="${tmp_dir}/env.txt"
9
+ tor_called="${tmp_dir}/tor-called"
10
+
11
+ cleanup() {
12
+ rm -rf "${tmp_dir}"
13
+ }
14
+ trap cleanup EXIT
15
+
16
+ mkdir -p "${stub_dir}"
17
+
18
+ cat > "${stub_dir}/tor" <<EOF
19
+ #!/usr/bin/env bash
20
+ echo called > "${tor_called}"
21
+ exit 99
22
+ EOF
23
+
24
+ cat > "${stub_dir}/Rscript" <<EOF
25
+ #!/usr/bin/env bash
26
+ env | sort > "${env_capture}"
27
+ exit 0
28
+ EOF
29
+
30
+ chmod +x "${stub_dir}/tor" "${stub_dir}/Rscript"
31
+
32
+ (
33
+ cd "${repo_root}"
34
+ PATH="${stub_dir}:${PATH}" \
35
+ ASA_ENABLE_TOR=false \
36
+ ASA_PROXY=socks5h://127.0.0.1:9050 \
37
+ TOR_CONTROL_PORT=9051 \
38
+ ASA_TOR_CONTROL_COOKIE=/tmp/tor/control.authcookie \
39
+ PORT=8123 \
40
+ bash scripts/start-with-tor.sh
41
+ )
42
+
43
+ if [[ -f "${tor_called}" ]]; then
44
+ echo "Tor should not be invoked when ASA_ENABLE_TOR=false." >&2
45
+ exit 1
46
+ fi
47
+
48
+ if [[ ! -s "${env_capture}" ]]; then
49
+ echo "Rscript stub did not capture the environment." >&2
50
+ exit 1
51
+ fi
52
+
53
+ if grep -Eq '^(ASA_PROXY|TOR_CONTROL_PORT|ASA_TOR_CONTROL_COOKIE)=' "${env_capture}"; then
54
+ echo "Tor-related proxy environment variables should be cleared before starting the API." >&2
55
+ exit 1
56
+ fi
57
+
58
+ if ! grep -q '^ASA_ENABLE_TOR=false$' "${env_capture}"; then
59
+ echo "ASA_ENABLE_TOR should remain visible to the API process." >&2
60
+ exit 1
61
+ fi
62
+
63
+ if ! grep -q '^PORT=8123$' "${env_capture}"; then
64
+ echo "Expected the API process to receive the configured PORT." >&2
65
+ exit 1
66
+ fi
67
+
68
+ echo "startup_no_tor_smoke passed"
scripts/start-with-tor.sh CHANGED
@@ -5,6 +5,28 @@ log() {
5
  printf '[asa-api] %s\n' "$*" >&2
6
  }
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  proxy_url="${ASA_PROXY:-socks5h://127.0.0.1:9050}"
9
  proxy_without_scheme="${proxy_url#*://}"
10
  proxy_host_port="${proxy_without_scheme%%/*}"
@@ -118,5 +140,5 @@ export ASA_PROXY="${proxy_url}"
118
  export TOR_CONTROL_PORT="${control_port}"
119
  export ASA_TOR_CONTROL_COOKIE="${tor_cookie_path}"
120
 
121
- log "Tor ready; starting API on port ${PORT:-7860}"
122
- exec Rscript -e 'pr <- plumber::plumb("R/plumber.R"); pr$run(host = "0.0.0.0", port = as.integer(Sys.getenv("PORT", "7860")))'
 
5
  printf '[asa-api] %s\n' "$*" >&2
6
  }
7
 
8
+ run_api() {
9
+ log "Starting API on port ${PORT:-7860}"
10
+ exec Rscript -e 'pr <- plumber::plumb("R/plumber.R"); pr$run(host = "0.0.0.0", port = as.integer(Sys.getenv("PORT", "7860")))'
11
+ }
12
+
13
+ enable_tor="${ASA_ENABLE_TOR:-false}"
14
+ enable_tor="$(printf '%s' "${enable_tor}" | tr '[:upper:]' '[:lower:]')"
15
+
16
+ case "${enable_tor}" in
17
+ 1|true|t|yes|y|on)
18
+ ;;
19
+ 0|false|f|no|n|off|'')
20
+ log "Tor integration disabled for this run; clearing proxy environment"
21
+ unset ASA_PROXY TOR_CONTROL_PORT ASA_TOR_CONTROL_COOKIE
22
+ run_api
23
+ ;;
24
+ *)
25
+ log "ASA_ENABLE_TOR must be a boolean value. Got: ${ASA_ENABLE_TOR:-}"
26
+ exit 1
27
+ ;;
28
+ esac
29
+
30
  proxy_url="${ASA_PROXY:-socks5h://127.0.0.1:9050}"
31
  proxy_without_scheme="${proxy_url#*://}"
32
  proxy_host_port="${proxy_without_scheme%%/*}"
 
140
  export TOR_CONTROL_PORT="${control_port}"
141
  export ASA_TOR_CONTROL_COOKIE="${tor_cookie_path}"
142
 
143
+ log "Tor ready; routing agent traffic through ${proxy_url}"
144
+ run_api