From 562deb3038f8f458d129315653db1aeab2d0cc04 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 13:46:13 +0000 Subject: [PATCH 1/2] feat: offer rstudioapi from the vscode-R-managed tool library Move rstudioapi from sess Imports to Suggests. When RStudio API emulation is enabled and no compatible rstudioapi is available, terminal startup and manual attach offer to install it into the managed tool library, at most once per extension host session, and load it before sess connects. Consent requests now carry a prompt policy instead of the plot backend, and prompt state and Don't ask again are tracked per package. The private R Interactive runtime still installs rstudioapi. --- .github/workflows/main.yml | 2 + R/attach_sess.R | 74 ++++- R/{prepare-jgd.R => prepare-tools.R} | 107 +++++-- R/profile.R | 1 + R/sess-package-install.R | 3 + R/sess_source.R | 2 +- R/tests/prepare-jgd.R | 21 +- R/tests/prepare-rstudioapi.R | 295 ++++++++++++++++++ R/tests/sess_source.R | 44 +++ R/tests/tool-consent.R | 40 +-- R/tests/tool-library.R | 6 +- R/tool-consent.R | 19 +- R/tool-library.R | 2 +- package.json | 3 +- sess/DESCRIPTION | 4 +- sess/inst/tinytest/test-rstudioapi-optional.R | 81 +++++ src/jgdVersion.ts | 2 - src/rTerminal.ts | 16 +- src/session.ts | 23 +- src/test/examples/sess-installer.R | 2 + src/test/node/interactiveLibraries.test.ts | 3 +- src/test/node/toolConsent.test.ts | 186 ++++++++--- src/test/suite/session.test.ts | 28 +- src/test/suite/terminal.test.ts | 18 +- src/test/suite/toolConsentAdapter.test.ts | 94 +++++- src/toolConsent.ts | 70 +++-- src/toolConsentAdapter.ts | 48 ++- src/toolVersions.ts | 13 + 28 files changed, 989 insertions(+), 218 deletions(-) rename R/{prepare-jgd.R => prepare-tools.R} (50%) create mode 100644 R/tests/prepare-rstudioapi.R create mode 100644 sess/inst/tinytest/test-rstudioapi-optional.R delete mode 100644 src/jgdVersion.ts create mode 100644 src/toolVersions.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 517e1cef3..ce009dfb7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -48,6 +48,8 @@ jobs: - run: pnpm run build - name: Verify managed jgd preparation run: pnpm run test:prepare-jgd + - name: Verify managed rstudioapi preparation + run: pnpm run test:prepare-rstudioapi - name: Verify installed sess identity run: | source("R/sess_source.R") diff --git a/R/attach_sess.R b/R/attach_sess.R index 500c6dd40..54d107483 100644 --- a/R/attach_sess.R +++ b/R/attach_sess.R @@ -1,9 +1,26 @@ # Shared terminal and manual-attach preparation. Installation requires a # single-use grant from the extension process; .libPaths() is never changed. + +# Source the tool preparation helper into a fresh environment. Returns NULL +# after reporting the failure when it cannot be loaded. +vscode_r_load_prepare_helper <- function(helper, package) { + setup <- new.env(parent = environment(vscode_r_load_prepare_helper)) + loaded <- nzchar(helper) && file.exists(helper) && + isTRUE(tryCatch({ + sys.source(helper, envir = setup) + TRUE + }, error = function(error) { + message("vscode-R could not load managed ", package, " setup resources: ", + conditionMessage(error)) + FALSE + })) + if (loaded) setup else NULL +} + vscode_r_with_jgd <- function( plot_backend, connect, - jgd_helper = Sys.getenv("VSCODE_R_JGD_HELPER", unset = ""), + tool_prepare_helper = Sys.getenv("VSCODE_R_TOOL_PREPARE_HELPER", unset = ""), tool_root = Sys.getenv("VSCODE_R_TOOL_ROOT", unset = ""), tool_consent_directory = Sys.getenv("VSCODE_R_TOOL_CONSENT_DIRECTORY", unset = ""), tool_library_helper = Sys.getenv("VSCODE_R_TOOL_LIBRARY_HELPER", unset = ""), @@ -15,17 +32,8 @@ vscode_r_with_jgd <- function( if (!plot_backend %in% c("jgd", "auto")) { return(connect(allow_jgd = TRUE)) } - jgd_setup <- new.env(parent = environment()) - loaded <- nzchar(jgd_helper) && file.exists(jgd_helper) && - isTRUE(tryCatch({ - sys.source(jgd_helper, envir = jgd_setup) - TRUE - }, error = function(error) { - message("vscode-R could not load managed jgd setup resources: ", - conditionMessage(error)) - FALSE - })) - if (loaded && is.function(jgd_setup$vscode_r_connect_with_jgd)) { + jgd_setup <- vscode_r_load_prepare_helper(tool_prepare_helper, "jgd") + if (!is.null(jgd_setup) && is.function(jgd_setup$vscode_r_connect_with_jgd)) { return(jgd_setup$vscode_r_connect_with_jgd( plot_backend, connect, tool_root, tool_consent_directory, tool_library_helper, tool_consent_helper, can_prompt, minimum_version @@ -35,6 +43,33 @@ vscode_r_with_jgd <- function( connect(allow_jgd = FALSE) } +# Prepare rstudioapi before sess starts its emulation. Declining or failing only +# prints a message; sess emulation stays enabled and patches rstudioapi +# whenever it is loaded. Does nothing when emulation is disabled. +vscode_r_with_rstudioapi <- function( + use_rstudioapi, + tool_prepare_helper = Sys.getenv("VSCODE_R_TOOL_PREPARE_HELPER", unset = ""), + tool_root = Sys.getenv("VSCODE_R_TOOL_ROOT", unset = ""), + tool_consent_directory = Sys.getenv("VSCODE_R_TOOL_CONSENT_DIRECTORY", unset = ""), + tool_library_helper = Sys.getenv("VSCODE_R_TOOL_LIBRARY_HELPER", unset = ""), + tool_consent_helper = Sys.getenv("VSCODE_R_TOOL_CONSENT_HELPER", unset = ""), + can_prompt = identical(Sys.getenv("VSCODE_R_RSTUDIOAPI_CAN_PROMPT", unset = "TRUE"), "TRUE"), + minimum_version = Sys.getenv("VSCODE_R_RSTUDIOAPI_MINIMUM_VERSION", unset = "") +) { + if (identical(use_rstudioapi, FALSE)) { + return(invisible(FALSE)) + } + setup <- vscode_r_load_prepare_helper(tool_prepare_helper, "rstudioapi") + if (!is.null(setup) && is.function(setup$vscode_r_prepare_rstudioapi)) { + return(invisible(isTRUE(setup$vscode_r_prepare_rstudioapi( + use_rstudioapi, tool_root, tool_consent_directory, + tool_library_helper, tool_consent_helper, can_prompt, minimum_version + )))) + } + message("vscode-R could not load managed rstudioapi setup resources; continuing without it.") + invisible(FALSE) +} + vscode_r_prepare_sess <- function(pkg_path, managed_root, consent_dir, source_helper, installer_helper, timeout_seconds = 180, @@ -231,10 +266,16 @@ vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, tool_consent_helper = Sys.getenv( "VSCODE_R_TOOL_CONSENT_HELPER", unset = "" ), - jgd_helper = Sys.getenv("VSCODE_R_JGD_HELPER", unset = ""), + tool_prepare_helper = Sys.getenv( + "VSCODE_R_TOOL_PREPARE_HELPER", unset = "" + ), jgd_can_prompt = TRUE, jgd_minimum_version = Sys.getenv( "VSCODE_R_JGD_MINIMUM_VERSION", unset = "" + ), + rstudioapi_can_prompt = TRUE, + rstudioapi_minimum_version = Sys.getenv( + "VSCODE_R_RSTUDIOAPI_MINIMUM_VERSION", unset = "" )) { if (!is.null(attachment_id) && !(is.character(attachment_id) && length(attachment_id) == 1L && @@ -291,13 +332,18 @@ vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, args$attachment_id <- attachment_id args$auto_activate <- auto_activate } + vscode_r_with_rstudioapi( + use_rstudioapi, tool_prepare_helper, tool_root, tool_consent_directory, + tool_library_helper, tool_consent_helper, rstudioapi_can_prompt, + rstudioapi_minimum_version + ) vscode_r_with_jgd( plot_backend, function(allow_jgd = TRUE) { args$allow_jgd <- allow_jgd do.call(connect, args) }, - jgd_helper, tool_root, tool_consent_directory, + tool_prepare_helper, tool_root, tool_consent_directory, tool_library_helper, tool_consent_helper, jgd_can_prompt, jgd_minimum_version ) } diff --git a/R/prepare-jgd.R b/R/prepare-tools.R similarity index 50% rename from R/prepare-jgd.R rename to R/prepare-tools.R index ca5a8b80f..dc1e3aeae 100644 --- a/R/prepare-jgd.R +++ b/R/prepare-tools.R @@ -20,68 +20,127 @@ vscode_r_connect_with_jgd <- function( connect(allow_jgd = ready) } -# Prepare optional plot support without making sess startup depend on CRAN access -# or user consent. The managed library is loaded before sess checks the package. -vscode_r_prepare_jgd <- function( - plot_backend, - tool_root = Sys.getenv("VSCODE_R_TOOL_ROOT", unset = ""), - consent_directory = Sys.getenv("VSCODE_R_TOOL_CONSENT_DIRECTORY", unset = ""), - library_helper = Sys.getenv("VSCODE_R_TOOL_LIBRARY_HELPER", unset = ""), - consent_helper = Sys.getenv("VSCODE_R_TOOL_CONSENT_HELPER", unset = ""), - can_prompt = identical(Sys.getenv("VSCODE_R_JGD_CAN_PROMPT", unset = "TRUE"), "TRUE"), - minimum_version = Sys.getenv("VSCODE_R_JGD_MINIMUM_VERSION", unset = "") +# Select, install if approved, and eagerly load one tool package. Returns TRUE +# when a compatible copy is loaded, otherwise prints a short message and +# returns FALSE so the caller can continue with its fallback. `policy` is the +# consent policy sent to the extension ("always" or "once"); `fallback` and +# `support` only word the messages. An already loaded namespace is never replaced. +vscode_r_prepare_tool_package <- function( + package, + policy, + tool_root, + consent_directory, + library_helper, + consent_helper, + can_prompt, + minimum_version, + support, + fallback, + report_load_failure = TRUE ) { - if (!is.character(plot_backend) || length(plot_backend) != 1L || - is.na(plot_backend) || !plot_backend %in% c("jgd", "auto")) { - return(FALSE) - } if (!(is.logical(can_prompt) && length(can_prompt) == 1L && !is.na(can_prompt))) { stop("can_prompt must be a single non-NA logical value.", call. = FALSE) } helpers <- c(library_helper, consent_helper) if (any(!nzchar(helpers)) || any(!file.exists(helpers)) || !nzchar(tool_root)) { - message("vscode-R could not locate managed jgd setup resources; using the available plot fallback.") + message("vscode-R could not locate managed ", package, " setup resources; ", fallback, ".") return(FALSE) } tryCatch({ - tool_environment <- new.env(parent = environment(vscode_r_prepare_jgd)) + tool_environment <- new.env(parent = environment(vscode_r_prepare_tool_package)) for (helper in helpers) { sys.source(helper, envir = tool_environment) } if (!can_prompt) { # A compatible ordinary or managed copy remains usable without resolving a new plan. return(!is.null(tool_environment$tool_library_load( - "jgd", minimum_version, tool_root + package, minimum_version, tool_root ))) } prepared <- tool_environment$tool_library_prepare( - "jgd", + package, minimum_version, tool_root, consent_directory, - plot_backend, + policy, consent_timeout_seconds = 180, install_timeout_seconds = 300, resolution_timeout_seconds = 30 ) if (is.null(prepared)) { - message("vscode-R optional jgd installation was not approved; using the available plot fallback.") + message("vscode-R optional ", package, " installation was not approved; ", fallback, ".") return(FALSE) } namespace <- tool_environment$tool_library_load( - "jgd", minimum_version, tool_root + package, minimum_version, tool_root ) if (is.null(namespace)) { - message("vscode-R could not load a compatible jgd package; using the available plot fallback.") + if (report_load_failure) { + message("vscode-R could not load a compatible ", package, " package; ", fallback, ".") + } return(FALSE) } TRUE }, error = function(error) { - message("vscode-R could not prepare optional jgd plot support: ", conditionMessage(error)) + message("vscode-R could not prepare optional ", support, ": ", conditionMessage(error)) FALSE }, interrupt = function(error) { - message("vscode-R jgd preparation was interrupted; using the available plot fallback.") + message("vscode-R ", package, " preparation was interrupted; ", fallback, ".") FALSE }) } + +# Prepare optional plot support without making sess startup depend on CRAN access +# or user consent. The managed library is loaded before sess checks the package. +vscode_r_prepare_jgd <- function( + plot_backend, + tool_root = Sys.getenv("VSCODE_R_TOOL_ROOT", unset = ""), + consent_directory = Sys.getenv("VSCODE_R_TOOL_CONSENT_DIRECTORY", unset = ""), + library_helper = Sys.getenv("VSCODE_R_TOOL_LIBRARY_HELPER", unset = ""), + consent_helper = Sys.getenv("VSCODE_R_TOOL_CONSENT_HELPER", unset = ""), + can_prompt = identical(Sys.getenv("VSCODE_R_JGD_CAN_PROMPT", unset = "TRUE"), "TRUE"), + minimum_version = Sys.getenv("VSCODE_R_JGD_MINIMUM_VERSION", unset = "") +) { + if (!is.character(plot_backend) || length(plot_backend) != 1L || + is.na(plot_backend) || !plot_backend %in% c("jgd", "auto")) { + return(FALSE) + } + # An explicit jgd backend is offered installation on every request; auto only once. + vscode_r_prepare_tool_package( + "jgd", + if (identical(plot_backend, "jgd")) "always" else "once", + tool_root, consent_directory, library_helper, consent_helper, + can_prompt, minimum_version, + support = "jgd plot support", + fallback = "using the available plot fallback" + ) +} + +# Make rstudioapi available before sess starts its emulation, so that +# requireNamespace("rstudioapi") succeeds even in isolated project libraries. +# Emulation stays enabled whatever the outcome: sess patches rstudioapi when it +# is loaded now or later. Does nothing when emulation is disabled. +vscode_r_prepare_rstudioapi <- function( + use_rstudioapi, + tool_root = Sys.getenv("VSCODE_R_TOOL_ROOT", unset = ""), + consent_directory = Sys.getenv("VSCODE_R_TOOL_CONSENT_DIRECTORY", unset = ""), + library_helper = Sys.getenv("VSCODE_R_TOOL_LIBRARY_HELPER", unset = ""), + consent_helper = Sys.getenv("VSCODE_R_TOOL_CONSENT_HELPER", unset = ""), + can_prompt = identical(Sys.getenv("VSCODE_R_RSTUDIOAPI_CAN_PROMPT", unset = "TRUE"), "TRUE"), + minimum_version = Sys.getenv("VSCODE_R_RSTUDIOAPI_MINIMUM_VERSION", unset = "") +) { + # sess treats an unset (NA) value as enabled. + if (identical(use_rstudioapi, FALSE)) { + return(FALSE) + } + vscode_r_prepare_tool_package( + "rstudioapi", "once", + tool_root, consent_directory, library_helper, consent_helper, + can_prompt, minimum_version, + support = "rstudioapi support", + fallback = "continuing without it", + # An incompatible loaded namespace is already reported by the loader. + report_load_failure = FALSE + ) +} diff --git a/R/profile.R b/R/profile.R index 937402003..b8b4a6d9e 100644 --- a/R/profile.R +++ b/R/profile.R @@ -52,6 +52,7 @@ invisible(local({ plot_backend <- Sys.getenv("SESS_PLOT_BACKEND", "auto") connect <- get("connect", envir = ns, inherits = FALSE) use_rstudioapi <- as.logical(Sys.getenv("SESS_RSTUDIOAPI", "TRUE")) + vscode_r_with_rstudioapi(use_rstudioapi) result <- vscode_r_with_jgd(plot_backend, function(allow_jgd = TRUE) { connect(use_rstudioapi = use_rstudioapi, plot_backend = plot_backend, allow_jgd = allow_jgd) diff --git a/R/sess-package-install.R b/R/sess-package-install.R index 47b229f00..6dedb45f4 100644 --- a/R/sess-package-install.R +++ b/R/sess-package-install.R @@ -51,6 +51,9 @@ sess_install <- function(pkg_path, library, repos, interactive = FALSE) { deps <- if ("Imports" %in% colnames(description)) description[1L, "Imports"] else "" deps <- trimws(gsub("\\s*\\(.*\\)", "", unlist(strsplit(deps, ",")))) + # The private Interactive runtime loads rstudioapi itself. Ordinary terminals + # obtain it on demand from the extension's tool library instead. + if (interactive) deps <- c(deps, "rstudioapi") installed <- utils::installed.packages(lib.loc = unique(c(library, .libPaths()))) missing <- setdiff(deps[nzchar(deps)], rownames(installed)) if (length(missing)) { diff --git a/R/sess_source.R b/R/sess_source.R index 499470d16..0d3f7e50d 100644 --- a/R/sess_source.R +++ b/R/sess_source.R @@ -79,7 +79,7 @@ sess_load_namespace <- function(library, revision, normal_libraries = .libPaths( if ("processx" %in% imports) { loadNamespace("ps", lib.loc = support_paths) } - for (package in intersect(c("jsonlite", "later", "processx", "rstudioapi"), imports)) { + for (package in intersect(c("jsonlite", "later", "processx"), imports)) { loadNamespace(package, lib.loc = support_paths) } ns <- loadNamespace("sess", lib.loc = paths) diff --git a/R/tests/prepare-jgd.R b/R/tests/prepare-jgd.R index c06180a89..5381c366b 100644 --- a/R/tests/prepare-jgd.R +++ b/R/tests/prepare-jgd.R @@ -97,8 +97,8 @@ local({ }, add = TRUE) installed <- utils::installed.packages(lib.loc = .libPaths()) runtime_dependencies <- unique(c( - "processx", "later", "jsonlite", "rstudioapi", - unlist(tools::package_dependencies(c("processx", "later", "jsonlite", "rstudioapi"), + "processx", "later", "jsonlite", + unlist(tools::package_dependencies(c("processx", "later", "jsonlite"), db = installed, which = c("Depends", "Imports"), recursive = TRUE), use.names = FALSE) )) @@ -133,7 +133,7 @@ local({ "stopifnot(!ambient %in% normalizePath(.libPaths(), winslash='/'));", "source(file.path(getwd(), 'R', 'tool-library.R'));", "source(file.path(getwd(), 'R', 'tool-consent.R'));", - "source(file.path(getwd(), 'R', 'prepare-jgd.R'));", + "source(file.path(getwd(), 'R', 'prepare-tools.R'));", "source(file.path(getwd(), 'R', 'attach_sess.R'));", "options(repos=c(CRAN=repo), pkgType='source');", "consent <- file.path(root, 'consent'); dir.create(consent, showWarnings=FALSE);", @@ -170,11 +170,12 @@ local({ "}", "timeout_before <- getOption('timeout'); libraries_before <- .libPaths();", "answer <- vscode_r_attach_sess('endpoint', 'unused', file.path(root, 'tools'), consent,", - " 'unused', 'unused', 'jgd', timeout_seconds=0, tool_root=file.path(root, 'tools'),", + " 'unused', 'unused', 'jgd', timeout_seconds=0, use_rstudioapi=FALSE,", + " tool_root=file.path(root, 'tools'),", " tool_consent_directory=consent, tool_library_helper=tool_library_helper,", " tool_consent_helper=file.path(getwd(), 'R', 'tool-consent.R'),", - " jgd_helper=if (mode == 'helper-missing') file.path(root, 'missing-prepare-jgd.R') else", - " file.path(getwd(), 'R', 'prepare-jgd.R'),", + " tool_prepare_helper=if (mode == 'helper-missing') file.path(root, 'missing-prepare-tools.R') else", + " file.path(getwd(), 'R', 'prepare-tools.R'),", " jgd_can_prompt=!(mode %in% c('no-prompt', 'no-prompt-compatible', 'socket-empty')),", " jgd_minimum_version='0.1.0');", "stopifnot(isTRUE(answer), identical(Sys.getenv('JGD_SOCKET'), 'fixture-socket'));", @@ -230,7 +231,7 @@ local({ "stopifnot(!ambient %in% normalizePath(.libPaths(), winslash='/'));", "source(file.path(getwd(), 'R', 'tool-library.R'));", "source(file.path(getwd(), 'R', 'tool-consent.R'));", - "source(file.path(getwd(), 'R', 'prepare-jgd.R'));", + "source(file.path(getwd(), 'R', 'prepare-tools.R'));", "source(file.path(getwd(), 'R', 'attach_sess.R'));", "options(repos=c(CRAN=repo), pkgType='source', sess.quiet=TRUE);", "sess_ns <- loadNamespace('sess', lib.loc=sess_library);", @@ -263,7 +264,7 @@ local({ "answer <- vscode_r_with_jgd('jgd', function(allow_jgd=TRUE)", " connect_env$connect(endpoint=endpoint, use_rstudioapi=FALSE,", " plot_backend='jgd', allow_jgd=allow_jgd),", - " jgd_helper=file.path(getwd(), 'R', 'prepare-jgd.R'),", + " tool_prepare_helper=file.path(getwd(), 'R', 'prepare-tools.R'),", " tool_root=file.path(root, paste0(mode, '-tools')),", " tool_consent_directory=file.path(root, 'consent'),", " tool_library_helper=file.path(getwd(), 'R', 'tool-library.R'),", @@ -472,7 +473,7 @@ local({ " VSCODE_R_SESS_STARTUP_TOKEN='0123456789abcdef0123456789abcdef',", " VSCODE_R_SESS_STARTUP_ENDPOINT='fixture-endpoint',", paste0(" VSCODE_R_SESS_STARTUP_HELPER=", r_literal(file.path(getwd(), "R", "terminal-startup.R")), ","), - paste0(" VSCODE_R_JGD_HELPER=", r_literal(file.path(getwd(), "R", "prepare-jgd.R")), ","), + paste0(" VSCODE_R_TOOL_PREPARE_HELPER=", r_literal(file.path(getwd(), "R", "prepare-tools.R")), ","), paste0(" VSCODE_R_TOOL_ROOT=", r_literal(file.path(profile_root, "tools")), ","), paste0(" VSCODE_R_TOOL_CONSENT_DIRECTORY=", r_literal(profile_consent), ","), paste0(" VSCODE_R_TOOL_LIBRARY_HELPER=", r_literal(file.path(getwd(), "R", "tool-library.R")), ","), @@ -480,7 +481,7 @@ local({ " VSCODE_R_JGD_CAN_PROMPT='TRUE',", " VSCODE_R_JGD_MINIMUM_VERSION='0.1.0',", paste0(" R_PROFILE_USER_OLD=", r_literal(empty_user_profile), ","), - " SESS_PLOT_BACKEND='jgd', JGD_SOCKET='fixture-socket')", + " SESS_PLOT_BACKEND='jgd', SESS_RSTUDIOAPI='FALSE', JGD_SOCKET='fixture-socket')", paste0("options(repos=c(CRAN=", r_literal(repo), "), pkgType='source')"), "source(file.path(getwd(), 'R', 'profile.R'))" ), startup_profile) diff --git a/R/tests/prepare-rstudioapi.R b/R/tests/prepare-rstudioapi.R new file mode 100644 index 000000000..1f8b8fd84 --- /dev/null +++ b/R/tests/prepare-rstudioapi.R @@ -0,0 +1,295 @@ +# Base-R integration coverage for optional rstudioapi preparation during attach +# and terminal startup. All packages are local fixtures served from a file repository. +local({ + root <- tempfile("prepare-rstudioapi-") + dir.create(root) + root <- normalizePath(root, winslash = "/", mustWork = TRUE) + on.exit(unlink(root, recursive = TRUE), add = TRUE) + r_literal <- function(value) paste0("\"", gsub("\\\"", "\\\\\\\"", value), "\"") + r_binary <- file.path(R.home("bin"), "R") + minimum_version <- "0.4.0" + ambient_library <- file.path(root, "ambient-user-library") + dir.create(ambient_library) + isolated_child_env <- function(label) { + user_library <- file.path(root, "isolated-libraries", label, "user") + site_library <- file.path(root, "isolated-libraries", label, "site") + dir.create(user_library, recursive = TRUE, showWarnings = FALSE) + dir.create(site_library, recursive = TRUE, showWarnings = FALSE) + c(R_LIBS_USER = user_library, R_LIBS_SITE = site_library, R_LIBS = "") + } + system2_with_child_environment <- function(environment, command, args, ...) { + keys <- names(environment) + previous <- Sys.getenv(keys, unset = NA_character_) + on.exit({ + unset <- keys[is.na(previous)] + if (length(unset)) Sys.unsetenv(unset) + restore <- previous[!is.na(previous)] + if (length(restore)) do.call(Sys.setenv, as.list(restore)) + }, add = TRUE) + do.call(Sys.setenv, as.list(environment)) + system2(command, args, ...) + } + + # Fixtures: rstudioapi 0.4.0 is published, 0.3.1 exists only as a local source + # directory below the minimum. jgd 0.1.0 is published for the combined startup. + fixture_package <- function(package, version, publish) { + directory <- file.path(root, "fixtures", paste0(package, "-", version)) + dir.create(file.path(directory, "R"), recursive = TRUE) + writeLines(c( + paste0("Package: ", package), paste0("Version: ", version), + paste0("Title: ", package, " fixture"), + paste0("Description: Local ", package, " fixture."), "License: MIT", + "Author: Test Author", "Maintainer: Test Author ", + "Encoding: UTF-8" + ), file.path(directory, "DESCRIPTION")) + writeLines("fixture_value <- function() 'fixture'", file.path(directory, "R", "api.R")) + writeLines("export(fixture_value)", file.path(directory, "NAMESPACE")) + if (publish) { + previous <- setwd(dirname(directory)) + on.exit(setwd(previous), add = TRUE) + output <- system2_with_child_environment( + isolated_child_env(paste0("build-", package, "-", version)), r_binary, + c("CMD", "build", "--no-build-vignettes", shQuote(basename(directory))), + stdout = TRUE, stderr = TRUE + ) + status <- attr(output, "status") + if (!is.null(status) && status != 0L) stop(paste(output, collapse = "\n")) + contrib <- file.path(root, "repo", "src", "contrib") + dir.create(contrib, recursive = TRUE, showWarnings = FALSE) + archive <- file.path(dirname(directory), paste0(package, "_", version, ".tar.gz")) + stopifnot(file.copy(archive, contrib)) + } + directory + } + fixture_package("rstudioapi", minimum_version, publish = TRUE) + fixture_package("rstudioapi", "0.3.1", publish = FALSE) + fixture_package("jgd", "0.1.0", publish = TRUE) + tools::write_PACKAGES(file.path(root, "repo", "src", "contrib"), type = "source") + repo <- paste0("file:///", sub("^/", "", normalizePath(file.path(root, "repo"), winslash = "/"))) + fixtures <- file.path(root, "fixtures") + + # The child records what sess would observe when connect() runs. + runner <- file.path(root, "run-attach.R") + writeLines(c( + "args <- commandArgs(TRUE); mode <- args[[1L]]; root <- args[[2L]]; repo <- args[[3L]];", + "result <- args[[4L]]; fixtures <- args[[5L]];", + "source(file.path(getwd(), 'R', 'tool-library.R'));", + "source(file.path(getwd(), 'R', 'tool-consent.R'));", + "source(file.path(getwd(), 'R', 'attach_sess.R'));", + "options(repos=c(CRAN=repo), pkgType='source');", + "if (mode == 'offline') options(repos=c(CRAN=file.path(root, 'missing-repo')));", + "work <- file.path(root, mode);", + "consent <- file.path(work, 'consent'); dir.create(consent);", + "install_fixture <- function(name, label) {", + " library <- file.path(work, label); dir.create(library);", + " utils::install.packages(file.path(fixtures, name), repos=NULL, type='source',", + " lib=library, quiet=TRUE);", + " .libPaths(c(library, .libPaths())); library", + "};", + "if (mode %in% c('no-prompt-compatible', 'loaded-compatible'))", + " loaded_from <- install_fixture('rstudioapi-0.4.0', 'ordinary-library');", + "if (mode == 'loaded-compatible') loadNamespace('rstudioapi', lib.loc=loaded_from);", + "if (mode == 'loaded-incompatible') {", + " loaded_from <- install_fixture('rstudioapi-0.3.1', 'old-library');", + " loadNamespace('rstudioapi', lib.loc=loaded_from);", + "}", + # Record metadata resolution without changing what the helper does. + "marker <- file.path(work, 'plan-called');", + "instrumented <- file.path(work, 'tool-library.R');", + "writeLines(c(readLines(file.path(getwd(), 'R', 'tool-library.R'), warn=FALSE),", + " 'tool_library_plan_original <- tool_library_plan',", + " paste0('tool_library_plan <- function(...) { file.create(',", + " deparse(marker), '); tool_library_plan_original(...) }')), instrumented);", + "seen <- new.env();", + "vscode_r_prepare_sess <- function(...) { ns <- new.env();", + " ns$connect <- function(allow_jgd=TRUE, ...) {", + " seen$called <- TRUE; seen$allow_jgd <- allow_jgd;", + " seen$rstudioapi <- 'rstudioapi' %in% loadedNamespaces();", + " seen$jgd <- 'jgd' %in% loadedNamespaces();", + " seen$path <- if (seen$rstudioapi)", + " normalizePath(getNamespaceInfo('rstudioapi', 'path'), winslash='/') else '';", + " seen$version <- if (seen$rstudioapi)", + " as.character(getNamespaceVersion(asNamespace('rstudioapi'))) else '';", + " seen$require <- requireNamespace('rstudioapi', quietly=TRUE);", + " TRUE", + " }; ns };", + "libraries_before <- .libPaths();", + "can_prompt <- !(mode %in% c('no-prompt', 'no-prompt-compatible'));", + "plot_backend <- switch(mode, both='auto', 'both-explicit'='jgd', 'standard');", + "answer <- vscode_r_attach_sess('endpoint', 'unused', file.path(work, 'managed'), consent,", + " 'unused', 'unused', plot_backend, timeout_seconds=0,", + " use_rstudioapi=mode != 'disabled',", + " tool_root=file.path(work, 'tools'), tool_consent_directory=consent,", + " tool_library_helper=instrumented,", + " tool_consent_helper=file.path(getwd(), 'R', 'tool-consent.R'),", + " tool_prepare_helper=file.path(getwd(), 'R', 'prepare-tools.R'),", + " jgd_can_prompt=can_prompt, jgd_minimum_version='0.1.0',", + " rstudioapi_can_prompt=can_prompt, rstudioapi_minimum_version='0.4.0');", + "stopifnot(isTRUE(answer), isTRUE(seen$called), identical(.libPaths(), libraries_before));", + "tool_path <- tool_library_path(file.path(work, 'tools'));", + "writeLines(c(as.character(seen$rstudioapi), seen$path, seen$version,", + " as.character(seen$require), as.character(seen$jgd), as.character(seen$allow_jgd),", + " as.character(file.exists(marker)), tool_path), result)" + ), runner) + + # Answer consent requests by package until the child publishes its result. + serve <- function(consent, result, answers, logs) { + seen <- list() + handled <- character() + deadline <- Sys.time() + 90 + repeat { + for (path in list.files(consent, pattern = "\\.request$", full.names = TRUE)) { + id <- sub("\\.request$", "", basename(path)) + if (id %in% handled) next + handled <- c(handled, id) + lines <- readLines(path, warn = FALSE) + seen[[length(seen) + 1L]] <- lines + answer <- answers[[lines[[6L]]]] + if (is.null(answer)) answer <- "decline" + temporary <- file.path(consent, paste0(id, ".response.tmp")) + writeLines(answer, temporary, useBytes = TRUE) + stopifnot(file.rename(temporary, file.path(consent, paste0(id, ".response")))) + } + if (file.exists(result)) return(seen) + if (Sys.time() >= deadline) { + output <- unlist(lapply(logs, function(path) { + if (file.exists(path)) readLines(path, warn = FALSE) else character() + })) + stop("Timed out waiting for the attach child: ", paste(output, collapse = "\n")) + } + Sys.sleep(0.02) + } + } + + run_mode <- function(mode, answers = list()) { + result <- file.path(root, paste0(mode, "-result")) + logs <- file.path(root, paste0(mode, c("-out", "-err"))) + dir.create(file.path(root, mode), showWarnings = FALSE) + args <- c("--vanilla", "--slave", paste0("--file=", runner), "--args", + mode, root, repo, result, fixtures) + system2_with_child_environment(isolated_child_env(mode), r_binary, shQuote(args), + wait = FALSE, stdout = logs[[1L]], stderr = logs[[2L]]) + requests <- serve(file.path(root, mode, "consent"), result, answers, logs) + output <- unlist(lapply(logs, readLines, warn = FALSE)) + list(values = readLines(result, warn = FALSE), requests = requests, + messages = grep("^vscode-R|incompatible", output, value = TRUE)) + } + request_summary <- function(request) request[c(4L, 6L, 7L)] + + approved <- run_mode("approved", list(rstudioapi = "approve")) + tool_package <- file.path(approved$values[[8L]], "rstudioapi") + stopifnot(length(approved$requests) == 1L, + identical(request_summary(approved$requests[[1L]]), + c("once", "rstudioapi", minimum_version)), + identical(approved$values[1:7], + c("TRUE", normalizePath(tool_package, winslash = "/"), minimum_version, + "TRUE", "FALSE", "TRUE", "TRUE")), + length(approved$messages) == 0L) + + declined <- run_mode("declined") + stopifnot(length(declined$requests) == 1L, + identical(declined$values[c(1L, 4L, 7L)], c("FALSE", "FALSE", "TRUE")), + length(declined$messages) == 1L, + grepl("rstudioapi installation was not approved", declined$messages)) + stopifnot(!dir.exists(file.path(declined$values[[8L]], "rstudioapi"))) + + offline <- run_mode("offline") + stopifnot(length(offline$requests) == 0L, + identical(offline$values[c(1L, 4L)], c("FALSE", "FALSE")), + length(offline$messages) == 1L, + grepl("could not prepare optional rstudioapi support", offline$messages)) + + # Emulation disabled: no request, no repository query, no message. + disabled <- run_mode("disabled", list(rstudioapi = "approve")) + stopifnot(length(disabled$requests) == 0L, + identical(disabled$values[c(1L, 4L, 7L)], c("FALSE", "FALSE", "FALSE")), + length(disabled$messages) == 0L) + + # Without a prompt only an existing compatible copy is used, silently and + # without contacting repositories. + no_prompt <- run_mode("no-prompt") + stopifnot(length(no_prompt$requests) == 0L, + identical(no_prompt$values[c(1L, 4L, 7L)], c("FALSE", "FALSE", "FALSE")), + length(no_prompt$messages) == 0L) + compatible <- run_mode("no-prompt-compatible") + stopifnot(length(compatible$requests) == 0L, + identical(compatible$values[c(1L, 3L, 4L, 7L)], c("TRUE", minimum_version, "TRUE", "FALSE")), + grepl("/ordinary-library/rstudioapi$", compatible$values[[2L]])) + + # A loaded namespace is never replaced, whatever its version. + loaded <- run_mode("loaded-compatible") + stopifnot(length(loaded$requests) == 0L, + identical(loaded$values[c(1L, 3L, 7L)], c("TRUE", minimum_version, "FALSE")), + grepl("/ordinary-library/rstudioapi$", loaded$values[[2L]])) + old <- run_mode("loaded-incompatible", list(rstudioapi = "approve")) + stopifnot(length(old$requests) == 0L, + identical(old$values[c(1L, 3L, 7L)], c("TRUE", "0.3.1", "FALSE")), + grepl("/old-library/rstudioapi$", old$values[[2L]]), + length(old$messages) == 1L) + + # jgd and rstudioapi in the same startup: two separate requests, each + # with its own policy, and the decision for one does not affect the other. + both <- run_mode("both", list(rstudioapi = "approve", jgd = "decline")) + summaries <- vapply(both$requests, function(request) paste(request_summary(request), collapse = " "), "") + stopifnot(identical(sort(summaries), sort(c("once rstudioapi 0.4.0", "once jgd 0.1.0"))), + identical(both$values[c(1L, 4L, 5L, 6L)], c("TRUE", "TRUE", "FALSE", "FALSE"))) + explicit <- run_mode("both-explicit", list(rstudioapi = "approve", jgd = "approve")) + summaries <- vapply(explicit$requests, function(request) paste(request_summary(request), collapse = " "), "") + stopifnot(identical(sort(summaries), sort(c("once rstudioapi 0.4.0", "always jgd 0.1.0"))), + identical(explicit$values[c(1L, 4L, 5L, 6L)], c("TRUE", "TRUE", "TRUE", "TRUE"))) + + # Terminal startup profile: emulation setting drives preparation. + profile_run <- function(label, emulation, answer) { + profile_root <- file.path(root, label) + dir.create(profile_root) + consent <- file.path(profile_root, "consent") + dir.create(consent) + connect_log <- file.path(profile_root, "connect-log") + stub_attach <- file.path(profile_root, "attach-helper.R") + placeholder <- file.path(profile_root, "placeholder.R") + file.create(placeholder) + writeLines(c( + "source(file.path(getwd(), 'R', 'attach_sess.R'))", + "vscode_r_prepare_sess <- function(...) {", + " ns <- new.env(); ns$connect <- function(use_rstudioapi=TRUE, ...) {", + paste0(" writeLines(c(as.character(use_rstudioapi), as.character('rstudioapi' %in% loadedNamespaces())), ", + r_literal(connect_log), ")"), + " TRUE", + "}; ns }" + ), stub_attach) + startup <- file.path(profile_root, "run-profile.R") + writeLines(c( + paste0("Sys.setenv(VSCODE_R_SESS_PKG_PATH=", r_literal(profile_root), ","), + paste0(" VSCODE_R_SESS_SOURCE_HELPER=", r_literal(placeholder), ","), + paste0(" VSCODE_R_SESS_ATTACH_HELPER=", r_literal(stub_attach), ","), + paste0(" VSCODE_R_SESS_INSTALLER_HELPER=", r_literal(placeholder), ","), + paste0(" VSCODE_R_SESS_ROOT=", r_literal(profile_root), ","), + paste0(" VSCODE_R_SESS_CONSENT_DIRECTORY=", r_literal(consent), ","), + paste0(" VSCODE_R_TOOL_PREPARE_HELPER=", r_literal(file.path(getwd(), "R", "prepare-tools.R")), ","), + paste0(" VSCODE_R_TOOL_ROOT=", r_literal(file.path(profile_root, "tools")), ","), + paste0(" VSCODE_R_TOOL_CONSENT_DIRECTORY=", r_literal(consent), ","), + paste0(" VSCODE_R_TOOL_LIBRARY_HELPER=", r_literal(file.path(getwd(), "R", "tool-library.R")), ","), + paste0(" VSCODE_R_TOOL_CONSENT_HELPER=", r_literal(file.path(getwd(), "R", "tool-consent.R")), ","), + paste0(" VSCODE_R_RSTUDIOAPI_MINIMUM_VERSION=", r_literal(minimum_version), ","), + " VSCODE_R_RSTUDIOAPI_CAN_PROMPT='TRUE',", + paste0(" R_PROFILE_USER_OLD=", r_literal(placeholder), ","), + paste0(" SESS_PLOT_BACKEND='standard', SESS_RSTUDIOAPI='", emulation, "')"), + paste0("options(repos=c(CRAN=", r_literal(repo), "), pkgType='source')"), + "source(file.path(getwd(), 'R', 'profile.R'))" + ), startup) + logs <- file.path(profile_root, c("stdout", "stderr")) + system2_with_child_environment(isolated_child_env(label), r_binary, + shQuote(c("--vanilla", "--slave", paste0("--file=", startup))), + wait = FALSE, stdout = logs[[1L]], stderr = logs[[2L]]) + requests <- serve(consent, connect_log, list(rstudioapi = answer), logs) + list(values = readLines(connect_log, warn = FALSE), requests = requests) + } + enabled <- profile_run("profile-enabled", "TRUE", "approve") + stopifnot(length(enabled$requests) == 1L, + identical(request_summary(enabled$requests[[1L]]), c("once", "rstudioapi", minimum_version)), + identical(enabled$values, c("TRUE", "TRUE"))) + suppressed <- profile_run("profile-disabled", "FALSE", "approve") + stopifnot(length(suppressed$requests) == 0L, identical(suppressed$values, c("FALSE", "FALSE"))) + + cat("rstudioapi preparation integration tests passed\n") +}) diff --git a/R/tests/sess_source.R b/R/tests/sess_source.R index 944bf42ad..54be57c24 100644 --- a/R/tests/sess_source.R +++ b/R/tests/sess_source.R @@ -199,6 +199,50 @@ stopifnot(identical(sess_loaded_source_revision(), stable)) stopifnot(identical(getExportedValue("sess", "connect")(), "normal-library-dependency")) stopifnot(identical(normalized_paths(.libPaths()), normalized_paths(ordinary_before))) +# The private Interactive runtime installs rstudioapi beside sess, which only +# suggests it. Ordinary installations must not request it. +local({ + package <- file.path(root, "installer-dependencies", "sess") + dir.create(package, recursive = TRUE) + writeLines(c("Package: sess", "Version: 1.0.0", "Imports: sessfixturedep", + "Config/vscode-R/source-revision: fixture"), + file.path(package, "DESCRIPTION")) + target <- file.path(root, "installer-dependencies", "library") + isolated <- file.path(root, "installer-dependencies", "empty") + for (directory in c(target, isolated)) dir.create(directory) + child <- file.path(root, "installer-dependencies.R") + writeLines(c( + "args <- commandArgs(TRUE)", + "installer <- new.env(parent = baseenv())", + "sys.source(args[[1L]], installer)", + "installer$sess_install_dependencies <- function(packages, ...) requested <<- packages", + "installer$sess_install_command <- function(...) TRUE", + "installer$sess_verify_package <- function(...) invisible(TRUE)", + "for (interactive in c(TRUE, FALSE)) {", + " requested <- character()", + " installer$sess_install(args[[2L]], args[[3L]], 'https://example.com',", + " interactive = interactive)", + " cat('requested', interactive, paste(requested, collapse = ','), '\\n', sep = ';')", + "}" + ), child) + keys <- c("R_LIBS", "R_LIBS_USER", "R_LIBS_SITE") + previous <- Sys.getenv(keys, unset = NA_character_) + on.exit({ + Sys.unsetenv(keys[is.na(previous)]) + if (any(!is.na(previous))) do.call(Sys.setenv, as.list(previous[!is.na(previous)])) + }, add = TRUE) + Sys.setenv(R_LIBS = "", R_LIBS_USER = isolated, R_LIBS_SITE = isolated) + output <- system2(r_binary, shQuote(c( + "--vanilla", "--silent", "--no-echo", "--no-save", "--no-restore", + paste0("--file=", child), "--args", + file.path(getwd(), "R", "sess-package-install.R"), package, target + )), stdout = TRUE, stderr = TRUE) + stopifnot(is.null(attr(output, "status"))) + stopifnot(identical(grep("^requested;", output, value = TRUE), + c("requested;TRUE;sessfixturedep,rstudioapi;", + "requested;FALSE;sessfixturedep;"))) +}) + Sys.unsetenv(c("VSCODE_R_SESS_PKG_PATH", "VSCODE_R_SESS_REPO", "VSCODE_R_SESS_LIBRARY", "VSCODE_R_SESS_INTERACTIVE")) if (is.na(previous_r_libs)) Sys.unsetenv("R_LIBS") else Sys.setenv(R_LIBS = previous_r_libs) diff --git a/R/tests/tool-consent.R b/R/tests/tool-consent.R index d2472fe53..364ff439b 100644 --- a/R/tests/tool-consent.R +++ b/R/tests/tool-consent.R @@ -30,30 +30,30 @@ local({ class = "vscode_r_tool_install_plan" ) - lines <- .tool_consent_lines(plan, strrep("a", 32L), "jgd") + lines <- .tool_consent_lines(plan, strrep("a", 32L), "always") stopifnot(length(lines) == 11L) stopifnot(identical(lines[c(1L, 2L, 4L, 5L)], - c("vscode-r-package-consent-v1", strrep("a", 32L), "jgd", "1"))) + c("vscode-r-package-consent-v1", strrep("a", 32L), "always", "1"))) stopifnot(identical(lines[6:11], c("jgd", "0.1.0", "0.1.0", repository, "source", "yes"))) invalid <- plan invalid$repository <- paste0(repository, "\nother.example") - stopifnot(inherits(tryCatch(.tool_consent_lines(invalid, strrep("b", 32L), "auto"), + stopifnot(inherits(tryCatch(.tool_consent_lines(invalid, strrep("b", 32L), "once"), error = identity), "error")) directory <- file.path(root, "consent") dir.create(directory) - approval <- .tool_publish_consent_request(plan, strrep("c", 32L), "auto", directory) + approval <- .tool_publish_consent_request(plan, strrep("c", 32L), "once", directory) stopifnot(identical(readLines(approval$request_path), - .tool_consent_lines(plan, strrep("c", 32L), "auto"))) + .tool_consent_lines(plan, strrep("c", 32L), "once"))) stopifnot(inherits(tryCatch(.tool_publish_consent_request(plan, strrep("c", 32L), - "auto", directory), + "once", directory), error = identity), "error")) writeLines("approve", approval$response_path) stopifnot(isTRUE(.tool_wait_for_consent(approval, timeout_seconds = 0))) stopifnot(!file.exists(approval$request_path), !file.exists(approval$response_path)) - changed <- .tool_publish_consent_request(plan, strrep("d", 32L), "auto", directory) + changed <- .tool_publish_consent_request(plan, strrep("d", 32L), "once", directory) changed_lines <- changed$lines changed_lines[8L] <- "0.2.0" writeLines(changed_lines, changed$request_path) @@ -61,18 +61,18 @@ local({ stopifnot(!.tool_wait_for_consent(changed, timeout_seconds = 0)) stopifnot(!file.exists(changed$request_path), !file.exists(changed$response_path)) - declined <- .tool_publish_consent_request(plan, strrep("e", 32L), "jgd", directory) + declined <- .tool_publish_consent_request(plan, strrep("e", 32L), "always", directory) writeLines("decline", declined$response_path) stopifnot(!.tool_wait_for_consent(declined, timeout_seconds = 0)) stopifnot(!file.exists(declined$request_path), !file.exists(declined$response_path)) - stopifnot(!.tool_request_consent(plan, "auto", directory, timeout_seconds = 0)) + stopifnot(!.tool_request_consent(plan, "once", directory, timeout_seconds = 0)) stopifnot(length(list.files(directory)) == 0L) - unavailable <- .tool_publish_consent_request(plan, strrep("f", 32L), "auto", directory) + unavailable <- .tool_publish_consent_request(plan, strrep("f", 32L), "once", directory) stopifnot(!.tool_wait_for_consent(unavailable, timeout_seconds = 0)) stopifnot(!file.exists(unavailable$request_path), !file.exists(unavailable$response_path)) - invalid_timeout <- .tool_publish_consent_request(plan, strrep("0", 32L), "auto", directory) + invalid_timeout <- .tool_publish_consent_request(plan, strrep("0", 32L), "once", directory) stopifnot(inherits(tryCatch(.tool_wait_for_consent(invalid_timeout, timeout_seconds = -1), error = identity), "error")) stopifnot(!file.exists(invalid_timeout$request_path), !file.exists(invalid_timeout$response_path)) @@ -88,10 +88,10 @@ local({ install_required = !ready, library = library) } prepare_environment$tool_library_plan <- function(...) if (ready) NULL else plan - prepare_environment$.tool_request_consent <- function(received, backend, directory, + prepare_environment$.tool_request_consent <- function(received, policy, directory, timeout_seconds) { prompt_calls <<- prompt_calls + 1L - stopifnot(identical(received, plan), backend %in% c("jgd", "auto"), + stopifnot(identical(received, plan), policy %in% c("always", "once"), identical(directory, "consent")) permission } @@ -105,16 +105,16 @@ local({ received_plan <- plan prepare <- tool_library_prepare environment(prepare) <- prepare_environment - stopifnot(is.null(prepare("jgd", "0.1.0", root, "consent", "auto", repos = character()))) + stopifnot(is.null(prepare("jgd", "0.1.0", root, "consent", "once", repos = character()))) stopifnot(prompt_calls == 1L, install_calls == 0L) permission <- FALSE - stopifnot(is.null(prepare("jgd", "0.1.0", root, "consent", "jgd", repos = character()))) + stopifnot(is.null(prepare("jgd", "0.1.0", root, "consent", "always", repos = character()))) stopifnot(prompt_calls == 2L, install_calls == 0L) permission <- TRUE - prepare("jgd", "0.1.0", root, "consent", "auto", repos = character()) + prepare("jgd", "0.1.0", root, "consent", "once", repos = character()) stopifnot(prompt_calls == 3L, install_calls == 1L) ready <- TRUE - prepare("jgd", "0.1.0", root, "consent", "auto", repos = character()) + prepare("jgd", "0.1.0", root, "consent", "once", repos = character()) stopifnot(prompt_calls == 3L, install_calls == 1L) # Exercise prepare through its real file request and response path. @@ -122,17 +122,17 @@ local({ consent_directory <- file.path(root, "prepare consent") dir.create(consent_directory) original_wait <- .tool_wait_for_consent - prepare_environment$.tool_request_consent <- function(received, backend, + prepare_environment$.tool_request_consent <- function(received, policy, directory, timeout_seconds) { request <- .tool_publish_consent_request(received, strrep("1", 32L), - backend, directory) + policy, directory) prepare_environment$.tool_wait_for_consent(request, timeout_seconds) } prepare_environment$.tool_wait_for_consent <- function(request, timeout_seconds) { writeLines("approve", request$response_path) original_wait(request, timeout_seconds = 0) } - prepared <- prepare("jgd", "0.1.0", root, consent_directory, "jgd", + prepared <- prepare("jgd", "0.1.0", root, consent_directory, "always", repos = character()) stopifnot(identical(prepared$version, plan$version), prompt_calls == 3L, install_calls == 2L, diff --git a/R/tests/tool-library.R b/R/tests/tool-library.R index c720e4816..3b9345c17 100644 --- a/R/tests/tool-library.R +++ b/R/tests/tool-library.R @@ -93,6 +93,8 @@ local({ stopifnot(tool_library_check("jgd", "1.0.0", root_path)$install_required) stopifnot(inherits(tryCatch(tool_library_check("arbitrary", "1.0.0", root_path), error = identity), "error")) + stopifnot(identical(unname(.vscode_r_tool_packages), c("jgd", "rstudioapi"))) + stopifnot(isTRUE(.tool_validate_request("rstudioapi", "0.4.0"))) for (invalid in c("", "1.foo", "https://example.com/pkg")) { stopifnot(inherits(tryCatch(tool_library_check("jgd", invalid, root_path), error = identity), "error")) @@ -135,14 +137,14 @@ local({ stopifnot(inherits(too_old, "error"), !dir.exists(file.path(managed, "jgd"))) frozen_plan <- tool_library_plan("jgd", "1.0.0", root_path, repos = c(CRAN = repo)) stopifnot(identical(frozen_plan$version, "1.2.0")) - consent_lines <- .tool_consent_lines(frozen_plan, strrep("a", 32L), "auto") + consent_lines <- .tool_consent_lines(frozen_plan, strrep("a", 32L), "once") stopifnot(identical(consent_lines[1:5], c("vscode-r-package-consent-v1", strrep("a", 32L), paste(R.version$platform, paste(R.version$major, strsplit(R.version$minor, ".", fixed = TRUE)[[1L]][1L], sep = "."), sep = "|"), - "auto", "1"))) + "once", "1"))) stopifnot(identical(consent_lines[6:11], c("jgd", "1.0.0", "1.2.0", frozen_plan$repository, frozen_plan$package_type, if (frozen_plan$source_build) "yes" else "no"))) diff --git a/R/tool-consent.R b/R/tool-consent.R index 6c2e33a4d..48ffe32d3 100644 --- a/R/tool-consent.R +++ b/R/tool-consent.R @@ -2,11 +2,11 @@ .tool_consent_max_bytes <- 16384L .tool_consent_request_version <- "vscode-r-package-consent-v1" -.tool_consent_lines <- function(plan, id, backend) { +.tool_consent_lines <- function(plan, id, policy) { if (!inherits(plan, "vscode_r_tool_install_plan") || !(is.character(id) && length(id) == 1L && grepl("^[A-Za-z0-9_-]{1,64}$", id)) || - !(is.character(backend) && length(backend) == 1L && backend %in% c("jgd", "auto"))) { + !(is.character(policy) && length(policy) == 1L && policy %in% c("always", "once"))) { stop("Invalid managed tool consent request.", call. = FALSE) } runtime_minor <- strsplit(R.version$minor, ".", fixed = TRUE)[[1L]][1L] @@ -20,7 +20,7 @@ stop("The managed tool installation plan cannot be represented in a consent request.", call. = FALSE) } - lines <- c(.tool_consent_request_version, id, runtime, backend, "1", package_fields) + lines <- c(.tool_consent_request_version, id, runtime, policy, "1", package_fields) request <- paste0(paste(lines, collapse = "\n"), "\n") if (nchar(request, type = "bytes") > .tool_consent_max_bytes) { stop("The managed tool consent request is too large.", call. = FALSE) @@ -28,7 +28,7 @@ lines } -.tool_publish_consent_request <- function(plan, id, backend, directory) { +.tool_publish_consent_request <- function(plan, id, policy, directory) { if (!(is.character(directory) && length(directory) == 1L && !is.na(directory) && nzchar(directory))) { stop("The managed tool consent directory is unavailable.", call. = FALSE) @@ -38,7 +38,7 @@ stop("The managed tool consent directory is unavailable.", call. = FALSE) } if (.Platform$OS.type == "unix") Sys.chmod(directory, "0700") - lines <- .tool_consent_lines(plan, id, backend) + lines <- .tool_consent_lines(plan, id, policy) request_path <- file.path(directory, paste0(id, ".request")) response_path <- file.path(directory, paste0(id, ".response")) if (file.exists(request_path) || file.exists(response_path)) { @@ -88,7 +88,7 @@ } } -.tool_request_consent <- function(plan, backend, directory, timeout_seconds = 180) { +.tool_request_consent <- function(plan, policy, directory, timeout_seconds = 180) { if (!(is.character(directory) && length(directory) == 1L && !is.na(directory) && nzchar(directory) && dir.exists(directory))) { stop("The extension's tool consent service is unavailable. Restart VS Code and try again.", @@ -106,14 +106,15 @@ response_path <- file.path(directory, paste0(id, ".response")) if (!file.exists(request_path) && !file.exists(response_path)) break } - request <- .tool_publish_consent_request(plan, id, backend, directory) + request <- .tool_publish_consent_request(plan, id, policy, directory) .tool_wait_for_consent(request, timeout_seconds) } # Request consent only when no compatible namespace is already available. # On approval the frozen repository result is passed directly to installation. +# `policy` is "always" or "once"; the extension applies it to its prompt. tool_library_prepare <- function(package, minimum_version, root, consent_directory, - backend, repos = getOption("repos"), + policy, repos = getOption("repos"), consent_timeout_seconds = 180, install_timeout_seconds = 300, resolution_timeout_seconds = 30) { @@ -131,7 +132,7 @@ tool_library_prepare <- function(package, minimum_version, root, consent_directo tool_library_plan(package, minimum_version, root, repos) }) if (is.null(plan)) return(invisible(tool_library_check(package, minimum_version, root))) - approved <- .tool_request_consent(plan, backend, consent_directory, + approved <- .tool_request_consent(plan, policy, consent_directory, timeout_seconds = consent_timeout_seconds) if (!isTRUE(approved)) return(invisible(NULL)) tool_library_install(package, minimum_version, root, repos = plan$repositories, diff --git a/R/tool-library.R b/R/tool-library.R index 4936aff3d..e4b043827 100644 --- a/R/tool-library.R +++ b/R/tool-library.R @@ -1,7 +1,7 @@ # R-side support for packages managed by vscode-R as optional tools. # The allowlist is intentionally fixed here; callers may select a minimum version # but cannot use this installer to fetch arbitrary package names or URLs. -.vscode_r_tool_packages <- c(jgd = "jgd") +.vscode_r_tool_packages <- c(jgd = "jgd", rstudioapi = "rstudioapi") tool_library_path <- function(root) { .tool_validate_root(root) diff --git a/package.json b/package.json index 2524ea83b..e89747d0b 100644 --- a/package.json +++ b/package.json @@ -2101,7 +2101,7 @@ "r.session.emulateRStudioAPI": { "type": "boolean", "default": true, - "markdownDescription": "Emulate the RStudio API for addin support and other {rstudioapi} calls. Requires `#r.sessionWatcher#` to be set to `true`." + "markdownDescription": "Emulate the RStudio API for addin support and other {rstudioapi} calls. Requires `#r.sessionWatcher#` to be set to `true`. If {rstudioapi} is not installed, vscode-R can offer to install it into a vscode-R-managed library." }, "r.session.data.pageSize": { "type": "integer", @@ -2613,6 +2613,7 @@ "test:tool-library": "Rscript --vanilla R/tests/tool-library.R", "test:tool-consent": "Rscript --vanilla R/tests/tool-consent.R", "test:prepare-jgd": "Rscript --vanilla R/tests/prepare-jgd.R", + "test:prepare-rstudioapi": "Rscript --vanilla R/tests/prepare-rstudioapi.R", "test:interactive": "pnpm run pretest && pnpm run test:node", "test:interactive:examples": "pnpm run compile && tsc && node src/test/examples/run.cjs" }, diff --git a/sess/DESCRIPTION b/sess/DESCRIPTION index 44b47bb49..135f09106 100644 --- a/sess/DESCRIPTION +++ b/sess/DESCRIPTION @@ -28,8 +28,7 @@ Imports: later, jsonlite, utils, - methods, - rstudioapi + methods Suggests: bit64, data.table, @@ -38,6 +37,7 @@ Suggests: systemfonts, httpgd, jgd, + rstudioapi, svglite, tinytest NeedsCompilation: no diff --git a/sess/inst/tinytest/test-rstudioapi-optional.R b/sess/inst/tinytest/test-rstudioapi-optional.R new file mode 100644 index 000000000..058f5610b --- /dev/null +++ b/sess/inst/tinytest/test-rstudioapi-optional.R @@ -0,0 +1,81 @@ +# sess only suggests rstudioapi: it must load and start its emulation without the +# package, and patch it whenever it is loaded later. +local({ + description <- utils::packageDescription("sess", fields = c("Imports", "Suggests")) + expect_false(grepl("rstudioapi", description$Imports, fixed = TRUE)) + expect_true(grepl("rstudioapi", description$Suggests, fixed = TRUE)) + + root <- tempfile("sess-rstudioapi-") + dir.create(root) + on.exit(unlink(root, recursive = TRUE), add = TRUE) + r_command <- function(program) file.path(R.home("bin"), program) + + # A directory without a valid installation shadows any real rstudioapi. + absent_library <- file.path(root, "absent-library") + dir.create(file.path(absent_library, "rstudioapi"), recursive = TRUE) + writeLines(c("Package: rstudioapi", "Version: 0.0.0"), + file.path(absent_library, "rstudioapi", "DESCRIPTION")) + + # A minimal installed package named rstudioapi, loaded only on demand. + stub_source <- file.path(root, "rstudioapi") + dir.create(file.path(stub_source, "R"), recursive = TRUE) + writeLines(c("Package: rstudioapi", "Version: 0.4.0", "Title: Fixture", + "Description: Local rstudioapi fixture.", "License: MIT", + "Author: Test Author", "Maintainer: Test Author "), + file.path(stub_source, "DESCRIPTION")) + writeLines("isAvailable <- function(version_needed = NULL, child_ok = FALSE) 'original'", + file.path(stub_source, "R", "api.R")) + writeLines("export(isAvailable)", file.path(stub_source, "NAMESPACE")) + stub_library <- file.path(root, "stub-library") + dir.create(stub_library) + installed <- suppressWarnings(system2( + r_command("R"), + c("CMD", "INSTALL", paste0("--library=", shQuote(stub_library)), shQuote(stub_source)), + stdout = TRUE, stderr = TRUE + )) + expect_null(attr(installed, "status")) + + script <- file.path(root, "child.R") + writeLines(c( + "args <- commandArgs(TRUE); scenario <- args[[1L]]; result <- args[[2L]]", + "hook <- packageEvent('rstudioapi', 'onLoad')", + "loadNamespace('sess')", + "lines <- c(available=as.character(requireNamespace('rstudioapi', quietly=TRUE)))", + "if (scenario == 'stub') unloadNamespace('rstudioapi')", + "lines[['loaded_with_sess']] <- as.character('rstudioapi' %in% loadedNamespaces())", + "sess_env <- sess:::.sess_env; sess_env$latest_plot_path <- tempfile(fileext='.png')", + "sess:::runtime_start(use_rstudioapi=TRUE, plot_backend='standard')", + "lines[['loaded_after_start']] <- as.character('rstudioapi' %in% loadedNamespaces())", + "lines[['hook']] <- as.character(length(getHook(hook)))", + "if (scenario == 'stub') {", + " loadNamespace('rstudioapi')", + " lines[['patched']] <- as.character(isTRUE(rstudioapi::isAvailable()))", + "}", + "sess:::runtime_stop()", + "lines[['hook_after_stop']] <- as.character(length(getHook(hook)))", + "writeLines(paste(names(lines), lines, sep='='), result)" + ), script) + + run_child <- function(scenario, library) { + result <- file.path(root, paste0(scenario, "-result")) + previous <- Sys.getenv("R_LIBS", unset = NA_character_) + on.exit(if (is.na(previous)) Sys.unsetenv("R_LIBS") else Sys.setenv(R_LIBS = previous)) + sess_library <- dirname(system.file(package = "sess")) + Sys.setenv(R_LIBS = paste(unique(c(library, sess_library, .libPaths())), + collapse = .Platform$path.sep)) + output <- system2(r_command("Rscript"), + c("--vanilla", shQuote(script), scenario, shQuote(result)), + stdout = TRUE, stderr = TRUE) + expect_null(attr(output, "status"), info = paste(output, collapse = "\n")) + if (file.exists(result)) readLines(result) else character() + } + + absent <- run_child("absent", absent_library) + expect_equal(absent, c("available=FALSE", "loaded_with_sess=FALSE", + "loaded_after_start=FALSE", "hook=1", "hook_after_stop=0")) + + present <- run_child("stub", stub_library) + expect_equal(present, c("available=TRUE", "loaded_with_sess=FALSE", + "loaded_after_start=FALSE", "hook=1", "patched=TRUE", + "hook_after_stop=0")) +}) diff --git a/src/jgdVersion.ts b/src/jgdVersion.ts deleted file mode 100644 index 300f684cc..000000000 --- a/src/jgdVersion.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Shared by consent eligibility and R package preparation. -export const JGD_MINIMUM_VERSION = '0.1.0'; diff --git a/src/rTerminal.ts b/src/rTerminal.ts index c0438cdae..3208ea2e7 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import * as crypto from 'node:crypto'; import { getMigratedSetting } from './configuration'; -import { JGD_MINIMUM_VERSION } from './jgdVersion'; +import { TOOL_MINIMUM_VERSIONS } from './toolVersions'; import { isDeepStrictEqual } from 'util'; import * as vscode from 'vscode'; @@ -19,7 +19,8 @@ import { getGlobalPipePath, getSessConsentDirectory, getToolConsentDirectory, - canPromptForToolConsent, + canPromptForJgdInstall, + canPromptForRStudioApiInstall, isTerminalClosed, removeTerminalDiscoveryFile, updateTerminalSessionDiscoveryFile, @@ -316,7 +317,8 @@ export async function makeTerminalOptions(resource?: vscode.Uri): Promise 0 && attachToolConsentService?.canPrompt(backend) === true; + const jgdCanPrompt = toolConsentDirectory.length > 0 && canPromptForJgdInstall(backend); + const rstudioapiCanPrompt = toolConsentDirectory.length > 0 && canPromptForRStudioApiInstall(); return [ 'local({', ' tryCatch({', @@ -1577,8 +1577,9 @@ function buildAttachSessionScript( ` tool_consent_dir <- ${asRStringLiteral(toolConsentDirectory.replace(/\\/g, '/'))}`, ` tool_library_helper <- ${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'tool-library.R')).replace(/\\/g, '/'))}`, ` tool_consent_helper <- ${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'tool-consent.R')).replace(/\\/g, '/'))}`, - ` jgd_helper <- ${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'prepare-jgd.R')).replace(/\\/g, '/'))}`, + ` tool_prepare_helper <- ${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'prepare-tools.R')).replace(/\\/g, '/'))}`, ` jgd_can_prompt <- ${jgdCanPrompt ? 'TRUE' : 'FALSE'}`, + ` rstudioapi_can_prompt <- ${rstudioapiCanPrompt ? 'TRUE' : 'FALSE'}`, ` startup_helper <- ${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'terminal-startup.R')).replace(/\\/g, '/'))}`, ` attach_helper <- ${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'attach_sess.R')).replace(/\\/g, '/'))}`, ...(backend === 'native' @@ -1590,7 +1591,7 @@ function buildAttachSessionScript( ]), ' source(startup_helper, local = TRUE)', ' source(attach_helper, local = TRUE)', - ` vscode_r_attach_sess(endpoint, sess_src, managed_root, consent_dir, source_helper, installer_helper, ${asRStringLiteral(backend)}, tool_root = tool_root, tool_consent_directory = tool_consent_dir, tool_library_helper = tool_library_helper, tool_consent_helper = tool_consent_helper, jgd_helper = jgd_helper, jgd_can_prompt = jgd_can_prompt, jgd_minimum_version = ${asRStringLiteral(JGD_MINIMUM_VERSION)}, use_rstudioapi = ${config().get('session.emulateRStudioAPI') ? 'TRUE' : 'FALSE'}${attachment ? `, attachment_id = ${asRStringLiteral(attachment.attachmentId)}, auto_activate = ${attachment.autoActivate ? 'TRUE' : 'FALSE'}` : ''})`, + ` vscode_r_attach_sess(endpoint, sess_src, managed_root, consent_dir, source_helper, installer_helper, ${asRStringLiteral(backend)}, tool_root = tool_root, tool_consent_directory = tool_consent_dir, tool_library_helper = tool_library_helper, tool_consent_helper = tool_consent_helper, tool_prepare_helper = tool_prepare_helper, jgd_can_prompt = jgd_can_prompt, jgd_minimum_version = ${asRStringLiteral(TOOL_MINIMUM_VERSIONS.jgd)}, rstudioapi_can_prompt = rstudioapi_can_prompt, rstudioapi_minimum_version = ${asRStringLiteral(TOOL_MINIMUM_VERSIONS.rstudioapi)}, use_rstudioapi = ${config().get('session.emulateRStudioAPI') ? 'TRUE' : 'FALSE'}${attachment ? `, attachment_id = ${asRStringLiteral(attachment.attachmentId)}, auto_activate = ${attachment.autoActivate ? 'TRUE' : 'FALSE'}` : ''})`, ' })', ' invisible(isTRUE(attached))', ' }, error = function(error) {', @@ -1766,9 +1767,15 @@ export async function getToolConsentDirectory(): Promise { return ensureAttachToolConsentService(); } -/** Whether the running consent service may display a prompt for this backend. */ -export function canPromptForToolConsent(backend: string): boolean { - return attachToolConsentService?.canPrompt(backend) === true; +/** Whether the running consent service may offer to install jgd for this plot backend. */ +export function canPromptForJgdInstall(backend: string): boolean { + const policy = backend === 'jgd' ? 'always' : backend === 'auto' ? 'once' : undefined; + return policy !== undefined && attachToolConsentService?.canPrompt(['jgd'], policy) === true; +} + +/** Whether the running consent service may offer to install rstudioapi. */ +export function canPromptForRStudioApiInstall(): boolean { + return attachToolConsentService?.canPrompt(['rstudioapi'], 'once') === true; } function validateAttachmentEndpoint(endpoint: unknown): asserts endpoint is string { diff --git a/src/test/examples/sess-installer.R b/src/test/examples/sess-installer.R index d696ce649..ebd13f49b 100644 --- a/src/test/examples/sess-installer.R +++ b/src/test/examples/sess-installer.R @@ -8,6 +8,8 @@ local({ description <- read.dcf(file.path(package, "DESCRIPTION")) deps <- if ("Imports" %in% colnames(description)) description[1L, "Imports"] else "" deps <- trimws(gsub("\\s*\\(.*\\)", "", unlist(strsplit(deps, ",")))) + # The Interactive installation also requests rstudioapi, which sess only suggests. + deps <- c(deps, "rstudioapi") installed <- utils::installed.packages(lib.loc = .libPaths()) missing <- setdiff(deps[nzchar(deps)], rownames(installed)) stopifnot(!length(missing)) diff --git a/src/test/node/interactiveLibraries.test.ts b/src/test/node/interactiveLibraries.test.ts index 7024a2d25..6a46dc105 100644 --- a/src/test/node/interactiveLibraries.test.ts +++ b/src/test/node/interactiveLibraries.test.ts @@ -79,7 +79,8 @@ const run = promisify(execFile); private <- ${rString(runtime.library)} user <- ${rString(userLibrary)} database <- installed.packages(lib.loc=unique(c(private,.libPaths()))) -dependencies <- unique(c("ps", unlist(tools::package_dependencies( +# The Interactive setup installs rstudioapi beside sess although sess only suggests it. +dependencies <- unique(c("ps", "rstudioapi", unlist(tools::package_dependencies( c("sess","ps"), database, which=c("Depends","Imports","LinkingTo"), recursive=TRUE)))) for (package in dependencies) { if (!is.na(database[package,"Priority"]) || dir.exists(file.path(private,package))) next diff --git a/src/test/node/toolConsent.test.ts b/src/test/node/toolConsent.test.ts index a8554ad9e..22522b278 100644 --- a/src/test/node/toolConsent.test.ts +++ b/src/test/node/toolConsent.test.ts @@ -7,17 +7,26 @@ import { ToolConsentChoice, ToolConsentRequest, ToolConsentService, + ToolConsentSessionState, } from '../../toolConsent'; -function requestContent(id: string, backend: 'jgd' | 'auto', version = '1.2.0'): string { +type Policy = 'always' | 'once'; + +function requestContent( + id: string, + policy: Policy, + version = '1.2.0', + packageName = 'jgd', + minimumVersion = '0.1.0', +): string { return [ 'vscode-r-package-consent-v1', id, 'linux-x86_64|4.5', - backend, + policy, '1', - 'jgd', - '0.1.0', + packageName, + minimumVersion, version, 'https://cloud.r-project.org', 'source', @@ -40,20 +49,20 @@ async function waitForFile(file: string): Promise { suite('managed package consent bridge', () => { let directory: string; - let autoPromptEnabled: boolean; + let dismissed: Set; let failPreferenceSave: boolean; let choice: ToolConsentChoice; let prompts: ToolConsentRequest[]; - let sessionState: { autoPrompted: boolean }; + let sessionState: ToolConsentSessionState; const services: ToolConsentService[] = []; setup(async () => { directory = await fs.mkdtemp(path.join(os.tmpdir(), 'vscode-r-package-consent-')); - autoPromptEnabled = true; + dismissed = new Set(); failPreferenceSave = false; choice = 'install'; prompts = []; - sessionState = { autoPrompted: false }; + sessionState = { promptedPackages: new Set() }; }); teardown(async () => { @@ -68,13 +77,13 @@ suite('managed package consent bridge', () => { const result = new ToolConsentService({ directory: requestDirectory, isEnabled: () => true, - isAutoPromptEnabled: () => autoPromptEnabled, + isPromptEnabled: (name) => !dismissed.has(name), sessionState, - rememberAutoPromptDismissal: () => { + rememberPromptDismissal: (name) => { if (failPreferenceSave) { return Promise.reject(new Error('global state unavailable')); } - autoPromptEnabled = false; + dismissed.add(name); return Promise.resolve(); }, prompt: async (request) => { @@ -89,23 +98,25 @@ suite('managed package consent bridge', () => { async function writeRequest( id: string, - backend: 'jgd' | 'auto', + policy: Policy, version?: string, requestDirectory = directory, + packageName?: string, ): Promise { const request = path.join(requestDirectory, `${id}.request`); - await fs.writeFile(request, requestContent(id, backend, version)); + await fs.writeFile(request, requestContent(id, policy, version, packageName)); return request; } test('explicit jgd asks on each request and does not alter automatic prompt preference', async () => { const broker = service(); await broker.start(); - assert.strictEqual(broker.canPrompt('jgd'), true); - assert.strictEqual(broker.canPrompt('auto'), true); - assert.strictEqual(broker.canPrompt('httpgd'), false); + assert.strictEqual(broker.canPrompt(['jgd'], 'always'), true); + assert.strictEqual(broker.canPrompt(['jgd'], 'once'), true); + assert.strictEqual(broker.canPrompt(['unlisted'], 'always'), false); + assert.strictEqual(broker.canPrompt([], 'always'), false); for (const id of ['1'.repeat(32), '2'.repeat(32)]) { - await writeRequest(id, 'jgd'); + await writeRequest(id, 'always'); assert.strictEqual( await waitForFile(path.join(directory, `${id}.response`)), 'approve\n', @@ -114,7 +125,7 @@ suite('managed package consent bridge', () => { await fs.rm(path.join(directory, `${id}.response`), { force: true }); } assert.strictEqual(prompts.length, 2); - assert.strictEqual(autoPromptEnabled, true); + assert.strictEqual(dismissed.size, 0); assert.deepStrictEqual(prompts[0].packages, [ { name: 'jgd', @@ -131,9 +142,9 @@ suite('managed package consent bridge', () => { const id = 'b'.repeat(32); const request = parseToolConsentRequest( id, - requestContent(id, 'jgd').replace(/\n/g, '\r\n'), + requestContent(id, 'always').replace(/\n/g, '\r\n'), ); - assert.strictEqual(request.backend, 'jgd'); + assert.strictEqual(request.policy, 'always'); assert.strictEqual(request.packages[0].sourceBuild, true); }); @@ -142,23 +153,23 @@ suite('managed package consent bridge', () => { await first.start(); const firstId = '3'.repeat(32); choice = 'notNow'; - await writeRequest(firstId, 'auto'); + await writeRequest(firstId, 'once'); assert.strictEqual( await waitForFile(path.join(directory, `${firstId}.response`)), 'decline\n', ); - assert.strictEqual(autoPromptEnabled, true); - assert.strictEqual(sessionState.autoPrompted, true); - assert.strictEqual(first.canPrompt('auto'), false); + assert.strictEqual(dismissed.size, 0); + assert.deepStrictEqual([...sessionState.promptedPackages], ['jgd']); + assert.strictEqual(first.canPrompt(['jgd'], 'once'), false); await first.stop(); const secondDirectory = path.join(directory, 'another-service'); await fs.mkdir(secondDirectory); const secondService = service(undefined, secondDirectory); await secondService.start(); - assert.strictEqual(secondService.canPrompt('auto'), false); + assert.strictEqual(secondService.canPrompt(['jgd'], 'once'), false); const secondId = '4'.repeat(32); - await writeRequest(secondId, 'auto', undefined, secondDirectory); + await writeRequest(secondId, 'once', undefined, secondDirectory); assert.strictEqual( await waitForFile(path.join(secondDirectory, `${secondId}.response`)), 'decline\n', @@ -167,15 +178,15 @@ suite('managed package consent bridge', () => { await secondService.stop(); // A fresh state represents a new VS Code extension-host session. - sessionState = { autoPrompted: false }; + sessionState = { promptedPackages: new Set() }; const newHostDirectory = path.join(directory, 'new-host'); await fs.mkdir(newHostDirectory); const nextHost = service(undefined, newHostDirectory); await nextHost.start(); - assert.strictEqual(nextHost.canPrompt('auto'), true); + assert.strictEqual(nextHost.canPrompt(['jgd'], 'once'), true); choice = 'install'; const thirdId = '5'.repeat(32); - await writeRequest(thirdId, 'auto', undefined, newHostDirectory); + await writeRequest(thirdId, 'once', undefined, newHostDirectory); assert.strictEqual( await waitForFile(path.join(newHostDirectory, `${thirdId}.response`)), 'approve\n', @@ -188,19 +199,19 @@ suite('managed package consent bridge', () => { await broker.start(); choice = 'dontAskAgain'; const firstId = '6'.repeat(32); - assert.strictEqual(broker.canPrompt('auto'), true); - await writeRequest(firstId, 'auto'); + assert.strictEqual(broker.canPrompt(['jgd'], 'once'), true); + await writeRequest(firstId, 'once'); assert.strictEqual( await waitForFile(path.join(directory, `${firstId}.response`)), 'decline\n', ); - assert.strictEqual(autoPromptEnabled, false); - assert.strictEqual(broker.canPrompt('auto'), false); - assert.strictEqual(broker.canPrompt('jgd'), true); + assert.deepStrictEqual([...dismissed], ['jgd']); + assert.strictEqual(broker.canPrompt(['jgd'], 'once'), false); + assert.strictEqual(broker.canPrompt(['jgd'], 'always'), true); choice = 'install'; const explicitId = '7'.repeat(32); - await writeRequest(explicitId, 'jgd'); + await writeRequest(explicitId, 'always'); assert.strictEqual( await waitForFile(path.join(directory, `${explicitId}.response`)), 'approve\n', @@ -208,13 +219,13 @@ suite('managed package consent bridge', () => { assert.strictEqual(prompts.length, 2); await broker.stop(); - sessionState = { autoPrompted: false }; + sessionState = { promptedPackages: new Set() }; const nextHostDirectory = path.join(directory, 'next-host'); await fs.mkdir(nextHostDirectory); const nextHost = service(undefined, nextHostDirectory); await nextHost.start(); const suppressedId = '8'.repeat(32); - await writeRequest(suppressedId, 'auto', undefined, nextHostDirectory); + await writeRequest(suppressedId, 'once', undefined, nextHostDirectory); assert.strictEqual( await waitForFile(path.join(nextHostDirectory, `${suppressedId}.response`)), 'decline\n', @@ -232,8 +243,8 @@ suite('managed package consent bridge', () => { ); const firstId = '7'.repeat(32); const secondId = '8'.repeat(32); - await writeRequest(firstId, 'auto'); - await writeRequest(secondId, 'auto'); + await writeRequest(firstId, 'once'); + await writeRequest(secondId, 'once'); await broker.start(); const deadline = Date.now() + 3000; while (prompts.length === 0 && Date.now() < deadline) { @@ -248,7 +259,7 @@ suite('managed package consent bridge', () => { ); await fs.writeFile( path.join(directory, `${promptedId}.request`), - requestContent(promptedId, 'auto', '1.3.0'), + requestContent(promptedId, 'once', '1.3.0'), ); finishPrompt('install'); assert.strictEqual( @@ -263,29 +274,29 @@ suite('managed package consent bridge', () => { const broker = service(); await broker.start(); const id = '9'.repeat(32); - await writeRequest(id, 'auto'); + await writeRequest(id, 'once'); assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'decline\n'); assert.strictEqual(prompts.length, 1); - assert.strictEqual(autoPromptEnabled, true); + assert.strictEqual(dismissed.size, 0); }); test('Not now, dismiss, and install do not change persistent suppression', async () => { for (const [index, result] of (['notNow', 'dismiss', 'install'] as const).entries()) { - autoPromptEnabled = true; + dismissed.clear(); choice = result; - sessionState = { autoPrompted: false }; + sessionState = { promptedPackages: new Set() }; const hostDirectory = path.join(directory, `host-${index}`); await fs.mkdir(hostDirectory); const broker = service(undefined, hostDirectory); await broker.start(); const id = String(index + 1).repeat(32); - await writeRequest(id, 'auto', undefined, hostDirectory); + await writeRequest(id, 'once', undefined, hostDirectory); const expected = result === 'install' ? 'approve\n' : 'decline\n'; assert.strictEqual( await waitForFile(path.join(hostDirectory, `${id}.response`)), expected, ); - assert.strictEqual(autoPromptEnabled, true); + assert.strictEqual(dismissed.size, 0); } }); @@ -299,9 +310,9 @@ suite('managed package consent bridge', () => { const broker = new ToolConsentService({ directory, isEnabled: () => true, - isAutoPromptEnabled: () => true, + isPromptEnabled: () => true, sessionState, - rememberAutoPromptDismissal: () => { + rememberPromptDismissal: () => { dismissals++; return Promise.resolve(); }, @@ -317,7 +328,7 @@ suite('managed package consent bridge', () => { services.push(broker); await broker.start(); const id = 'a'.repeat(32); - await writeRequest(id, 'auto'); + await writeRequest(id, 'once'); await started; await broker.stop(); finishPrompt('install'); @@ -325,4 +336,81 @@ suite('managed package consent bridge', () => { assert.strictEqual(prompts.length, 1); assert.strictEqual(dismissals, 0); }); + + async function answerFor( + id: string, + policy: Policy, + packageName: string, + requestDirectory = directory, + ): Promise { + await writeRequest(id, policy, undefined, requestDirectory, packageName); + return waitForFile(path.join(requestDirectory, `${id}.response`)); + } + + test('automatic prompts are tracked per package within one host session', async () => { + const broker = service(); + await broker.start(); + choice = 'notNow'; + assert.strictEqual(broker.canPrompt(['rstudioapi'], 'once'), true); + assert.strictEqual(await answerFor('c'.repeat(32), 'once', 'jgd'), 'decline\n'); + // Offering jgd must not use up the offer for rstudioapi in the same startup. + assert.strictEqual(broker.canPrompt(['jgd'], 'once'), false); + assert.strictEqual(broker.canPrompt(['rstudioapi'], 'once'), true); + choice = 'install'; + assert.strictEqual(await answerFor('d'.repeat(32), 'once', 'rstudioapi'), 'approve\n'); + assert.deepStrictEqual( + prompts.map((request) => request.packages[0].name), + ['jgd', 'rstudioapi'], + ); + // Each package is offered once per host session. + assert.strictEqual(await answerFor('e'.repeat(32), 'once', 'rstudioapi'), 'decline\n'); + assert.strictEqual(await answerFor('f'.repeat(32), 'once', 'jgd'), 'decline\n'); + assert.strictEqual(prompts.length, 2); + assert.deepStrictEqual([...sessionState.promptedPackages].sort(), ['jgd', 'rstudioapi']); + }); + + test("Don't ask again is remembered only for its own package", async () => { + const broker = service(); + await broker.start(); + choice = 'dontAskAgain'; + assert.strictEqual(await answerFor('1'.repeat(32), 'once', 'rstudioapi'), 'decline\n'); + assert.deepStrictEqual([...dismissed], ['rstudioapi']); + assert.strictEqual(broker.canPrompt(['rstudioapi'], 'once'), false); + assert.strictEqual(broker.canPrompt(['jgd'], 'once'), true); + + // A new host session still honors the remembered choice for rstudioapi only. + sessionState = { promptedPackages: new Set() }; + const nextHostDirectory = path.join(directory, 'next-host'); + await fs.mkdir(nextHostDirectory); + const nextHost = service(undefined, nextHostDirectory); + await nextHost.start(); + assert.strictEqual(nextHost.canPrompt(['rstudioapi'], 'once'), false); + assert.strictEqual(nextHost.canPrompt(['jgd'], 'once'), true); + assert.strictEqual( + await answerFor('2'.repeat(32), 'once', 'rstudioapi', nextHostDirectory), + 'decline\n', + ); + assert.strictEqual(prompts.length, 1); + choice = 'install'; + assert.strictEqual( + await answerFor('3'.repeat(32), 'once', 'jgd', nextHostDirectory), + 'approve\n', + ); + assert.strictEqual(prompts.length, 2); + }); + + test('packages outside the allowlist are declined without a prompt', async () => { + const broker = service(); + await broker.start(); + assert.strictEqual(await answerFor('4'.repeat(32), 'always', 'unlisted'), 'decline\n'); + assert.strictEqual(prompts.length, 0); + }); + + test('the prompt request carries the package and policy', async () => { + const broker = service(); + await broker.start(); + assert.strictEqual(await answerFor('5'.repeat(32), 'once', 'rstudioapi'), 'approve\n'); + assert.strictEqual(prompts[0].policy, 'once'); + assert.strictEqual(prompts[0].packages[0].name, 'rstudioapi'); + }); }); diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index e4dcb068a..960e599f5 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -1,4 +1,4 @@ -import { JGD_MINIMUM_VERSION } from '../../jgdVersion'; +import { TOOL_MINIMUM_VERSIONS } from '../../toolVersions'; import * as vscode from 'vscode'; import * as sinon from 'sinon'; import * as assert from 'assert'; @@ -995,12 +995,21 @@ suite('Session Communication', () => { assert.match(source, /tool_consent_directory = tool_consent_dir/); assert.match(source, /tool_library_helper = tool_library_helper/); assert.match(source, /tool_consent_helper = tool_consent_helper/); - assert.match(source, /prepare-jgd\.R/); + assert.match(source, /prepare-tools\.R/); assert.match(source, /jgd_can_prompt <- (?:TRUE|FALSE)/); - assert.match(source, /jgd_helper = jgd_helper/); + assert.match(source, /rstudioapi_can_prompt <- (?:TRUE|FALSE)/); + assert.match(source, /tool_prepare_helper = tool_prepare_helper/); assert.match(source, /jgd_can_prompt = jgd_can_prompt/); + assert.match(source, /rstudioapi_can_prompt = rstudioapi_can_prompt/); assert.ok( - source.includes(`jgd_minimum_version = ${JSON.stringify(JGD_MINIMUM_VERSION)}`), + source.includes( + `jgd_minimum_version = ${JSON.stringify(TOOL_MINIMUM_VERSIONS.jgd)}`, + ), + ); + assert.ok( + source.includes( + `rstudioapi_minimum_version = ${JSON.stringify(TOOL_MINIMUM_VERSIONS.rstudioapi)}`, + ), ); const backgroundSource = await fsp.readFile(backgroundPreparation.scriptPath, 'utf8'); assert.ok( @@ -1381,10 +1390,21 @@ suite('Session Communication', () => { assert.ok(preparation); const source = await fsp.readFile(preparation!.scriptPath, 'utf8'); assert.match(source, /tool_consent_dir <- ""/); + assert.match(source, /jgd_can_prompt <- FALSE/); + assert.match(source, /rstudioapi_can_prompt <- FALSE/); assert.match(source, /vscode_r_attach_sess\(/); preparation!.dispose(); }); + test('jgd install offers are limited to the jgd and auto plot backends', async () => { + await session.getToolConsentDirectory(); + // An explicit jgd backend is offered every time, so it does not depend on prior prompts. + assert.strictEqual(session.canPromptForJgdInstall('jgd'), true); + for (const backend of ['httpgd', 'standard', 'native']) { + assert.strictEqual(session.canPromptForJgdInstall(backend), false); + } + }); + test('sess and tool consent use separate directories and both are cleaned up on shutdown', async () => { const sessDirectory = await session.getSessConsentDirectory(); const toolDirectory = await session.getToolConsentDirectory(); diff --git a/src/test/suite/terminal.test.ts b/src/test/suite/terminal.test.ts index 2c92b3550..f01debbad 100644 --- a/src/test/suite/terminal.test.ts +++ b/src/test/suite/terminal.test.ts @@ -1,4 +1,4 @@ -import { JGD_MINIMUM_VERSION } from '../../jgdVersion'; +import { TOOL_MINIMUM_VERSIONS } from '../../toolVersions'; import * as vscode from 'vscode'; import * as sinon from 'sinon'; import * as assert from 'assert'; @@ -858,9 +858,21 @@ suite('R Terminal', () => { path.join('R', 'tool-consent.R'), ), ); - assert.ok(options.env?.['VSCODE_R_JGD_HELPER']?.endsWith(path.join('R', 'prepare-jgd.R'))); - assert.strictEqual(options.env?.['VSCODE_R_JGD_MINIMUM_VERSION'], JGD_MINIMUM_VERSION); + assert.ok( + options.env?.['VSCODE_R_TOOL_PREPARE_HELPER']?.endsWith( + path.join('R', 'prepare-tools.R'), + ), + ); + assert.strictEqual( + options.env?.['VSCODE_R_JGD_MINIMUM_VERSION'], + TOOL_MINIMUM_VERSIONS.jgd, + ); assert.strictEqual(options.env?.['VSCODE_R_JGD_CAN_PROMPT'], 'FALSE'); + assert.strictEqual( + options.env?.['VSCODE_R_RSTUDIOAPI_MINIMUM_VERSION'], + TOOL_MINIMUM_VERSIONS.rstudioapi, + ); + assert.strictEqual(options.env?.['VSCODE_R_RSTUDIOAPI_CAN_PROMPT'], 'FALSE'); sinon.assert.notCalled(getRpath); sinon.assert.notCalled(queryR); sinon.assert.notCalled(installTask); diff --git a/src/test/suite/toolConsentAdapter.test.ts b/src/test/suite/toolConsentAdapter.test.ts index 67ad3a149..21df5ed19 100644 --- a/src/test/suite/toolConsentAdapter.test.ts +++ b/src/test/suite/toolConsentAdapter.test.ts @@ -1,18 +1,18 @@ import * as assert from 'assert'; -import { JGD_MINIMUM_VERSION } from '../../jgdVersion'; import * as fs from 'node:fs/promises'; import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { createToolConsentService } from '../../toolConsentAdapter'; +import { TOOL_MINIMUM_VERSIONS } from '../../toolVersions'; suite('Managed package consent persistence', () => { test('dismissal applies to the same minimum version and a changed minimum prompts again', async () => { const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'vscode-r-tool-adapter-')); const sandbox = sinon.createSandbox(); const values = new Map([ - ['jgdInstallPromptDismissedMinimumVersion', JGD_MINIMUM_VERSION], + ['jgdInstallPromptDismissedMinimumVersion', TOOL_MINIMUM_VERSIONS.jgd], ]); const globalState: vscode.Memento = { keys: () => [...values.keys()], @@ -29,25 +29,25 @@ suite('Managed package consent persistence', () => { const broker = createToolConsentService(directory, () => true, globalState, 5); try { await broker.start(); - assert.strictEqual(broker.canPrompt('auto'), false); - assert.strictEqual(broker.canPrompt('jgd'), true); + assert.strictEqual(broker.canPrompt(['jgd'], 'once'), false); + assert.strictEqual(broker.canPrompt(['jgd'], 'always'), true); // A prior minimum's dismissal permits an offer for the current minimum. values.set('jgdInstallPromptDismissedMinimumVersion', '0.0.1'); - assert.strictEqual(broker.canPrompt('auto'), true); - for (const [index, backend] of ['auto', 'jgd'].entries()) { + assert.strictEqual(broker.canPrompt(['jgd'], 'once'), true); + for (const [index, policy] of (['once', 'always'] as const).entries()) { const id = String(index + 1).repeat(32); const content = [ 'vscode-r-package-consent-v1', id, 'linux-x86_64|4.5', - backend, + policy, '1', 'jgd', - JGD_MINIMUM_VERSION, + TOOL_MINIMUM_VERSIONS.jgd, '1.2.0', 'https://cloud.r-project.org', - backend === 'auto' ? 'source' : 'win.binary', - backend === 'auto' ? 'yes' : 'no', + policy === 'once' ? 'source' : 'win.binary', + policy === 'once' ? 'yes' : 'no', '', ].join('\n'); await fs.writeFile(path.join(directory, id + '.request'), content); @@ -57,7 +57,7 @@ suite('Managed package consent persistence', () => { try { assert.strictEqual( await fs.readFile(response, 'utf8'), - backend === 'auto' ? 'decline\n' : 'approve\n', + policy === 'once' ? 'decline\n' : 'approve\n', ); break; } catch (error) { @@ -72,10 +72,10 @@ suite('Managed package consent persistence', () => { } } assert.deepStrictEqual(update.args, [ - ['jgdInstallPromptDismissedMinimumVersion', JGD_MINIMUM_VERSION], + ['jgdInstallPromptDismissedMinimumVersion', TOOL_MINIMUM_VERSIONS.jgd], ]); - assert.strictEqual(broker.canPrompt('auto'), false); - assert.strictEqual(broker.canPrompt('jgd'), true); + assert.strictEqual(broker.canPrompt(['jgd'], 'once'), false); + assert.strictEqual(broker.canPrompt(['jgd'], 'always'), true); assert.strictEqual(prompt.callCount, 2); assert.ok(prompt.firstCall.args[0].includes('(source; minimum ')); assert.ok(prompt.secondCall.args[0].includes('(binary; minimum ')); @@ -85,4 +85,70 @@ suite('Managed package consent persistence', () => { await fs.rm(directory, { recursive: true, force: true }); } }); + + test('rstudioapi has its own dismissal key and prompt text', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'vscode-r-tool-adapter-')); + const sandbox = sinon.createSandbox(); + const values = new Map([ + ['jgdInstallPromptDismissedMinimumVersion', TOOL_MINIMUM_VERSIONS.jgd], + ]); + const globalState: vscode.Memento = { + keys: () => [...values.keys()], + get: (key: string, fallback?: T) => (values.get(key) as T | undefined) ?? fallback, + update: (key: string, value: unknown) => { + values.set(key, value); + return Promise.resolve(); + }, + }; + const update = sandbox.spy(globalState, 'update'); + const prompt = sandbox.stub(vscode.window, 'showWarningMessage'); + prompt.onFirstCall().resolves("Don't ask again" as never); + const broker = createToolConsentService(directory, () => true, globalState, 5); + try { + await broker.start(); + // The remembered jgd choice does not hide the rstudioapi offer. + assert.strictEqual(broker.canPrompt(['jgd'], 'once'), false); + assert.strictEqual(broker.canPrompt(['rstudioapi'], 'once'), true); + const id = '3'.repeat(32); + await fs.writeFile( + path.join(directory, id + '.request'), + [ + 'vscode-r-package-consent-v1', + id, + 'linux-x86_64|4.5', + 'once', + '1', + 'rstudioapi', + TOOL_MINIMUM_VERSIONS.rstudioapi, + '0.17.1', + 'https://cloud.r-project.org', + 'source', + 'no', + '', + ].join('\n'), + ); + const response = path.join(directory, id + '.response'); + const deadline = Date.now() + 3000; + while (!(await fs.stat(response).catch(() => undefined)) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.strictEqual(await fs.readFile(response, 'utf8'), 'decline\n'); + assert.deepStrictEqual(update.args, [ + [ + 'rstudioapiInstallPromptDismissedMinimumVersion', + TOOL_MINIMUM_VERSIONS.rstudioapi, + ], + ]); + assert.strictEqual(broker.canPrompt(['rstudioapi'], 'once'), false); + assert.strictEqual(prompt.callCount, 1); + const text = prompt.firstCall.args[0] as string; + assert.ok(text.includes('rstudioapi 0.17.1')); + assert.ok(text.includes('RStudio API emulation is optional')); + assert.ok(!/plot/i.test(text)); + } finally { + await broker.stop(); + sandbox.restore(); + await fs.rm(directory, { recursive: true, force: true }); + } + }); }); diff --git a/src/toolConsent.ts b/src/toolConsent.ts index 02ef238fa..bdbd9b769 100644 --- a/src/toolConsent.ts +++ b/src/toolConsent.ts @@ -1,10 +1,16 @@ import { ConsentResponse, FileConsentService } from './fileConsent'; +import { isToolPackageName } from './toolVersions'; -export type ToolConsentBackend = 'jgd' | 'auto'; +/** + * `always` asks on every request; `once` asks at most once per host session for each package + * and honors a remembered "Don't ask again". + */ +export type ToolConsentPolicy = 'always' | 'once'; export type ToolConsentChoice = 'install' | 'notNow' | 'dontAskAgain' | 'dismiss'; export interface ToolConsentSessionState { - autoPrompted: boolean; + /** Packages already offered under the `once` policy in this extension host. */ + promptedPackages: Set; } export interface ToolPackagePlan { @@ -19,16 +25,16 @@ export interface ToolPackagePlan { export interface ToolConsentRequest { id: string; runtime: string; - backend: ToolConsentBackend; + policy: ToolConsentPolicy; packages: ToolPackagePlan[]; } export interface ToolConsentServiceOptions { directory: string; isEnabled: () => boolean; - isAutoPromptEnabled: () => boolean; + isPromptEnabled: (packageName: string) => boolean; sessionState: ToolConsentSessionState; - rememberAutoPromptDismissal: () => Promise; + rememberPromptDismissal: (packageName: string) => Promise; prompt: (request: ToolConsentRequest) => Promise; intervalMs?: number; } @@ -54,11 +60,11 @@ export function parseToolConsentRequest(id: string, content: string): ToolConsen throw new Error('Malformed package consent request.'); } const runtime = lines[2]; - const backend = lines[3]; + const policy = lines[3]; const count = Number(lines[4]); if ( !RUNTIME.test(runtime) || - (backend !== 'jgd' && backend !== 'auto') || + (policy !== 'always' && policy !== 'once') || !/^(?:[1-9]|1[0-6])$/.test(lines[4]) || count < 1 || count > MAX_PACKAGES || @@ -94,7 +100,7 @@ export function parseToolConsentRequest(id: string, content: string): ToolConsen sourceBuild: sourceBuild === 'yes', }); } - return { id, runtime, backend, packages }; + return { id, runtime, policy, packages }; } /** Bridges a fixed R package plan to a one-time VS Code decision. Use a directory dedicated to this service. */ @@ -124,39 +130,37 @@ export class ToolConsentService { return this.bridge.stop(); } - canPrompt(backend: string): boolean { - if (!this.running || !this.options.isEnabled()) { + /** Whether a request for these packages under this policy could currently be shown. */ + canPrompt(packageNames: readonly string[], policy: ToolConsentPolicy): boolean { + if (!this.running || !this.options.isEnabled() || packageNames.length === 0) { return false; } - if (backend === 'jgd') { - return true; - } - return ( - backend === 'auto' && - this.options.isAutoPromptEnabled() && - !this.options.sessionState.autoPrompted - ); + return packageNames.every((name) => { + if (!isToolPackageName(name)) { + return false; + } + return ( + policy === 'always' || + (this.options.isPromptEnabled(name) && + !this.options.sessionState.promptedPackages.has(name)) + ); + }); } private async processRequest( request: ToolConsentRequest, respond: (response: ConsentResponse) => Promise, ): Promise { - if (!this.running || !this.options.isEnabled()) { - await respond('decline'); - return; - } - const automatic = request.backend === 'auto'; - if (!this.canPrompt(request.backend)) { + const names = request.packages.map((item) => item.name); + const once = request.policy === 'once'; + if (!this.canPrompt(names, request.policy)) { await respond('decline'); return; } - if (automatic) { - this.options.sessionState.autoPrompted = true; - } - if (!this.running || !this.options.isEnabled()) { - await respond('decline'); - return; + if (once) { + for (const name of names) { + this.options.sessionState.promptedPackages.add(name); + } } let choice: ToolConsentChoice = 'dismiss'; try { @@ -168,9 +172,11 @@ export class ToolConsentService { await respond('decline'); return; } - if (automatic && choice === 'dontAskAgain') { + if (once && choice === 'dontAskAgain') { try { - await this.options.rememberAutoPromptDismissal(); + for (const name of names) { + await this.options.rememberPromptDismissal(name); + } } catch { await respond('decline'); return; diff --git a/src/toolConsentAdapter.ts b/src/toolConsentAdapter.ts index bd120034e..816f01620 100644 --- a/src/toolConsentAdapter.ts +++ b/src/toolConsentAdapter.ts @@ -1,15 +1,33 @@ import * as vscode from 'vscode'; -import { JGD_MINIMUM_VERSION } from './jgdVersion'; import { ToolConsentChoice, ToolConsentRequest, ToolConsentService, ToolConsentSessionState, } from './toolConsent'; +import { isToolPackageName, TOOL_MINIMUM_VERSIONS, ToolPackageName } from './toolVersions'; -const JGD_INSTALL_PROMPT_DISMISSED_MINIMUM_VERSION = 'jgdInstallPromptDismissedMinimumVersion'; +// One global-state key per package, for example jgdInstallPromptDismissedMinimumVersion. +function dismissedMinimumVersionKey(packageName: string): string { + return `${packageName}InstallPromptDismissedMinimumVersion`; +} // Shared by services in this extension host; a new host starts with a fresh state. -const hostSessionState: ToolConsentSessionState = { autoPrompted: false }; +const hostSessionState: ToolConsentSessionState = { promptedPackages: new Set() }; + +const OPTIONAL_NOTICES: Record = { + jgd: 'Automatic plot backend setup is optional; declining uses the available fallback.', + rstudioapi: 'RStudio API emulation is optional; declining skips installing rstudioapi.', +}; + +function optionalNotice(request: ToolConsentRequest): string { + if (request.policy !== 'once') { + return ''; + } + const notices = request.packages.flatMap((item) => + isToolPackageName(item.name) ? [OPTIONAL_NOTICES[item.name]] : [], + ); + return notices.length > 0 ? `\n\n${notices.join(' ')}` : ''; +} function formatPlan(request: ToolConsentRequest): string { const [platform, version] = request.runtime.split('|'); @@ -20,11 +38,7 @@ function formatPlan(request: ToolConsentRequest): string { const buildNotice = request.packages.some((item) => item.sourceBuild) ? '\n\nA source build may be required and may need system build tools.' : ''; - const modeNotice = - request.backend === 'auto' - ? '\n\nAutomatic plot backend setup is optional; declining uses the available fallback.' - : ''; - return `Install these R packages for R ${version} (${platform})?\n\n${rows.join('\n')}${buildNotice}${modeNotice}\n\nPackages are installed in a vscode-R-managed library. Existing user and project libraries are not changed.`; + return `Install these R packages for R ${version} (${platform})?\n\n${rows.join('\n')}${buildNotice}${optionalNotice(request)}\n\nPackages are installed in a vscode-R-managed library. Existing user and project libraries are not changed.`; } export function createToolConsentService( @@ -36,14 +50,18 @@ export function createToolConsentService( return new ToolConsentService({ directory, isEnabled, - isAutoPromptEnabled: () => - globalState.get(JGD_INSTALL_PROMPT_DISMISSED_MINIMUM_VERSION) !== - JGD_MINIMUM_VERSION, + isPromptEnabled: (packageName) => + isToolPackageName(packageName) && + globalState.get(dismissedMinimumVersionKey(packageName)) !== + TOOL_MINIMUM_VERSIONS[packageName], sessionState: hostSessionState, - rememberAutoPromptDismissal: async () => { + rememberPromptDismissal: async (packageName) => { + if (!isToolPackageName(packageName)) { + throw new Error(`Unknown managed tool package: ${packageName}`); + } await globalState.update( - JGD_INSTALL_PROMPT_DISMISSED_MINIMUM_VERSION, - JGD_MINIMUM_VERSION, + dismissedMinimumVersionKey(packageName), + TOOL_MINIMUM_VERSIONS[packageName], ); }, intervalMs, @@ -52,7 +70,7 @@ export function createToolConsentService( const notNow = 'Not now'; const dontAskAgain = "Don't ask again"; const choice = - request.backend === 'auto' + request.policy === 'once' ? await vscode.window.showWarningMessage( formatPlan(request), install, diff --git a/src/toolVersions.ts b/src/toolVersions.ts new file mode 100644 index 000000000..40d89128d --- /dev/null +++ b/src/toolVersions.ts @@ -0,0 +1,13 @@ +// Minimum versions of the CRAN packages vscode-R can install into its managed tool library. +// Shared by consent eligibility and R package preparation. +export const TOOL_MINIMUM_VERSIONS = { + jgd: '0.1.0', + // The first release that exports the document position and range helpers used by sess. + rstudioapi: '0.4.0', +} as const; + +export type ToolPackageName = keyof typeof TOOL_MINIMUM_VERSIONS; + +export function isToolPackageName(name: string): name is ToolPackageName { + return Object.prototype.hasOwnProperty.call(TOOL_MINIMUM_VERSIONS, name); +} From 2ad61f58e7bac6d394df8981738535151c108cfd Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 23:08:51 +0000 Subject: [PATCH 2/2] fix: retry asynchronous R test log reads on Windows --- R/tests/prepare-rstudioapi.R | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/R/tests/prepare-rstudioapi.R b/R/tests/prepare-rstudioapi.R index 1f8b8fd84..1bf403426 100644 --- a/R/tests/prepare-rstudioapi.R +++ b/R/tests/prepare-rstudioapi.R @@ -132,6 +132,24 @@ local({ " as.character(file.exists(marker)), tool_path), result)" ), runner) + read_child_logs <- function(logs, timeout_seconds = 10) { + deadline <- Sys.time() + timeout_seconds + repeat { + output <- lapply(logs, function(path) { + suppressWarnings(tryCatch(readLines(path, warn = FALSE), + error = function(error) error)) + }) + failed <- vapply(output, inherits, FALSE, "error") + if (!any(failed)) return(unlist(output)) + if (Sys.time() >= deadline) { + stop(output[[which(failed)[[1L]]]]) + } + # Publishing a result does not close redirected stdout/stderr. Windows + # can deny read access until the asynchronous child releases them. + Sys.sleep(0.02) + } + } + # Answer consent requests by package until the child publishes its result. serve <- function(consent, result, answers, logs) { seen <- list() @@ -152,9 +170,8 @@ local({ } if (file.exists(result)) return(seen) if (Sys.time() >= deadline) { - output <- unlist(lapply(logs, function(path) { - if (file.exists(path)) readLines(path, warn = FALSE) else character() - })) + output <- tryCatch(read_child_logs(logs, timeout_seconds = 0), + error = conditionMessage) stop("Timed out waiting for the attach child: ", paste(output, collapse = "\n")) } Sys.sleep(0.02) @@ -170,7 +187,7 @@ local({ system2_with_child_environment(isolated_child_env(mode), r_binary, shQuote(args), wait = FALSE, stdout = logs[[1L]], stderr = logs[[2L]]) requests <- serve(file.path(root, mode, "consent"), result, answers, logs) - output <- unlist(lapply(logs, readLines, warn = FALSE)) + output <- read_child_logs(logs) list(values = readLines(result, warn = FALSE), requests = requests, messages = grep("^vscode-R|incompatible", output, value = TRUE)) }