diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index af863e8bb..517e1cef3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -38,10 +38,16 @@ jobs: - run: pnpm install --frozen-lockfile - name: Verify sess source identity and bootstrap run: pnpm run test:sess-source + - name: Verify managed tool library + run: pnpm run test:tool-library + - name: Verify managed tool consent + run: pnpm run test:tool-consent - name: Install R test dependencies run: install.packages(c("remotes", "pkgbuild", "languageserver", "lintr", "renv")) shell: Rscript {0} - run: pnpm run build + - name: Verify managed jgd preparation + run: pnpm run test:prepare-jgd - name: Verify installed sess identity run: | source("R/sess_source.R") diff --git a/R/attach_sess.R b/R/attach_sess.R index 3b2849a84..500c6dd40 100644 --- a/R/attach_sess.R +++ b/R/attach_sess.R @@ -1,5 +1,40 @@ # Shared terminal and manual-attach preparation. Installation requires a # single-use grant from the extension process; .libPaths() is never changed. +vscode_r_with_jgd <- function( + plot_backend, + connect, + jgd_helper = Sys.getenv("VSCODE_R_JGD_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_JGD_CAN_PROMPT", unset = "TRUE"), "TRUE"), + minimum_version = Sys.getenv("VSCODE_R_JGD_MINIMUM_VERSION", unset = "") +) { + if (!is.function(connect)) stop("connect must be a function.", call. = FALSE) + 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)) { + 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 + )) + } + message("vscode-R could not load managed jgd setup resources; using the available plot fallback.") + connect(allow_jgd = FALSE) +} + vscode_r_prepare_sess <- function(pkg_path, managed_root, consent_dir, source_helper, installer_helper, timeout_seconds = 180, @@ -185,7 +220,22 @@ vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, setup_timeout_seconds = 300, use_rstudioapi = TRUE, attachment_id = NULL, - auto_activate = TRUE) { + auto_activate = TRUE, + 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 = "" + ), + jgd_helper = Sys.getenv("VSCODE_R_JGD_HELPER", unset = ""), + jgd_can_prompt = TRUE, + jgd_minimum_version = Sys.getenv( + "VSCODE_R_JGD_MINIMUM_VERSION", unset = "" + )) { if (!is.null(attachment_id) && !(is.character(attachment_id) && length(attachment_id) == 1L && !is.na(attachment_id) && @@ -241,7 +291,15 @@ vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, args$attachment_id <- attachment_id args$auto_activate <- auto_activate } - do.call(connect, args) + 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_library_helper, tool_consent_helper, jgd_can_prompt, jgd_minimum_version + ) } if (profile_process) { diff --git a/R/prepare-jgd.R b/R/prepare-jgd.R new file mode 100644 index 000000000..ca5a8b80f --- /dev/null +++ b/R/prepare-jgd.R @@ -0,0 +1,87 @@ +# Keep optional jgd preparation and its result shared by terminal connections. +vscode_r_connect_with_jgd <- function( + plot_backend, + connect, + 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.function(connect)) stop("connect must be a function.", call. = FALSE) + if (!plot_backend %in% c("jgd", "auto")) { + return(connect(allow_jgd = TRUE)) + } + ready <- isTRUE(vscode_r_prepare_jgd( + plot_backend, tool_root, consent_directory, + library_helper, consent_helper, can_prompt, minimum_version + )) + 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 = "") +) { + 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.") + return(FALSE) + } + + tryCatch({ + tool_environment <- new.env(parent = environment(vscode_r_prepare_jgd)) + 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 + ))) + } + prepared <- tool_environment$tool_library_prepare( + "jgd", + minimum_version, + tool_root, + consent_directory, + plot_backend, + 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.") + return(FALSE) + } + namespace <- tool_environment$tool_library_load( + "jgd", minimum_version, tool_root + ) + if (is.null(namespace)) { + message("vscode-R could not load a compatible jgd package; using the available plot fallback.") + return(FALSE) + } + TRUE + }, error = function(error) { + message("vscode-R could not prepare optional jgd plot support: ", conditionMessage(error)) + FALSE + }, interrupt = function(error) { + message("vscode-R jgd preparation was interrupted; using the available plot fallback.") + FALSE + }) +} diff --git a/R/profile.R b/R/profile.R index 9ca4c2c3b..937402003 100644 --- a/R/profile.R +++ b/R/profile.R @@ -52,7 +52,10 @@ 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")) - result <- connect(use_rstudioapi = use_rstudioapi, plot_backend = plot_backend) + result <- vscode_r_with_jgd(plot_backend, function(allow_jgd = TRUE) { + connect(use_rstudioapi = use_rstudioapi, plot_backend = plot_backend, + allow_jgd = allow_jgd) + }) isTRUE(result) } diff --git a/R/tests/attach_sess.R b/R/tests/attach_sess.R index bbb4b7b47..572264f1c 100644 --- a/R/tests/attach_sess.R +++ b/R/tests/attach_sess.R @@ -198,7 +198,7 @@ writeLines(c( " stopifnot(identical(result, TRUE), same_paths(.libPaths(), ordinary))", " stopifnot(identical(getOption('vscodeR.test.use_rstudioapi'), FALSE))", " stopifnot(identical(getOption('vscodeR.test.connect.args'),", - " list(attachment_id='console_1', auto_activate=FALSE)))", + " list(attachment_id='console_1', auto_activate=FALSE, allow_jgd=TRUE)))", " stopifnot(identical(sess_loaded_source_revision(), revision))", " actual <- getNamespaceInfo(asNamespace('sess'), 'path')", " stopifnot(same_paths(actual, file.path(expected, 'sess')))", @@ -211,7 +211,7 @@ writeLines(c( " file.path(getwd(), 'R', 'sess-package-install.R'), 'standard', 0)", " stopifnot(identical(legacy_result, TRUE),", " identical(getOption('vscodeR.test.use_rstudioapi'), TRUE))", - " stopifnot(identical(getOption('vscodeR.test.connect.args'), list()))", + " stopifnot(identical(getOption('vscodeR.test.connect.args'), list(allow_jgd=TRUE)))", " blocked_file <- file.path(root, 'missing-directory', 'startup')", " context <- vscode_r_startup_register(blocked_file, strrep('c', 32), 'endpoint')", " called <- FALSE", diff --git a/R/tests/prepare-jgd.R b/R/tests/prepare-jgd.R new file mode 100644 index 000000000..c06180a89 --- /dev/null +++ b/R/tests/prepare-jgd.R @@ -0,0 +1,517 @@ +# Base-R integration coverage for optional jgd preparation during attach. +local({ + root <- tempfile("prepare-jgd-") + dir.create(root) + root <- normalizePath(root, winslash = "/", mustWork = TRUE) + r_literal <- function(value) paste0("\"", gsub("\\\"", "\\\\\\\"", value), "\"") + on.exit(unlink(root, recursive = TRUE), add = TRUE) + ambient_library <- file.path(root, "ambient-user-library") + isolated_child_env <- function(label, libraries = "") { + 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 = libraries, VSCODE_R_TEST_AMBIENT_LIBRARY = ambient_library) + } + with_child_environment <- function(environment, expression) { + stopifnot(is.character(environment), !is.null(names(environment)), + !anyNA(environment), !anyDuplicated(names(environment))) + 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)) + force(expression) + } + system2_with_child_environment <- function(environment, command, args, ...) { + arguments <- c(list(command = command, args = args), list(...)) + with_child_environment(environment, do.call(system2, arguments)) + } + + source_package <- function(version) { + package <- file.path(root, paste0("jgd-", version)) + dir.create(file.path(package, "R"), recursive = TRUE) + writeLines(c( + "Package: jgd", paste0("Version: ", version), "Title: jgd fixture", + "Description: Local jgd fixture.", "License: MIT", "Author: Test Author", + "Maintainer: Test Author ", "Encoding: UTF-8" + ), file.path(package, "DESCRIPTION")) + writeLines(c( + "fixture_device <- function() 'jgd'", + "jgd <- function() {", + " path <- Sys.getenv('JGD_TEST_DEVICE_LOG', '')", + " if (nzchar(path)) cat('jgd\\n', file=path, append=TRUE)", + " invisible(NULL)", + "}" + ), file.path(package, "R", "api.R")) + writeLines(c("export(fixture_device)", "export(jgd)"), file.path(package, "NAMESPACE")) + previous <- setwd(root) + on.exit(setwd(previous), add = TRUE) + output <- system2_with_child_environment( + isolated_child_env(paste0("build-", version)), + file.path(R.home("bin"), "R"), + c("CMD", "build", "--no-build-vignettes", shQuote(basename(package))), + stdout = TRUE, stderr = TRUE + ) + status <- attr(output, "status") + if (!is.null(status) && status != 0L) stop(paste(output, collapse = "\n")) + archive <- file.path(root, paste0("jgd_", version, ".tar.gz")) + stopifnot(file.exists(archive)) + contrib <- file.path(root, "repo", "src", "contrib") + dir.create(contrib, recursive = TRUE, showWarnings = FALSE) + file.copy(archive, contrib) + tools::write_PACKAGES(contrib, type = "source") + package + } + + package <- source_package("0.1.0") + source_package("0.0.1") + unlink(file.path(root, "repo", "src", "contrib", "jgd_0.0.1.tar.gz")) + tools::write_PACKAGES(file.path(root, "repo", "src", "contrib"), type = "source") + repo <- paste0("file:///", sub("^/", "", normalizePath(file.path(root, "repo"), winslash = "/"))) + dir.create(ambient_library) + ambient_install <- system2_with_child_environment( + isolated_child_env("ambient-install"), + file.path(R.home("bin"), "R"), + c("CMD", "INSTALL", paste0("--library=", shQuote(ambient_library)), + shQuote(file.path(root, "jgd-0.1.0"))), + stdout = TRUE, stderr = TRUE + ) + ambient_status <- attr(ambient_install, "status") + if (!is.null(ambient_status) && ambient_status != 0L) { + stop("Could not install ambient jgd fixture: ", paste(ambient_install, collapse = "\n")) + } + old_user_library <- Sys.getenv("R_LIBS_USER", unset = NA_character_) + old_site_library <- Sys.getenv("R_LIBS_SITE", unset = NA_character_) + Sys.setenv(R_LIBS_USER = ambient_library, R_LIBS_SITE = ambient_library) + on.exit({ + if (is.na(old_user_library)) Sys.unsetenv("R_LIBS_USER") else + Sys.setenv(R_LIBS_USER = old_user_library) + if (is.na(old_site_library)) Sys.unsetenv("R_LIBS_SITE") else + Sys.setenv(R_LIBS_SITE = old_site_library) + }, 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"), + db = installed, which = c("Depends", "Imports"), + recursive = TRUE), use.names = FALSE) + )) + dependency_library <- file.path(root, "runtime-dependencies") + dir.create(dependency_library) + for (dependency in setdiff(runtime_dependencies, rownames(installed)[installed[, "LibPath"] == .Library])) { + rows <- which(rownames(installed) == dependency) + if (!length(rows)) stop("Missing installed runtime test dependency: ", dependency) + source_path <- file.path(installed[rows[[1L]], "LibPath"], dependency) + if (!file.copy(source_path, dependency_library, recursive = TRUE)) { + stop("Could not stage runtime test dependency: ", dependency) + } + } + sess_library <- file.path(root, "sess-library") + dir.create(sess_library) + sess_install <- system2_with_child_environment( + isolated_child_env("sess-install", dependency_library), + file.path(R.home("bin"), "R"), + c("CMD", "INSTALL", paste0("--library=", shQuote(sess_library)), + shQuote(file.path(getwd(), "sess"))), + stdout = TRUE, stderr = TRUE + ) + sess_status <- attr(sess_install, "status") + if (!is.null(sess_status) && sess_status != 0L) { + stop("Could not install sess test fixture: ", paste(sess_install, collapse = "\n")) + } + runner <- file.path(root, "run.R") + writeLines(c( + "args <- commandArgs(TRUE); mode <- args[[1L]]; root <- args[[2L]]; repo <- args[[3L]];", + "result <- args[[4L]];", + "ambient <- normalizePath(Sys.getenv('VSCODE_R_TEST_AMBIENT_LIBRARY'), winslash='/');", + "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', 'attach_sess.R'));", + "options(repos=c(CRAN=repo), pkgType='source');", + "consent <- file.path(root, 'consent'); dir.create(consent, showWarnings=FALSE);", + # Manual attach must use its supplied minimum rather than an inherited terminal value. + "Sys.setenv(JGD_SOCKET='fixture-socket', VSCODE_R_JGD_MINIMUM_VERSION='9.9.9');", + "connect_seen <- new.env();", + "device_log <- file.path(root, paste0(mode, '-device-log'));", + "Sys.setenv(JGD_TEST_DEVICE_LOG=device_log);", + "vscode_r_prepare_sess <- function(...) { ns <- new.env(); ns$connect <- function(allow_jgd=TRUE, ...) {", + " connect_seen$allow_jgd <- allow_jgd;", + " if (mode == 'socket-empty') Sys.setenv(JGD_SOCKET='fixture-socket');", + " connect_seen$socket <- Sys.getenv('JGD_SOCKET', unset='');", + " connect_seen$loaded <- requireNamespace('jgd', quietly=TRUE);", + " hooks <- new.env(parent=globalenv());", + " sys.source(file.path(getwd(), 'sess', 'R', 'hooks.R'), hooks);", + " has_jgd <- connect_seen$allow_jgd && nzchar(connect_seen$socket) && connect_seen$loaded;", + " connect_seen$device <- hooks$.select_plot_backend('jgd', FALSE, has_jgd);", + " if (connect_seen$device == 'jgd') jgd::jgd(); TRUE }; ns };", + "if (mode == 'old') {", + " old <- file.path(root, 'old-library'); dir.create(old);", + " utils::install.packages(file.path(root, 'jgd-0.0.1'), repos=NULL, type='source', lib=old, quiet=TRUE);", + " .libPaths(c(old, .Library)); loadNamespace('jgd', lib.loc=old);", + "}", + "if (mode == 'offline') options(repos=c(CRAN=file.path(root, 'missing-repo')));", + "if (mode == 'socket-empty') Sys.unsetenv('JGD_SOCKET');", + "tool_library_helper <- if (mode %in% c('parallel', 'no-prompt', 'no-prompt-compatible'))", + " file.path(root, 'instrumented-tool-library.R') else", + " file.path(getwd(), 'R', 'tool-library.R');", + "if (mode == 'library-helper-missing') tool_library_helper <- file.path(root, 'missing-tool-library.R');", + "if (mode == 'no-prompt-compatible') {", + " ordinary <- file.path(root, 'ordinary-library'); dir.create(ordinary);", + " utils::install.packages('jgd', repos=repo, type='source', lib=ordinary, quiet=TRUE);", + " .libPaths(c(ordinary, .libPaths()));", + "}", + "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'),", + " 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'),", + " 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'));", + "stopifnot(identical(getOption('timeout'), timeout_before), identical(.libPaths(), libraries_before));", + "writeLines(c(connect_seen$socket, as.character(connect_seen$loaded), connect_seen$device,", + " as.character(connect_seen$allow_jgd)), result);" + ), runner) + + r_binary <- file.path(R.home("bin"), "R") + run_child <- function(mode, result) { + child_args <- c("--vanilla", "--slave", paste0("--file=", runner), "--args", + mode, root, repo, result) + output <- system2_with_child_environment(isolated_child_env(mode), r_binary, + shQuote(child_args), stdout = TRUE, stderr = TRUE) + status <- attr(output, "status") + if (!is.null(status) && status != 0L) stop(paste(output, collapse = "\n")) + readLines(result, warn = FALSE) + } + + wait_requests <- function(directory, count) { + deadline <- Sys.time() + 15 + repeat { + paths <- list.files(directory, pattern = "\\.request$", full.names = TRUE) + if (length(paths) >= count) return(paths) + if (Sys.time() >= deadline) { + log_paths <- list.files(root, pattern = "(out|err)$", recursive = TRUE, + full.names = TRUE) + logs <- unlist(lapply(log_paths, function(path) { + c(path, readLines(path, warn = FALSE)) + })) + stop("Timed out waiting for jgd consent request in ", directory, ": ", + paste(logs, collapse = "\n")) + } + Sys.sleep(0.01) + } + } + + answer_requests <- function(paths, answer) { + for (path in paths) { + id <- sub("\\.request$", "", basename(path)) + temporary <- file.path(dirname(path), paste0(id, ".response.tmp")) + writeLines(answer, temporary, useBytes = TRUE) + stopifnot(file.rename(temporary, file.path(dirname(path), paste0(id, ".response")))) + } + } + + dir.create(file.path(root, "consent"), showWarnings = FALSE) + real_runner <- file.path(root, "real-connect.R") + writeLines(c( + "args <- commandArgs(TRUE); mode <- args[[1L]]; root <- args[[2L]]; repo <- args[[3L]];", + "result <- args[[4L]]; sess_library <- args[[5L]];", + "ambient <- normalizePath(Sys.getenv('VSCODE_R_TEST_AMBIENT_LIBRARY'), winslash='/');", + "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', 'attach_sess.R'));", + "options(repos=c(CRAN=repo), pkgType='source', sess.quiet=TRUE);", + "sess_ns <- loadNamespace('sess', lib.loc=sess_library);", + "endpoint <- if (.Platform$OS.type == 'windows')", + " paste0('\\\\\\\\?\\\\pipe\\\\', basename(tempfile('sess-jgd-'))) else tempfile(fileext='.sock');", + "server <- processx::conn_create_unix_socket(endpoint);", + "discovery <- file.path(root, paste0(mode, '-discovery.json'));", + "writeLines(jsonlite::toJSON(list(version=1L, endpoint=endpoint, jgdSocket='fixture-jgd-socket'),", + " auto_unbox=TRUE), discovery);", + "Sys.setenv(SESS_DISCOVERY_FILE=discovery, JGD_TEST_DEVICE_LOG=file.path(root, paste0(mode, '-real-device')));", + "Sys.unsetenv('JGD_SOCKET');", + "old_library <- file.path(root, paste0(mode, '-old-library')); dir.create(old_library);", + "if (mode %in% c('real-loaded-old', 'real-user-old'))", + " utils::install.packages(file.path(root, 'jgd-0.0.1'), repos=NULL,", + " type='source', lib=old_library, quiet=TRUE);", + "if (mode == 'real-loaded-old') loadNamespace('jgd', lib.loc=old_library);", + "if (mode == 'real-user-old') .libPaths(c(old_library, .libPaths()));", + "connect_env <- new.env(parent=sess_ns); connect_env$.sess_env <- sess_ns$.sess_env;", + "connect_env$connect <- getExportedValue('sess', 'connect');", + "environment(connect_env$connect) <- connect_env;", + "connect_env$.schedule_reconnect <- sess_ns$.schedule_reconnect;", + "environment(connect_env$.schedule_reconnect) <- connect_env;", + "connect_env$.reconnect_with_startup <- sess_ns$.reconnect_with_startup;", + "environment(connect_env$.reconnect_with_startup) <- connect_env;", + "connect_env$poll_connection <- function(...) {", + " processx::poll(list(server), 1000L); processx::conn_accept_unix_socket(server);", + " processx::poll(list(server), 1000L); message <- processx::conn_read_chars(server);", + " stopifnot(grepl('\\\"method\\\":\\\"attach\\\"', message)); invisible(NULL)", + "};", + "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_root=file.path(root, paste0(mode, '-tools')),", + " tool_consent_directory=file.path(root, 'consent'),", + " tool_library_helper=file.path(getwd(), 'R', 'tool-library.R'),", + " tool_consent_helper=file.path(getwd(), 'R', 'tool-consent.R'),", + " can_prompt=identical(mode, 'real-approved'), minimum_version='0.1.0');", + "allow <- sess_ns$.sess_env$reconnect$options$allow_jgd;", + "socket <- Sys.getenv('JGD_SOCKET', unset='');", + "loaded <- 'jgd' %in% loadedNamespaces();", + "getOption('device')();", + "settings <- sess_ns$.sess_env$reconnect;", + "sess_ns$.transport_disconnect(silent=TRUE); close(server);", + "next_endpoint <- if (.Platform$OS.type == 'windows')", + " paste0('\\\\\\\\?\\\\pipe\\\\', basename(tempfile('sess-jgd-next-'))) else tempfile(fileext='.sock');", + "server <- processx::conn_create_unix_socket(next_endpoint);", + "writeLines(jsonlite::toJSON(list(version=1L, endpoint=next_endpoint,", + " jgdSocket='fixture-jgd-socket-next'), auto_unbox=TRUE), discovery);", + "scheduled <- NULL;", + "connect_env$.schedule_reconnect(settings, schedule=function(callback, delay) { scheduled <<- callback });", + "stopifnot(is.function(scheduled)); scheduled();", + "next_allow <- sess_ns$.sess_env$reconnect$options$allow_jgd;", + "next_socket <- Sys.getenv('JGD_SOCKET', unset='');", + "next_loaded <- 'jgd' %in% loadedNamespaces();", + "getOption('device')();", + "writeLines(c(as.character(answer), as.character(allow), socket, as.character(loaded),", + " as.character(next_allow), next_socket, as.character(next_loaded)), result);", + "sess_ns$.transport_disconnect(silent=TRUE); close(server)" + ), real_runner) + + run_real_child <- function(mode, result, wait = TRUE) { + args <- c("--vanilla", "--slave", paste0("--file=", real_runner), "--args", + mode, root, repo, result, sess_library) + system2_with_child_environment( + isolated_child_env(paste0("real-", mode), + paste(sess_library, dependency_library, sep = .Platform$path.sep)), + r_binary, shQuote(args), wait = wait, + stdout = if (wait) TRUE else file.path(root, paste0(mode, "-out")), + stderr = if (wait) TRUE else file.path(root, paste0(mode, "-err")) + ) + } + + for (mode in c("real-loaded-old", "real-user-old")) { + result <- file.path(root, paste0(mode, "-result")) + output <- run_real_child(mode, result) + status <- attr(output, "status") + if (!is.null(status) && status != 0L) stop(paste(output, collapse = "\n")) + values <- readLines(result, warn = FALSE) + stopifnot(identical(values[c(1L, 2L, 3L)], c("TRUE", "FALSE", ""))) + stopifnot(identical(values[[4L]], as.character(identical(mode, "real-loaded-old")))) + stopifnot(identical(values[c(5L, 6L)], c("FALSE", ""))) + stopifnot(identical(values[[7L]], values[[4L]])) + stopifnot(!file.exists(file.path(root, paste0(mode, "-real-device")))) + } + + approved_real_result <- file.path(root, "real-approved-result") + real_process <- run_real_child("real-approved", approved_real_result, wait = FALSE) + real_request <- wait_requests(file.path(root, "consent"), 1L) + answer_requests(real_request, "approve") + deadline <- Sys.time() + 30 + while (!file.exists(approved_real_result) && Sys.time() < deadline) Sys.sleep(0.01) + if (!file.exists(approved_real_result)) { + logs <- unlist(lapply(c("real-approved-out", "real-approved-err"), function(name) { + path <- file.path(root, name) + if (file.exists(path)) readLines(path, warn = FALSE) else character() + })) + stop("Actual sess jgd connection did not finish: ", paste(logs, collapse = "\n")) + } + stopifnot(identical(readLines(approved_real_result, warn = FALSE), + c("TRUE", "TRUE", "fixture-jgd-socket", "TRUE", "TRUE", + "fixture-jgd-socket-next", "TRUE"))) + stopifnot(identical(readLines(file.path(root, "real-approved-real-device"), warn = FALSE), + c("jgd", "jgd"))) + + approved_root <- file.path(root, "approved") + dir.create(approved_root) + approved_result <- file.path(root, "approved-result") + approved_args <- c("--vanilla", "--slave", paste0("--file=", runner), "--args", + "approved", approved_root, repo, approved_result) + approved_process <- system2_with_child_environment( + isolated_child_env("approved"), r_binary, shQuote(approved_args), wait = FALSE, + stdout = file.path(root, "approved-out"), stderr = file.path(root, "approved-err") + ) + request <- wait_requests(file.path(approved_root, "consent"), 1L) + answer_requests(request, "approve") + deadline <- Sys.time() + 30 + while (!file.exists(approved_result) && Sys.time() < deadline) Sys.sleep(0.01) + if (!file.exists(approved_result)) { + logs <- unlist(lapply(c("approved-out", "approved-err"), function(name) { + path <- file.path(root, name) + if (file.exists(path)) readLines(path, warn = FALSE) else character() + })) + stop("Approved jgd attach did not finish: ", paste(logs, collapse = "\n")) + } + stopifnot(identical(readLines(approved_result), c("fixture-socket", "TRUE", "jgd", "TRUE"))) + stopifnot(identical(readLines(file.path(approved_root, "approved-device-log")), "jgd")) + + declined_root <- file.path(root, "declined") + dir.create(declined_root) + declined_result <- file.path(root, "declined-result") + declined_args <- c("--vanilla", "--slave", paste0("--file=", runner), "--args", + "declined", declined_root, repo, declined_result) + declined_process <- system2_with_child_environment( + isolated_child_env("declined"), r_binary, shQuote(declined_args), wait = FALSE, + stdout = file.path(root, "declined-out"), stderr = file.path(root, "declined-err") + ) + request <- wait_requests(file.path(declined_root, "consent"), 1L) + answer_requests(request, "decline") + deadline <- Sys.time() + 30 + while (!file.exists(declined_result) && Sys.time() < deadline) Sys.sleep(0.01) + if (!file.exists(declined_result)) stop("Declined jgd attach did not finish.") + stopifnot(identical(readLines(declined_result), c("fixture-socket", "FALSE", "standard", "FALSE"))) + + for (mode in c("old", "offline", "helper-missing", "library-helper-missing", "socket-empty")) { + result <- file.path(root, paste0(mode, "-result")) + observed <- run_child(mode, result) + expected_socket <- "fixture-socket" + expected <- c(expected_socket, if (mode == "old") "TRUE" else "FALSE", + "standard", "FALSE") + if (!identical(observed, expected)) { + stop(mode, " expected ", paste(expected, collapse = " | "), " observed ", + paste(observed, collapse = " | ")) + } + stopifnot(length(list.files(file.path(root, mode, "consent"), pattern = "\\.request$")) == 0L) + } + + for (mode in c("no-prompt", "no-prompt-compatible")) { + plan_marker <- file.path(root, paste0(mode, "-plan-called")) + helper_lines <- readLines(file.path(getwd(), "R", "tool-library.R"), warn = FALSE) + helper_lines <- c(helper_lines, + paste0("tool_library_plan <- function(...) { file.create(", + r_literal(plan_marker), "); stop('unexpected metadata resolution') }")) + writeLines(helper_lines, file.path(root, "instrumented-tool-library.R")) + result <- file.path(root, paste0(mode, "-result")) + observed <- run_child(mode, result) + if (mode == "no-prompt") { + stopifnot(identical(observed, c("fixture-socket", "FALSE", "standard", "FALSE"))) + } else { + stopifnot(identical(observed, c("fixture-socket", "TRUE", "jgd", "TRUE"))) + stopifnot(identical(readLines(file.path(root, paste0(mode, "-device-log"))), "jgd")) + } + stopifnot(length(list.files(file.path(root, "consent"), pattern = "\\.request$")) == 0L) + stopifnot(!file.exists(plan_marker)) + } + + parallel_root <- file.path(root, "parallel") + dir.create(parallel_root) + base_helper <- readLines(file.path(getwd(), "R", "tool-library.R"), warn = FALSE) + instrumented_helper <- file.path(parallel_root, "instrumented-tool-library.R") + writeLines(c(base_helper, + ".tool_verify_child_base <- .tool_verify_child", + paste0(".tool_verify_child <- function(...) { cat('verify\\n', file=", + r_literal(file.path(parallel_root, "verify-log")), ", append=TRUE);", + " .tool_verify_child_base(...) }")), instrumented_helper) + parallel_consent <- file.path(parallel_root, "consent") + dir.create(parallel_consent) + parallel_result <- file.path(parallel_root, c("one", "two")) + parallel_children <- lapply(seq_along(parallel_result), function(index) { + child_args <- c("--vanilla", "--slave", paste0("--file=", runner), "--args", + "parallel", parallel_root, repo, parallel_result[[index]]) + system2_with_child_environment( + isolated_child_env(paste0("parallel-", index)), r_binary, shQuote(child_args), wait = FALSE, + stdout = file.path(parallel_root, paste0(index, "-out")), + stderr = file.path(parallel_root, paste0(index, "-err")) + ) + }) + requests <- wait_requests(parallel_consent, 2L) + answer_requests(requests, "approve") + deadline <- Sys.time() + 30 + while (!all(file.exists(parallel_result)) && Sys.time() < deadline) Sys.sleep(0.01) + if (!all(file.exists(parallel_result))) stop("Concurrent jgd preparation did not finish.") + stopifnot(all(vapply(parallel_result, function(path) { + identical(readLines(path, warn = FALSE), c("fixture-socket", "TRUE", "jgd", "TRUE")) + }, FALSE))) + stopifnot(length(readLines(file.path(parallel_root, "verify-log"), warn = FALSE)) == 1L) + + # Exercise the startup profile consumer separately: a declined optional tool + # request must still let sess connect and publish a ready startup status. + profile_root <- file.path(root, "profile-declined") + dir.create(profile_root) + profile_consent <- file.path(profile_root, "consent") + dir.create(profile_consent) + profile_log <- file.path(profile_root, "connect-log") + stub_attach <- file.path(profile_root, "attach-helper.R") + source_helper <- file.path(profile_root, "source-helper.R") + installer_helper <- file.path(profile_root, "installer-helper.R") + empty_user_profile <- file.path(profile_root, "empty-user-profile.R") + file.create(source_helper, installer_helper, empty_user_profile) + writeLines(c( + "source(file.path(getwd(), 'R', 'attach_sess.R'))", + "vscode_r_prepare_sess <- function(...) { ns <- new.env(); ns$connect <- function(allow_jgd=TRUE, ...) {", + paste0(" writeLines(c(Sys.getenv('JGD_SOCKET', unset=''), ", + "as.character(requireNamespace('jgd', quietly=TRUE)), as.character(allow_jgd)), ", + "", r_literal(profile_log), ")"), + " TRUE", + "}; ns }" + ), stub_attach) + startup_file <- file.path(profile_root, "startup.status") + startup_profile <- file.path(profile_root, "run-profile.R") + writeLines(c( + paste0("Sys.setenv(VSCODE_R_SESS_PKG_PATH=", r_literal(package), ","), + paste0(" VSCODE_R_SESS_SOURCE_HELPER=", r_literal(source_helper), ","), + paste0(" VSCODE_R_SESS_ATTACH_HELPER=", r_literal(stub_attach), ","), + paste0(" VSCODE_R_SESS_INSTALLER_HELPER=", r_literal(installer_helper), ","), + paste0(" VSCODE_R_SESS_ROOT=", r_literal(profile_root), ","), + paste0(" VSCODE_R_SESS_CONSENT_DIRECTORY=", r_literal(profile_consent), ","), + paste0(" VSCODE_R_SESS_STARTUP_FILE=", r_literal(startup_file), ","), + " 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_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")), ","), + paste0(" VSCODE_R_TOOL_CONSENT_HELPER=", r_literal(file.path(getwd(), "R", "tool-consent.R")), ","), + " 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')", + paste0("options(repos=c(CRAN=", r_literal(repo), "), pkgType='source')"), + "source(file.path(getwd(), 'R', 'profile.R'))" + ), startup_profile) + profile_result <- file.path(profile_root, "result") + profile_process <- system2_with_child_environment( + isolated_child_env("profile"), r_binary, + shQuote(c("--vanilla", "--slave", paste0("--file=", startup_profile))), + wait = FALSE, stdout = file.path(profile_root, "stdout"), + stderr = file.path(profile_root, "stderr") + ) + request <- wait_requests(profile_consent, 1L) + answer_requests(request, "decline") + deadline <- Sys.time() + 30 + while (!file.exists(profile_log) && Sys.time() < deadline) Sys.sleep(0.01) + if (!file.exists(profile_log)) stop("Declined startup profile did not connect sess.") + stopifnot(identical(readLines(profile_log), c("fixture-socket", "FALSE", "FALSE"))) + startup_status <- readLines(startup_file, warn = FALSE) + deadline <- Sys.time() + 10 + while (length(startup_status) == 6L && startup_status[[6L]] == "pending" && + Sys.time() < deadline) { + Sys.sleep(0.01) + startup_status <- readLines(startup_file, warn = FALSE) + } + if (length(startup_status) != 6L || startup_status[[6L]] != "ready") { + logs <- unlist(lapply(c("stdout", "stderr"), function(name) { + path <- file.path(profile_root, name) + if (file.exists(path)) readLines(path, warn = FALSE) else character() + })) + stop("Unexpected profile startup status: ", paste(startup_status, collapse = " | "), + " logs: ", paste(logs, collapse = "\n")) + } + + cat("jgd preparation integration tests passed\n") +}) diff --git a/R/tests/tool-consent.R b/R/tests/tool-consent.R new file mode 100644 index 000000000..d2472fe53 --- /dev/null +++ b/R/tests/tool-consent.R @@ -0,0 +1,142 @@ +# Base-R tests for the shared, plan-bound tool installation consent file. +source("R/tool-library.R") +source("R/tool-consent.R") + +local({ + root <- tempfile("tool-consent-") + dir.create(root) + root <- normalizePath(root, winslash = "/", mustWork = TRUE) + on.exit(unlink(root, recursive = TRUE), add = TRUE) + library <- tool_library_path(root) + repository <- "https://example.invalid/src/contrib" + available <- matrix( + c("jgd", "0.1.0", "", "yes", repository), + nrow = 1L, + dimnames = list("jgd", c("Package", "Version", "Imports", "NeedsCompilation", "Repository")) + ) + plan <- structure( + list( + package = "jgd", + minimum_version = "0.1.0", + version = "0.1.0", + repository = repository, + package_type = "source", + source_build = TRUE, + repositories = "https://example.invalid", + available = available, + root = root, + library = library + ), + class = "vscode_r_tool_install_plan" + ) + + lines <- .tool_consent_lines(plan, strrep("a", 32L), "jgd") + stopifnot(length(lines) == 11L) + stopifnot(identical(lines[c(1L, 2L, 4L, 5L)], + c("vscode-r-package-consent-v1", strrep("a", 32L), "jgd", "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"), + error = identity), "error")) + + directory <- file.path(root, "consent") + dir.create(directory) + approval <- .tool_publish_consent_request(plan, strrep("c", 32L), "auto", directory) + stopifnot(identical(readLines(approval$request_path), + .tool_consent_lines(plan, strrep("c", 32L), "auto"))) + stopifnot(inherits(tryCatch(.tool_publish_consent_request(plan, strrep("c", 32L), + "auto", 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_lines <- changed$lines + changed_lines[8L] <- "0.2.0" + writeLines(changed_lines, changed$request_path) + writeLines("approve", changed$response_path) + 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) + 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(length(list.files(directory)) == 0L) + + unavailable <- .tool_publish_consent_request(plan, strrep("f", 32L), "auto", 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) + 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)) + + prompt_calls <- 0L + install_calls <- 0L + permission <- FALSE + ready <- FALSE + prepare_environment <- new.env(parent = environment(tool_library_prepare)) + prepare_environment$tool_library_check <- function(...) { + list(package = "jgd", version = if (ready) "1.0.0" else NULL, + source = if (ready) "managed" else "install", + 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, + timeout_seconds) { + prompt_calls <<- prompt_calls + 1L + stopifnot(identical(received, plan), backend %in% c("jgd", "auto"), + identical(directory, "consent")) + permission + } + prepare_environment$tool_library_install <- function(package, minimum_version, root, + repos, timeout_seconds, plan) { + install_calls <<- install_calls + 1L + stopifnot(identical(plan, received_plan), identical(repos, plan$repositories)) + list(package = package, version = plan$version, source = "managed", + install_required = FALSE, library = plan$library) + } + 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(prompt_calls == 1L, install_calls == 0L) + permission <- FALSE + stopifnot(is.null(prepare("jgd", "0.1.0", root, "consent", "jgd", repos = character()))) + stopifnot(prompt_calls == 2L, install_calls == 0L) + permission <- TRUE + prepare("jgd", "0.1.0", root, "consent", "auto", repos = character()) + stopifnot(prompt_calls == 3L, install_calls == 1L) + ready <- TRUE + prepare("jgd", "0.1.0", root, "consent", "auto", repos = character()) + stopifnot(prompt_calls == 3L, install_calls == 1L) + + # Exercise prepare through its real file request and response path. + ready <- FALSE + 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, + directory, timeout_seconds) { + request <- .tool_publish_consent_request(received, strrep("1", 32L), + backend, 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", + repos = character()) + stopifnot(identical(prepared$version, plan$version), + prompt_calls == 3L, install_calls == 2L, + length(list.files(consent_directory)) == 0L) + + cat("managed tool consent tests passed\n") +}) diff --git a/R/tests/tool-library.R b/R/tests/tool-library.R new file mode 100644 index 000000000..c720e4816 --- /dev/null +++ b/R/tests/tool-library.R @@ -0,0 +1,203 @@ +# Base-R coverage for the managed tool library. All packages are local fixtures. +source("R/tool-library.R") +source("R/tool-consent.R") + +local({ + root <- tempfile("tool-library-") + dir.create(root) + root <- normalizePath(root, winslash = "/", mustWork = TRUE) + on.exit(unlink(root, recursive = TRUE), add = TRUE) + original_paths <- .libPaths() + on.exit(.libPaths(original_paths), add = TRUE) + + write_package <- function(package, version, imports = NULL) { + directory <- file.path(root, "source", package) + dir.create(file.path(directory, "R"), recursive = TRUE, showWarnings = FALSE) + fields <- c(paste0("Package: ", package), paste0("Version: ", version), + paste0("Title: ", package, " fixture"), + paste0("Description: Local test package for ", package, "."), + "License: MIT", "Author: Test Author", + "Maintainer: Test Author ", "Encoding: UTF-8") + if (!is.null(imports)) fields <- c(fields, paste0("Imports: ", imports)) + writeLines(fields, file.path(directory, "DESCRIPTION")) + writeLines("fixture_value <- function() 'managed'", file.path(directory, "R", "api.R")) + writeLines("export(fixture_value)", file.path(directory, "NAMESPACE")) + archive <- file.path(root, "repo", "src", "contrib") + dir.create(archive, recursive = TRUE, showWarnings = FALSE) + unlink(list.files(archive, pattern = paste0("^", package, "_.*\\.tar\\.gz$"), full.names = TRUE)) + previous <- setwd(dirname(directory)) + on.exit(setwd(previous), add = TRUE) + result <- system2(file.path(R.home("bin"), "R"), + c("CMD", "build", "--no-build-vignettes", shQuote(basename(directory))), + stdout = TRUE, stderr = TRUE) + status <- attr(result, "status") + if (!is.null(status) && status != 0L) stop(paste(result, collapse = "\n")) + built <- list.files(".", pattern = "\\.tar\\.gz$", full.names = TRUE) + stopifnot(length(built) == 1L) + stopifnot(file.copy(built, archive)) + unlink(built) + setwd(previous) + invisible(directory) + } + + # Build a package with an external dependency and verify that it is rejected. + write_package("jgddep", "1.0.0") + write_package("jgd", "1.3.0", imports = "jgddep") + tools::write_PACKAGES(file.path(root, "repo", "src", "contrib"), type = "source") + repo <- paste0("file:///", sub("^/", "", normalizePath(file.path(root, "repo"), winslash = "/"))) + previous_pkg_type <- getOption("pkgType") + options(pkgType = "source") + on.exit(options(pkgType = previous_pkg_type), add = TRUE) + root_path <- file.path(root, "vscode-R") + managed <- tool_library_path(root_path) + stopifnot(identical(managed, + file.path(root_path, "packages", R.version$platform, + paste(R.version$major, + strsplit(R.version$minor, ".", fixed = TRUE)[[1L]][1L], + sep = "."), "library"))) + + # DESCRIPTION can look compatible even when the installed package cannot load. + broken_root <- file.path(root, "broken") + broken_library <- tool_library_path(broken_root) + broken_package <- file.path(broken_library, "jgd") + dir.create(broken_package, recursive = TRUE) + writeLines(c("Package: jgd", "Version: 1.0.0"), file.path(broken_package, "DESCRIPTION")) + original_failure <- tryCatch(loadNamespace("jgd", lib.loc = broken_library), error = identity) + failure <- tryCatch(tool_library_load("jgd", "1.0.0", broken_root), error = identity) + stopifnot(inherits(original_failure, "error"), inherits(failure, "error"), + grepl(broken_package, conditionMessage(failure), fixed = TRUE), + grepl("Remove ", conditionMessage(failure), fixed = TRUE), + grepl("installation on the next startup", conditionMessage(failure), fixed = TRUE), + grepl(conditionMessage(original_failure), conditionMessage(failure), fixed = TRUE), + file.exists(file.path(broken_package, "DESCRIPTION"))) + .libPaths(c(broken_library, original_paths)) + user_failure <- tryCatch(tool_library_load("jgd", "1.0.0", broken_root), error = identity) + stopifnot(identical(conditionMessage(user_failure), conditionMessage(original_failure))) + .libPaths(original_paths) + + project_library <- file.path(root, "project library") + package_manager_library <- file.path(root, "package manager library") + user_library <- file.path(root, "user library") + dir.create(project_library) + dir.create(package_manager_library) + dir.create(user_library) + .libPaths(c(project_library, package_manager_library, user_library, original_paths)) + before <- .libPaths() + snapshot <- function(path) { + files <- sort(list.files(path, recursive = TRUE, all.files = TRUE, no.. = TRUE)) + list(files = files, contents = unname(tools::md5sum(file.path(path, files)))) + } + ordinary_libraries <- c(project_library, package_manager_library, user_library) + for (library in ordinary_libraries) writeLines("Keep this file.", file.path(library, "sentinel")) + ordinary_before <- lapply(ordinary_libraries, snapshot) + 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")) + for (invalid in c("", "1.foo", "https://example.com/pkg")) { + stopifnot(inherits(tryCatch(tool_library_check("jgd", invalid, root_path), + error = identity), "error")) + } + for (field in c("Depends", "LinkingTo")) { + description <- matrix("jgddep", nrow = 1L, dimnames = list(NULL, field)) + stopifnot(!.tool_dependencies_allowed(description)) + } + stopifnot(.tool_dependencies_allowed(matrix("R (>= 4.0), stats", nrow = 1L, + dimnames = list(NULL, "Depends")))) + rejection <- tryCatch(tool_library_install("jgd", "1.3.0", root_path, + repos = c(CRAN = repo)), error = identity) + stopifnot(inherits(rejection, "error"), grepl("non-base package dependencies", conditionMessage(rejection))) + stopifnot(!dir.exists(file.path(managed, "jgd"))) + + # Replace it with a dependency-free package, then install without network access. + write_package("jgd", "1.2.0") + tools::write_PACKAGES(file.path(root, "repo", "src", "contrib"), type = "source") + # The same verification failure must distinguish first installation from an update. + failing_install <- tool_library_install + environment(failing_install) <- list2env(list( + .tool_verify_child = function(...) stop("fixture verification failure") + ), parent = environment(tool_library_install)) + for (updating in c(FALSE, TRUE)) { + failure_root <- file.path(root, if (updating) "failed update" else "failed installation") + if (updating) { + existing <- file.path(tool_library_path(failure_root), "jgd") + dir.create(existing, recursive = TRUE) + writeLines(c("Package: jgd", "Version: 0.1.0"), file.path(existing, "DESCRIPTION")) + } + failure <- tryCatch(failing_install("jgd", "1.0.0", failure_root, repos = c(CRAN = repo)), + error = identity) + expected <- paste0("Could not ", if (updating) "update" else "install", " the managed jgd package. ", + if (updating) "Close other R sessions using it and try again. " else "", + "fixture verification failure") + stopifnot(inherits(failure, "error"), identical(conditionMessage(failure), expected)) + } + too_old <- tryCatch(tool_library_install("jgd", "1.3.0", root_path, repos = c(CRAN = repo)), + error = identity) + 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") + 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"))) + 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"))) + frozen_install <- tool_library_install("jgd", "1.0.0", root_path, + repos = character(), plan = frozen_plan) + stopifnot(identical(frozen_install$version, "1.2.0")) + stopifnot(inherits(tryCatch(.tool_validate_install_plan(frozen_plan, "jgd", "2.0.0", + root_path), error = identity), + "error")) + result <- tool_library_install("jgd", "1.0.0", root_path, repos = c(CRAN = repo)) + stopifnot(identical(result$source, "managed"), identical(result$version, "1.2.0")) + stopifnot(identical(.libPaths(), before)) + stopifnot(identical(tool_library_check("jgd", "1.0.0", root_path)$source, "managed")) + stopifnot(identical(lapply(ordinary_libraries, snapshot), ordinary_before)) + no_update <- tool_library_install("jgd", "1.0.0", root_path, repos = character()) + stopifnot(identical(no_update$version, "1.2.0")) + write_package("jgd", "1.4.0") + tools::write_PACKAGES(file.path(root, "repo", "src", "contrib"), type = "source") + upgrade <- tool_library_install("jgd", "1.3.0", root_path, repos = c(CRAN = repo)) + stopifnot(identical(upgrade$version, "1.4.0")) + stopifnot(identical(lapply(ordinary_libraries, snapshot), ordinary_before), identical(.libPaths(), before)) + + lock_path <- file.path(dirname(managed), ".setup-lock") + stopifnot(isTRUE(.tool_with_library_lock(managed, dir.exists(lock_path)))) + stopifnot(!dir.exists(lock_path)) + owner_error <- tryCatch(.tool_with_library_lock(managed, stop("fixture failure")), error = identity) + stopifnot(inherits(owner_error, "error"), !dir.exists(lock_path)) + dir.create(lock_path) + lock_timeout <- tryCatch(.tool_with_library_lock(managed, TRUE, timeout_seconds = 0), + error = identity) + stopifnot(inherits(lock_timeout, "error"), dir.exists(lock_path)) + unlink(lock_path, recursive = TRUE) + + # A compatible ordinary library copy wins over the managed one. + utils::install.packages(file.path(root, "source", "jgd"), repos = NULL, + type = "source", lib = user_library, quiet = TRUE) + .libPaths(c(user_library, .Library)) + user_paths <- .libPaths() + user_choice <- tool_library_check("jgd", "1.0.0", root_path) + stopifnot(identical(user_choice$source, "user")) + user_namespace <- tool_library_load("jgd", "1.0.0", root_path) + stopifnot(identical(normalizePath(getNamespaceInfo(user_namespace, "path"), winslash = "/"), + normalizePath(file.path(user_library, "jgd"), winslash = "/"))) + stopifnot(identical(.libPaths(), user_paths)) + unloadNamespace("jgd") + .libPaths(setdiff(before, user_library)) + managed_paths <- .libPaths() + namespace <- tool_library_load("jgd", "1.0.0", root_path) + stopifnot(identical(getExportedValue("jgd", "fixture_value")(), "managed")) + stopifnot(identical(normalizePath(getNamespaceInfo(namespace, "path"), winslash = "/"), + normalizePath(file.path(managed, "jgd"), winslash = "/"))) + stopifnot(is.null(tool_library_load("jgd", "2.0.0", root_path))) + child_rejection <- tryCatch(.tool_verify_child("jgd", "2.0.0", managed), error = identity) + stopifnot(inherits(child_rejection, "error")) + stopifnot(identical(.libPaths(), managed_paths)) + + cat("managed tool library tests passed\n") +}) diff --git a/R/tool-consent.R b/R/tool-consent.R new file mode 100644 index 000000000..6c2e33a4d --- /dev/null +++ b/R/tool-consent.R @@ -0,0 +1,139 @@ +# File-based consent for optional packages managed by vscode-R. +.tool_consent_max_bytes <- 16384L +.tool_consent_request_version <- "vscode-r-package-consent-v1" + +.tool_consent_lines <- function(plan, id, backend) { + 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"))) { + stop("Invalid managed tool consent request.", call. = FALSE) + } + runtime_minor <- strsplit(R.version$minor, ".", fixed = TRUE)[[1L]][1L] + runtime <- paste(R.version$platform, + paste(R.version$major, runtime_minor, sep = "."), sep = "|") + package_fields <- c(plan$package, plan$minimum_version, plan$version, + plan$repository, plan$package_type, + if (isTRUE(plan$source_build)) "yes" else "no") + if (anyNA(package_fields) || any(!nzchar(package_fields)) || + any(grepl("[\r\n]", package_fields))) { + 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) + 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) + } + lines +} + +.tool_publish_consent_request <- function(plan, id, backend, directory) { + if (!(is.character(directory) && length(directory) == 1L && + !is.na(directory) && nzchar(directory))) { + stop("The managed tool consent directory is unavailable.", call. = FALSE) + } + dir.create(directory, recursive = TRUE, showWarnings = FALSE, mode = "0700") + if (!dir.exists(directory) || file.access(directory, 2L) != 0L) { + 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) + 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)) { + stop("The managed tool consent request ID has already been used.", call. = FALSE) + } + temporary_path <- tempfile(pattern = paste0(id, "-"), tmpdir = directory) + on.exit(unlink(temporary_path), add = TRUE) + writeLines(lines, temporary_path, useBytes = TRUE) + if (.Platform$OS.type == "unix") Sys.chmod(temporary_path, "0600") + if (!file.rename(temporary_path, request_path)) { + stop("Could not publish the managed tool consent request.", call. = FALSE) + } + if (.Platform$OS.type == "unix") Sys.chmod(request_path, "0600") + list(id = id, lines = lines, request_path = request_path, + response_path = response_path) +} + +.tool_wait_for_consent <- function(request, timeout_seconds = 180) { + directory <- dirname(request$request_path) + on.exit(unlink(c(request$request_path, request$response_path)), add = TRUE) + if (!(is.numeric(timeout_seconds) && length(timeout_seconds) == 1L && + !is.na(timeout_seconds) && is.finite(timeout_seconds) && timeout_seconds >= 0)) { + stop("timeout_seconds must be a finite non-negative number.", call. = FALSE) + } + deadline <- Sys.time() + timeout_seconds + repeat { + if (!dir.exists(directory) || !file.exists(request$request_path)) return(FALSE) + request_size <- file.info(request$request_path)$size + if (is.na(request_size) || request_size > .tool_consent_max_bytes) return(FALSE) + request_lines <- tryCatch(readLines(request$request_path, warn = FALSE), + error = function(error) character()) + # A grant applies to the exact version and source the user saw. + if (!identical(request_lines, request$lines)) return(FALSE) + if (file.exists(request$response_path)) { + response_size <- file.info(request$response_path)$size + if (is.na(response_size) || response_size > 32L) { + stop("Invalid response to the managed tool consent request.", call. = FALSE) + } + response <- tryCatch(readLines(request$response_path, warn = FALSE), + error = function(error) character()) + if (identical(response, "approve")) return(TRUE) + if (identical(response, "decline")) return(FALSE) + stop("Invalid response to the managed tool consent request.", call. = FALSE) + } + if (Sys.time() >= deadline) return(FALSE) + Sys.sleep(0.1) + } +} + +.tool_request_consent <- function(plan, backend, 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.", + call. = FALSE) + } + repeat { + temporary <- paste0( + basename(tempfile(pattern = "request-", tmpdir = directory)), + basename(tempfile(pattern = "request-", tmpdir = directory)) + ) + id <- gsub("[^A-Za-z0-9_-]", "", + sub("^request-", "", sub("^request-", "", temporary))) + if (!grepl("^[A-Za-z0-9_-]{16,64}$", id)) next + 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)) break + } + request <- .tool_publish_consent_request(plan, id, backend, 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. +tool_library_prepare <- function(package, minimum_version, root, consent_directory, + backend, repos = getOption("repos"), + consent_timeout_seconds = 180, + install_timeout_seconds = 300, + resolution_timeout_seconds = 30) { + available <- tool_library_check(package, minimum_version, root) + if (!available$install_required) return(invisible(available)) + plan <- local({ + previous_timeout <- getOption("timeout") + on.exit(options(timeout = previous_timeout), add = TRUE) + bounded_timeout <- resolution_timeout_seconds + if (is.numeric(previous_timeout) && length(previous_timeout) == 1L && + !is.na(previous_timeout) && is.finite(previous_timeout) && previous_timeout > 0) { + bounded_timeout <- min(previous_timeout, resolution_timeout_seconds) + } + options(timeout = bounded_timeout) + 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, + timeout_seconds = consent_timeout_seconds) + if (!isTRUE(approved)) return(invisible(NULL)) + tool_library_install(package, minimum_version, root, repos = plan$repositories, + timeout_seconds = install_timeout_seconds, plan = plan) +} diff --git a/R/tool-library.R b/R/tool-library.R new file mode 100644 index 000000000..4936aff3d --- /dev/null +++ b/R/tool-library.R @@ -0,0 +1,303 @@ +# 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") + +tool_library_path <- function(root) { + .tool_validate_root(root) + minor <- strsplit(R.version$minor, ".", fixed = TRUE)[[1L]][1L] + version <- paste(R.version$major, minor, sep = ".") + if (!grepl("^[A-Za-z0-9_.-]+$", R.version$platform) || + !grepl("^[0-9]+\\.[0-9]+$", version)) { + stop("Unsupported R platform or version for the managed tool library.") + } + file.path(root, "packages", R.version$platform, version, "library") +} + +.tool_validate_root <- function(root) { + if (!(is.character(root) && length(root) == 1L && !is.na(root) && nzchar(root))) { + stop("A non-empty tool library root path is required.", call. = FALSE) + } + invisible(TRUE) +} + +.tool_validate_request <- function(package, minimum_version) { + if (!(is.character(package) && length(package) == 1L && !is.na(package) && + package %in% .vscode_r_tool_packages)) { + stop("Package is not allowed in the vscode-R tool library.", call. = FALSE) + } + if (!(is.character(minimum_version) && length(minimum_version) == 1L && + !is.na(minimum_version) && + !inherits(tryCatch(package_version(minimum_version), + error = identity), "error"))) { + stop("A valid minimum package version is required.", call. = FALSE) + } + invisible(TRUE) +} + +.tool_description <- function(path) { + if (!file.exists(file.path(path, "DESCRIPTION"))) return(NULL) + tryCatch(read.dcf(file.path(path, "DESCRIPTION")), error = function(e) NULL) +} + +.tool_version_at <- function(package, library) { + description <- .tool_description(file.path(library, package)) + if (is.null(description) || !"Version" %in% colnames(description)) return(NULL) + unname(description[1L, "Version"]) +} + +.tool_version_ok <- function(version, minimum_version) { + if (is.null(version) || length(version) != 1L || is.na(version)) return(FALSE) + isTRUE(tryCatch(package_version(version) >= package_version(minimum_version), + error = function(e) FALSE)) +} + +.tool_namespace_path <- function(package) { + if (!package %in% loadedNamespaces()) return(NULL) + getNamespaceInfo(asNamespace(package), "path") +} + +# Report what would satisfy a request without installing or loading anything. +# Returns package, version, source, install_required, and library fields. `source` +# is loaded, user, managed, install, or loaded-incompatible. +tool_library_check <- function(package, minimum_version, root) { + .tool_validate_request(package, minimum_version) + .tool_validate_root(root) + loaded_path <- .tool_namespace_path(package) + if (!is.null(loaded_path)) { + version <- as.character(getNamespaceVersion(asNamespace(package))) + compatible <- .tool_version_ok(version, minimum_version) + return(list(package = package, version = version, + source = if (compatible) "loaded" else "loaded-incompatible", + install_required = FALSE, library = dirname(loaded_path))) + } + for (library in .libPaths()) { + version <- .tool_version_at(package, library) + if (.tool_version_ok(version, minimum_version)) { + return(list(package = package, version = version, source = "user", + install_required = FALSE, library = library)) + } + } + library <- tool_library_path(root) + version <- .tool_version_at(package, library) + if (.tool_version_ok(version, minimum_version)) { + return(list(package = package, version = version, source = "managed", + install_required = FALSE, library = library)) + } + list(package = package, version = version, source = "install", + install_required = TRUE, library = library) +} + +.tool_dependencies_allowed <- function(description) { + installed <- utils::installed.packages(fields = "Priority") + allowed <- rownames(installed)[!is.na(installed[, "Priority"]) & + installed[, "Priority"] %in% c("base", "recommended")] + for (field in c("Depends", "Imports", "LinkingTo")) { + if (!field %in% colnames(description) || is.na(description[1L, field])) next + entries <- trimws(strsplit(description[1L, field], ",", fixed = TRUE)[[1L]]) + names <- sub("\\s*\\(.*$", "", entries) + names <- setdiff(names[nzchar(names)], "R") + if (length(setdiff(names, allowed))) return(FALSE) + } + TRUE +} + +.tool_with_library_lock <- function(library, code, timeout_seconds = 300) { + if (!(is.numeric(timeout_seconds) && length(timeout_seconds) == 1L && + !is.na(timeout_seconds) && is.finite(timeout_seconds) && timeout_seconds >= 0)) { + stop("timeout_seconds must be a finite non-negative number.") + } + parent <- dirname(library) + dir.create(parent, recursive = TRUE, showWarnings = FALSE) + if (!dir.exists(parent) || file.access(parent, 2L) != 0L) { + stop("The vscode-R managed tool library is not writable.") + } + lock <- file.path(parent, ".setup-lock") + deadline <- Sys.time() + timeout_seconds + repeat { + if (isTRUE(dir.create(lock, showWarnings = FALSE, mode = "0700"))) { + on.exit(unlink(lock, recursive = TRUE, force = TRUE), add = TRUE) + return(force(code)) + } + if (!dir.exists(lock)) { + if (Sys.time() >= deadline) stop("Timed out waiting for the managed tool library lock.") + Sys.sleep(0.1) + next + } + if (Sys.time() >= deadline) { + stop("Timed out waiting for another tool library setup. Its owner may have crashed; remove ", + shQuote(lock), " only after confirming its owner has exited.") + } + Sys.sleep(0.1) + } +} + +.tool_verify_child <- function(package, minimum_version, library) { + code <- paste( + "a <- commandArgs(TRUE); p <- a[1]; minimum <- a[2]; lib <- a[3];", + "ns <- loadNamespace(p, lib.loc=lib);", + "stopifnot(normalizePath(getNamespaceInfo(ns,'path')) == normalizePath(file.path(lib,p)));", + "stopifnot(utils::compareVersion(as.character(utils::packageVersion(p,lib.loc=lib)),minimum) >= 0)" + ) + status <- system2(file.path(R.home("bin"), "R"), + c("--vanilla", "--slave", "-e", shQuote(code), "--args", + shQuote(package), shQuote(minimum_version), shQuote(library))) + if (!identical(as.integer(status), 0L)) { + stop("The installed ", package, " package failed its compatibility/load check.") + } + invisible(TRUE) +} + +# Resolve and validate a single allowed package against the configured R repositories. +# The returned plan captures the repository result and package type for consent. +tool_library_plan <- function(package, minimum_version, root, repos = getOption("repos")) { + .tool_validate_request(package, minimum_version) + .tool_validate_root(root) + initial <- tool_library_check(package, minimum_version, root) + if (!initial$install_required) return(NULL) + if (!is.character(repos)) { + stop("No R package repository is configured for the managed tool library.") + } + repositories <- repos[!is.na(repos) & nzchar(repos) & repos != "@CRAN@"] + if (length(repositories) == 0L) stop("No usable R package repository is configured.") + package_type <- getOption("pkgType") + if (identical(package_type, "binary")) package_type <- .Platform$pkgType + package_types <- if (identical(package_type, "both")) { + binary_type <- if (.Platform$OS.type == "windows") "win.binary" else .Platform$pkgType + c(binary_type, "source") + } else { + package_type + } + candidates <- lapply(package_types, function(type) { + available <- tryCatch(utils::available.packages(repos = repositories, type = type, + fields = "NeedsCompilation"), + error = function(e) matrix(character(), nrow = 0L)) + list(type = type, available = available) + }) + selected <- NULL + for (candidate in candidates) { + available <- candidate$available + if (!package %in% rownames(available)) next + candidate_version <- available[package, "Version"] + if (.tool_version_ok(candidate_version, minimum_version)) { + selected <- candidate + break + } + } + if (is.null(selected)) { + stop("No configured repository version satisfies the requested minimum version.") + } + package_type <- selected$type + available <- selected$available + row <- available[package, , drop = TRUE] + description <- as.data.frame(as.list(row), stringsAsFactors = FALSE) + if (!.tool_dependencies_allowed(description)) { + stop("Refusing to install ", package, + " because it has non-base package dependencies.") + } + selected_repositories <- repositories + repository <- if ("Repository" %in% names(row) && !is.na(row[["Repository"]])) { + unname(row[["Repository"]]) + } else { + unname(repositories[[1L]]) + } + needs_compilation <- if ("NeedsCompilation" %in% names(row)) row[["NeedsCompilation"]] else NA_character_ + source_build <- identical(package_type, "source") && + (is.na(needs_compilation) || !nzchar(needs_compilation) || + identical(tolower(needs_compilation), "yes")) + structure(list( + package = package, + minimum_version = minimum_version, + version = unname(row[["Version"]]), + repository = repository, + package_type = package_type, + source_build = source_build, + repositories = selected_repositories, + available = available, + root = root, + library = initial$library + ), class = "vscode_r_tool_install_plan") +} + +.tool_validate_install_plan <- function(plan, package, minimum_version, root) { + if (!inherits(plan, "vscode_r_tool_install_plan") || + !identical(plan$package, package) || + !identical(plan$minimum_version, minimum_version) || + !identical(plan$root, root)) { + stop("The approved tool installation plan is invalid or does not match the request.", call. = FALSE) + } + invisible(TRUE) +} + +# Updating a shared library while another R process has loaded its package can +# fail (notably on Windows) or damage files. No process-use registry is kept; +# callers should close other R sessions using the package and retry on failure. +# Install only after the caller has obtained consent. A supplied plan uses the +# exact repository metadata and package type shown in the one-time request. +tool_library_install <- function(package, minimum_version, root, + repos = getOption("repos"), timeout_seconds = 300, plan = NULL) { + .tool_validate_request(package, minimum_version) + .tool_validate_root(root) + initial <- tool_library_check(package, minimum_version, root) + if (!initial$install_required) return(invisible(initial)) + if (is.null(plan)) plan <- tool_library_plan(package, minimum_version, root, repos) + if (is.null(plan)) return(invisible(tool_library_check(package, minimum_version, root))) + .tool_validate_install_plan(plan, package, minimum_version, root) + package_type <- plan$package_type + available <- plan$available + library <- plan$library + selected_repositories <- plan$repositories + .tool_with_library_lock(library, { + current <- tool_library_check(package, minimum_version, root) + if (!current$install_required) return(invisible(current)) + updating <- !is.null(current$version) + dir.create(library, recursive = TRUE, showWarnings = FALSE) + if (file.access(library, 2L) != 0L) stop("The vscode-R managed tool library is not writable.") + tryCatch({ + utils::install.packages(package, lib = library, repos = selected_repositories, + available = available, dependencies = FALSE, + type = package_type, quiet = TRUE) + version <- .tool_version_at(package, library) + if (!identical(version, plan$version) || + !.tool_version_ok(version, minimum_version)) { + stop("The installed version does not match the approved repository version.") + } + .tool_verify_child(package, minimum_version, library) + invisible(list(package = package, version = version, source = "managed", + install_required = FALSE, library = library)) + }, error = function(error) { + action <- if (updating) "update" else "install" + advice <- if (updating) "Close other R sessions using it and try again. " else "" + stop("Could not ", action, " the managed ", package, " package. ", advice, + conditionMessage(error), call. = FALSE) + }) + }, timeout_seconds = timeout_seconds) +} + +# Load a compatible namespace according to the agreed precedence. Returns the +# loaded namespace, or NULL when installation is needed or an incompatible +# namespace is already loaded. The latter is left untouched for safe fallback. +tool_library_load <- function(package, minimum_version, root) { + selected <- tool_library_check(package, minimum_version, root) + if (selected$source == "loaded-incompatible") { + message("An incompatible ", package, + " namespace is already loaded. Restart R before using the managed version.") + return(NULL) + } + if (selected$source == "loaded") return(asNamespace(package)) + if (selected$install_required) return(NULL) + namespace <- tryCatch(loadNamespace(package, lib.loc = selected$library), error = function(error) { + if (selected$source != "managed") stop(error) + stop("Could not load the managed ", package, " package. Remove ", + shQuote(file.path(selected$library, package)), + " to allow vscode-R to offer installation on the next startup. ", + conditionMessage(error), call. = FALSE) + }) + actual_path <- normalizePath(getNamespaceInfo(namespace, "path"), winslash = "/", mustWork = TRUE) + intended_path <- normalizePath(file.path(selected$library, package), winslash = "/", mustWork = TRUE) + actual_version <- as.character(getNamespaceVersion(namespace)) + if (!identical(actual_path, intended_path) || !.tool_version_ok(actual_version, minimum_version)) { + stop("Loaded ", package, " does not match the selected compatible library.") + } + namespace +} diff --git a/README.md b/README.md index 3d9e82979..56c9ef4a6 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ for other changes and upgrade notes. Steps 1–3 above are all that's required to get started with R in VS Code. But we also recommend the following software for a fully optimized R + VS Code experience: * Install an interactive plotting backend for a better R graphics experience: - * [jgd](https://github.com/grantmcdermott/jgd): Lightweight JSON graphics device with native vscode-R integration. + * [jgd](https://github.com/grantmcdermott/jgd): Lightweight JSON graphics device with native vscode-R integration. If it is not installed, vscode-R can offer to install it into a vscode-R-managed library without changing your R libraries. * [httpgd](https://github.com/nx10/httpgd): SVG-based graphics device served via HTTP and WebSockets. * [arf](https://github.com/eitsupi/arf): A modern R console with many features: syntax highlighting, multiline editing, vi/emacs keybindings, R version switching, etc. Successor to [radian](https://github.com/randy3k/radian) written in Rust. diff --git a/package.json b/package.json index ecab96194..2524ea83b 100644 --- a/package.json +++ b/package.json @@ -2610,6 +2610,9 @@ "test:sess-identity": "Rscript --vanilla R/tests/sess_source.R", "test:sess-attach": "Rscript --vanilla R/tests/attach_sess.R", "test:sess-source": "node --test scripts/sess-source.test.js && pnpm run test:sess-identity && pnpm run test:sess-attach", + "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: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 d8471e303..44b47bb49 100644 --- a/sess/DESCRIPTION +++ b/sess/DESCRIPTION @@ -1,7 +1,7 @@ Package: sess Type: Package Title: High-Performance IPC Bridge for R Sessions -Version: 3.2.1 +Version: 3.2.9000.9000 Authors@R: c( person(given = "Randy", family = "Lai", diff --git a/sess/R/hooks.R b/sess/R/hooks.R index 0d207ea44..7d3e65381 100644 --- a/sess/R/hooks.R +++ b/sess/R/hooks.R @@ -34,10 +34,16 @@ #' Start the VS Code runtime integration (internal) #' +#' @param allow_jgd Logical. Whether the jgd renderer may be selected. +#' #' @keywords internal runtime_start <- function(use_rstudioapi = TRUE, - plot_backend = c("auto", "jgd", "httpgd", "standard", "native")) { + plot_backend = c("auto", "jgd", "httpgd", "standard", "native"), + allow_jgd = TRUE) { plot_backend <- match.arg(plot_backend) + if (!(is.logical(allow_jgd) && length(allow_jgd) == 1L && !is.na(allow_jgd))) { + stop("allow_jgd must be a single non-NA logical value.", call. = FALSE) + } .sess_env$runtime_start_phase <- "initialize" state <- .runtime_state() if (isTRUE(state$active)) { @@ -213,7 +219,7 @@ runtime_start <- function(use_rstudioapi = TRUE, } # 4. Plot device: JGD > httpgd > Standard, or no plot integration for native .sess_env$runtime_start_phase <- "plot" - has_jgd <- plot_backend %in% c("auto", "jgd") && + has_jgd <- allow_jgd && plot_backend %in% c("auto", "jgd") && nzchar(Sys.getenv("JGD_SOCKET")) && requireNamespace("jgd", quietly = TRUE) # The built-in client requires the 2.x renderer schema, including descr. has_httpgd <- plot_backend %in% c("auto", "httpgd") && @@ -263,7 +269,11 @@ runtime_start <- function(use_rstudioapi = TRUE, # unavailable, warn before silently degrading to the standard viewer. # Auto is meant to degrade quietly. if (plot_backend %in% c("jgd", "httpgd")) { - if (plot_backend == "jgd" && !requireNamespace("jgd", quietly = TRUE)) { + if (plot_backend == "jgd" && !allow_jgd) { + warning("[sess] Plot backend \"jgd\" was requested but jgd setup was not available. ", + "Falling back to the standard plot viewer. Check the jgd setup message, ", + "or change the r.plot.backend setting.", call. = FALSE) + } else if (plot_backend == "jgd" && !requireNamespace("jgd", quietly = TRUE)) { warning("[sess] Plot backend \"jgd\" was requested but the jgd package ", "is not installed. Falling back to the standard plot viewer. ", "Install jgd, or change the r.plot.backend setting.", call. = FALSE) diff --git a/sess/R/server.R b/sess/R/server.R index 0fb37b410..89b4718f1 100644 --- a/sess/R/server.R +++ b/sess/R/server.R @@ -5,6 +5,8 @@ #' @param use_rstudioapi Logical. Enable rstudioapi emulation. Defaults to TRUE. #' @param plot_backend Plot backend: `auto`, `jgd`, `httpgd`, `standard`, or #' `native`. NULL also selects `auto`. +#' @param allow_jgd Logical. Whether the jgd renderer may be selected. Set to +#' `FALSE` when optional jgd preparation was unavailable. #' @param attachment_id Optional opaque identifier supplied by an external #' terminal integration to correlate this attachment with its preparation. #' It must be 1-128 ASCII characters from `A-Z`, `a-z`, `0-9`, `_`, and `-`. @@ -17,13 +19,13 @@ #' unexpected disconnect waits for a replacement endpoint in that file and #' reconnects with the same runtime options and session identity. The optional #' discovery jgdSocket string updates JGD_SOCKET when the resolved backend -#' includes jgd; an empty -#' string clears it, and an omitted field leaves it unchanged. Set +#' includes jgd and `allow_jgd` is `TRUE`; an empty string clears it, and an +#' omitted field leaves it unchanged. Set #' `options(sess.quiet = TRUE)` to suppress the successful connection message. #' @export connect <- function(endpoint = NULL, use_rstudioapi = TRUE, plot_backend = c("auto", "jgd", "httpgd", "standard", "native"), - attachment_id = NULL, auto_activate = TRUE) { + attachment_id = NULL, auto_activate = TRUE, allow_jgd = TRUE) { if (!is.null(attachment_id) && !(is.character(attachment_id) && length(attachment_id) == 1L && !is.na(attachment_id) && @@ -36,6 +38,9 @@ connect <- function(endpoint = NULL, use_rstudioapi = TRUE, if (!auto_activate && is.null(attachment_id)) { stop("auto_activate = FALSE requires attachment_id.", call. = FALSE) } + if (!(is.logical(allow_jgd) && length(allow_jgd) == 1L && !is.na(allow_jgd))) { + stop("allow_jgd must be a single non-NA logical value.", call. = FALSE) + } plot_backend <- .resolve_plot_backend(plot_backend) # Invalidate poll callbacks and restore a previous runtime before reconnecting. @@ -68,11 +73,12 @@ connect <- function(endpoint = NULL, use_rstudioapi = TRUE, # endpoints from the generated attach script. Never redirect an unrelated client. discovery <- if (nzchar(discovery_file)) .read_discovery(discovery_file) else NULL if (!is.null(discovery) && identical(discovery$endpoint, endpoint)) { - .configure_discovery_jgd(discovery, plot_backend %in% c("auto", "jgd")) + .configure_discovery_jgd(discovery, allow_jgd && plot_backend %in% c("auto", "jgd")) .sess_env$reconnect <- list( path = discovery_file, endpoint = endpoint, options = list(use_rstudioapi = use_rstudioapi, plot_backend = plot_backend, - attachment_id = attachment_id, auto_activate = auto_activate) + attachment_id = attachment_id, auto_activate = auto_activate, + allow_jgd = allow_jgd) ) } @@ -124,7 +130,8 @@ connect <- function(endpoint = NULL, use_rstudioapi = TRUE, if (is.na(use_rstudioapi)) use_rstudioapi <- TRUE if (isTRUE(connected) && !is.null(.sess_env$con)) { tryCatch( - runtime_start(use_rstudioapi = use_rstudioapi, plot_backend = plot_backend), + runtime_start(use_rstudioapi = use_rstudioapi, plot_backend = plot_backend, + allow_jgd = allow_jgd), error = function(e) { phase <- .sess_env$runtime_start_phase error_call <- conditionCall(e) @@ -309,8 +316,12 @@ connect <- function(endpoint = NULL, use_rstudioapi = TRUE, !identical(endpoint, settings$endpoint)) { # Read endpoint and renderer from one snapshot, even if the file is # replaced again while connect() runs. - .configure_discovery_jgd(discovery, - settings$options$plot_backend %in% c("auto", "jgd")) + allow_jgd <- settings$options$allow_jgd + if (is.null(allow_jgd)) allow_jgd <- TRUE + .configure_discovery_jgd( + discovery, + isTRUE(allow_jgd) && settings$options$plot_backend %in% c("auto", "jgd") + ) .sess_env$reconnecting <- TRUE tryCatch( .reconnect_with_startup(endpoint, function() { diff --git a/sess/inst/tinytest/test-attachment-metadata.R b/sess/inst/tinytest/test-attachment-metadata.R index 94619ff8b..ab353a3e8 100644 --- a/sess/inst/tinytest/test-attachment-metadata.R +++ b/sess/inst/tinytest/test-attachment-metadata.R @@ -55,7 +55,10 @@ local({ list(attachment_id = strrep("a", 129L)), list(auto_activate = NA), list(auto_activate = c(TRUE, FALSE)), - list(auto_activate = FALSE) + list(auto_activate = FALSE), + list(allow_jgd = NA), + list(allow_jgd = c(TRUE, FALSE)), + list(allow_jgd = 1L) ) for (args in invalid) { expect_error(do.call(env$connect, c(list(endpoint = ""), args))) @@ -88,12 +91,18 @@ local({ Sys.setenv(SESS_DISCOVERY_FILE = discovery_path) env$.resolve_endpoint <- function(endpoint = NULL, ...) target_endpoint env$.read_discovery <- function(path) list(endpoint = target_endpoint) - env$.configure_discovery_jgd <- function(discovery, jgd_enabled) NULL + discovery_jgd_enabled <- logical() + env$.configure_discovery_jgd <- function(discovery, jgd_enabled) { + discovery_jgd_enabled <<- c(discovery_jgd_enabled, jgd_enabled) + } invisible(env$connect( endpoint = target_endpoint, attachment_id = "console_retry_2", - auto_activate = FALSE + auto_activate = FALSE, + allow_jgd = FALSE )) expect_equal(state$reconnect$options$attachment_id, "console_retry_2") expect_false(state$reconnect$options$auto_activate) + expect_false(state$reconnect$options$allow_jgd) + expect_false(tail(discovery_jgd_enabled, 1L)) }) diff --git a/sess/inst/tinytest/test-native-plot.R b/sess/inst/tinytest/test-native-plot.R index d2cce3231..46be2eb23 100644 --- a/sess/inst/tinytest/test-native-plot.R +++ b/sess/inst/tinytest/test-native-plot.R @@ -17,6 +17,37 @@ local({ expect_equal(select("jgd", TRUE, TRUE), "jgd") expect_equal(select("httpgd", FALSE, TRUE), "standard") expect_equal(select("native", TRUE, TRUE), "native") + expect_error(sess:::runtime_start(allow_jgd = NA), "single non-NA logical") +}) + +# A denied optional renderer is short-circuited before package discovery and +# falls back with an actionable warning for an explicit jgd request. +local({ + sess:::runtime_stop() + state <- get(".sess_env", asNamespace("sess")) + original_device <- getOption("device") + original_plot_path <- state$latest_plot_path + fallback_plot_path <- tempfile("sess-jgd-fallback-") + state$latest_plot_path <- fallback_plot_path + on.exit({ + sess:::runtime_stop() + options(device = original_device) + unlink(fallback_plot_path) + state$latest_plot_path <- original_plot_path + }, add = TRUE) + env <- new.env(parent = asNamespace("sess")) + env$.sess_env <- state + env$runtime_start <- sess:::runtime_start + environment(env$runtime_start) <- env + env$requireNamespace <- function(package, ...) { + if (identical(package, "jgd")) stop("jgd lookup should be short-circuited") + base::requireNamespace(package, ...) + } + expect_warning( + env$runtime_start(use_rstudioapi = FALSE, plot_backend = "jgd", allow_jgd = FALSE), + "jgd setup was not available" + ) + expect_true(isTRUE(sess:::.runtime_state()$active)) }) local({ diff --git a/sess/inst/tinytest/test-reconnect.R b/sess/inst/tinytest/test-reconnect.R index 24759ecb5..e00f747fc 100644 --- a/sess/inst/tinytest/test-reconnect.R +++ b/sess/inst/tinytest/test-reconnect.R @@ -132,6 +132,21 @@ local({ expect_equal(Sys.getenv("JGD_SOCKET"), "external-jgd") expect_equal(length(callbacks), 0L) + # A failed optional jgd preparation remains disabled for discovery retries. + denied_options <- list(use_rstudioapi = FALSE, plot_backend = "jgd", + attachment_id = "console_no_jgd", auto_activate = FALSE, + allow_jgd = FALSE) + denied_settings <- list(path = path, endpoint = "old", options = denied_options) + env$.sess_env$con <- NULL + Sys.setenv(JGD_SOCKET = "keep-this-socket") + writeLines(jsonlite::toJSON(list(version = 1L, endpoint = "new", + jgdSocket = "replacement-jgd"), auto_unbox = TRUE), path) + env$.schedule_reconnect(denied_settings, schedule = schedule) + tick() + expect_equal(tail(attempts, 1L)[[1L]], c(list(endpoint = "new"), denied_options)) + expect_equal(Sys.getenv("JGD_SOCKET"), "keep-this-socket") + expect_identical(env$.sess_env$reconnect$options$allow_jgd, FALSE) + # A registered notifier receives the replacement endpoint and owns the # setup callback. A same-process registration without a notifier fails closed. startup_option <- "vscodeR.terminalStartup" diff --git a/sess/man/connect.Rd b/sess/man/connect.Rd index deb4bfee2..302f54856 100644 --- a/sess/man/connect.Rd +++ b/sess/man/connect.Rd @@ -9,7 +9,8 @@ connect( use_rstudioapi = TRUE, plot_backend = c("auto", "jgd", "httpgd", "standard", "native"), attachment_id = NULL, - auto_activate = TRUE + auto_activate = TRUE, + allow_jgd = TRUE ) } \arguments{ @@ -23,12 +24,14 @@ If NULL, uses SESS_ENDPOINT, then SESS_DISCOVERY_FILE.} \item{attachment_id}{Optional opaque identifier supplied by an external terminal integration to correlate this attachment with its preparation. -It must be 1-128 ASCII characters from \code{A-Z}, \code{a-z}, \code{0-9}, -\code{_}, and \code{-}. It is not an authentication token or an exclusive -ownership claim.} +It must be 1-128 ASCII characters from \code{A-Z}, \code{a-z}, \code{0-9}, \code{_}, and \code{-}. +It is not an authentication token or an exclusive ownership claim.} \item{auto_activate}{Logical. Whether vscode-R should select this attachment automatically. \code{FALSE} requires \code{attachment_id}.} + +\item{allow_jgd}{Logical. Whether the jgd renderer may be selected. Set to +\code{FALSE} when optional jgd preparation was unavailable.} } \value{ Invisibly returns \code{TRUE} when the IPC connection and runtime startup @@ -42,7 +45,7 @@ When SESS_DISCOVERY_FILE describes the connected endpoint, an unexpected disconnect waits for a replacement endpoint in that file and reconnects with the same runtime options and session identity. The optional discovery jgdSocket string updates JGD_SOCKET when the resolved backend -includes jgd; an empty -string clears it, and an omitted field leaves it unchanged. Set +includes jgd and \code{allow_jgd} is \code{TRUE}; an empty string clears it, and an +omitted field leaves it unchanged. Set \code{options(sess.quiet = TRUE)} to suppress the successful connection message. } diff --git a/sess/man/runtime_start.Rd b/sess/man/runtime_start.Rd index 927d43728..b0cd3c086 100644 --- a/sess/man/runtime_start.Rd +++ b/sess/man/runtime_start.Rd @@ -6,9 +6,13 @@ \usage{ runtime_start( use_rstudioapi = TRUE, - plot_backend = c("auto", "jgd", "httpgd", "standard", "native") + plot_backend = c("auto", "jgd", "httpgd", "standard", "native"), + allow_jgd = TRUE ) } +\arguments{ +\item{allow_jgd}{Logical. Whether the jgd renderer may be selected.} +} \description{ Start the VS Code runtime integration (internal) } diff --git a/src/fileConsent.ts b/src/fileConsent.ts new file mode 100644 index 000000000..b7f3c246c --- /dev/null +++ b/src/fileConsent.ts @@ -0,0 +1,203 @@ +import * as fs from 'node:fs/promises'; +import * as fsSync from 'node:fs'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; + +export type ConsentResponse = 'approve' | 'decline'; + +export interface FileConsentServiceOptions { + directory: string; + requestName: RegExp; + maxBytes: number; + isEnabled: () => boolean; + parseRequest: (id: string, content: string) => Request; + processRequest: ( + request: Request, + respond: (response: ConsentResponse) => Promise, + ) => Promise; + intervalMs?: number; +} + +/** Polls a private request directory and publishes a single-use decision. */ +export class FileConsentService { + private timer: NodeJS.Timeout | undefined; + private stopped = true; + private readonly pending = new Map>(); + private readonly seen = new Set(); + private readonly contents = new Map(); + private readonly responses = new Set(); + private readonly activeWrites = new Set>(); + + constructor(private readonly options: FileConsentServiceOptions) {} + + async start(): Promise { + if (!this.stopped) { + return; + } + await fs.mkdir(this.options.directory, { recursive: true, mode: 0o700 }); + await this.setOwnerOnlyPermissions(this.options.directory, 0o700); + this.stopped = false; + this.timer = setInterval(() => void this.scan(), this.options.intervalMs ?? 200); + this.timer.unref?.(); + await this.scan(); + } + + async stop(): Promise { + this.stopped = true; + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + await Promise.all([...this.pending.keys()].map((id) => this.respond(id, 'decline'))); + await Promise.all(this.activeWrites); + this.seen.clear(); + } + + private async scan(): Promise { + if (this.stopped) { + return; + } + let names: string[]; + try { + names = await fs.readdir(this.options.directory); + } catch { + return; + } + if (this.stopped) { + return; + } + const present = new Set( + names + .filter((name) => this.options.requestName.test(name)) + .map((name) => name.slice(0, -'.request'.length)), + ); + for (const id of this.seen) { + if (!present.has(id) && !this.pending.has(id)) { + this.seen.delete(id); + this.responses.delete(id); + } + } + for (const name of names) { + if (!this.options.requestName.test(name)) { + continue; + } + const id = name.slice(0, -'.request'.length); + if (this.pending.has(id) || this.seen.has(id)) { + continue; + } + this.seen.add(id); + const work = this.processFile(id).finally(() => { + this.pending.delete(id); + this.contents.delete(id); + }); + this.pending.set(id, work); + } + } + + private async processFile(id: string): Promise { + const requestPath = path.join(this.options.directory, `${id}.request`); + let content: string; + try { + content = await this.readBoundedRequest(requestPath); + } catch { + if (!this.stopped) { + await this.respond(id, 'decline'); + } + return; + } + this.contents.set(id, content); + if (this.stopped) { + await this.respond(id, 'decline'); + return; + } + let request: Request; + try { + request = this.options.parseRequest(id, content); + } catch { + await this.respond(id, 'decline'); + return; + } + if (!this.options.isEnabled()) { + await this.respond(id, 'decline'); + return; + } + try { + await this.options.processRequest(request, (response) => this.respond(id, response)); + } catch { + await this.respond(id, 'decline'); + } + } + + private async readBoundedRequest(filePath: string): Promise { + const handle = await fs.open(filePath, 'r'); + try { + const buffer = Buffer.alloc(this.options.maxBytes + 1); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + if (bytesRead > this.options.maxBytes) { + throw new Error('Consent request exceeds its size limit.'); + } + return buffer.toString('utf8', 0, bytesRead); + } finally { + await handle.close(); + } + } + + private async respond(id: string, response: ConsentResponse): Promise { + if (this.responses.has(id)) { + return; + } + this.responses.add(id); + const write = this.publishResponse(id, response, this.contents.get(id)); + this.activeWrites.add(write); + try { + await write; + } finally { + this.activeWrites.delete(write); + } + } + + private async publishResponse( + id: string, + response: ConsentResponse, + expectedContent: string | undefined, + ): Promise { + const finalPath = path.join(this.options.directory, `${id}.response`); + const temporaryPath = path.join( + this.options.directory, + `${id}.${crypto.randomBytes(8).toString('hex')}.tmp`, + ); + try { + await fs.writeFile(temporaryPath, `${response}\n`, { + encoding: 'utf8', + mode: 0o600, + flag: 'wx', + }); + await this.setOwnerOnlyPermissions(temporaryPath, 0o600); + const requestPath = path.join(this.options.directory, `${id}.request`); + let publish = + response === 'decline' || + (!this.stopped && this.options.isEnabled() && fsSync.existsSync(requestPath)); + if (publish && response === 'approve' && expectedContent !== undefined) { + try { + publish = fsSync.readFileSync(requestPath, 'utf8') === expectedContent; + } catch { + publish = false; + } + } + if (!publish) { + await fs.writeFile(temporaryPath, 'decline\n', { encoding: 'utf8', mode: 0o600 }); + await this.setOwnerOnlyPermissions(temporaryPath, 0o600); + } + fsSync.renameSync(temporaryPath, finalPath); + await this.setOwnerOnlyPermissions(finalPath, 0o600); + } catch { + await fs.rm(temporaryPath, { force: true }).catch(() => undefined); + } + } + + private async setOwnerOnlyPermissions(filePath: string, mode: number): Promise { + if (process.platform !== 'win32') { + await fs.chmod(filePath, mode); + } + } +} diff --git a/src/jgdVersion.ts b/src/jgdVersion.ts new file mode 100644 index 000000000..300f684cc --- /dev/null +++ b/src/jgdVersion.ts @@ -0,0 +1,2 @@ +// 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 3d4cd02e7..713862a41 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as crypto from 'node:crypto'; import { getMigratedSetting } from './configuration'; +import { JGD_MINIMUM_VERSION } from './jgdVersion'; import { isDeepStrictEqual } from 'util'; import * as vscode from 'vscode'; @@ -17,6 +18,8 @@ import { deferWorkspaceRefresh, getGlobalPipePath, getSessConsentDirectory, + getToolConsentDirectory, + canPromptForToolConsent, isTerminalClosed, removeTerminalDiscoveryFile, updateTerminalSessionDiscoveryFile, @@ -312,6 +315,8 @@ export async function makeTerminalOptions(resource?: vscode.Uri): Promise>(); - private readonly seen = new Set(); - private readonly activeWrites = new Set>(); - - constructor(private readonly options: SessConsentServiceOptions) {} - - async start(): Promise { - if (!this.stopped) { - return; - } - await fs.mkdir(this.options.directory, { recursive: true, mode: 0o700 }); - await this.setOwnerOnlyPermissions(this.options.directory, 0o700); - this.stopped = false; - this.timer = setInterval(() => { - void this.scan(); - }, this.options.intervalMs ?? 200); - this.timer.unref?.(); - await this.scan(); + private readonly bridge: FileConsentService; + private running = false; + + constructor(private readonly options: SessConsentServiceOptions) { + this.bridge = new FileConsentService({ + directory: options.directory, + requestName: REQUEST_NAME, + maxBytes: 256, + intervalMs: options.intervalMs, + isEnabled: options.isEnabled, + parseRequest: (id, content) => this.parseRequest(id, content), + processRequest: (request, respond) => this.processRequest(request, respond), + }); } - async stop(): Promise { - this.stopped = true; - if (this.timer) { - clearInterval(this.timer); - this.timer = undefined; - } - // A pending VS Code notification may outlive shutdown. A denial written - // here wins because the request has a unique, single-use ID. - await Promise.all([...this.pending.keys()].map((id) => this.writeResponse(id, 'decline'))); - await Promise.all(this.activeWrites); - this.seen.clear(); + start(): Promise { + this.running = true; + return this.bridge.start(); } - private async scan(): Promise { - if (this.stopped) { - return; - } - let names: string[]; - try { - names = await fs.readdir(this.options.directory); - } catch { - return; - } - if (this.stopped) { - return; - } - const present = new Set( - names - .filter((name) => REQUEST_NAME.test(name)) - .map((name) => name.slice(0, -'.request'.length)), - ); - for (const id of this.seen) { - if (!present.has(id) && !this.pending.has(id)) { - this.seen.delete(id); - } - } - for (const name of names) { - if (!REQUEST_NAME.test(name)) { - continue; - } - const id = name.slice(0, -'.request'.length); - if (this.pending.has(id) || this.seen.has(id)) { - continue; - } - this.seen.add(id); - const work = this.processRequest(id).finally(() => this.pending.delete(id)); - this.pending.set(id, work); - } + stop(): Promise { + this.running = false; + return this.bridge.stop(); } - private async processRequest(id: string): Promise { - const requestPath = path.join(this.options.directory, `${id}.request`); - let request: SessConsentRequest; - try { - request = this.parseRequest(id, await this.readBoundedRequest(requestPath)); - } catch { - if (!this.stopped) { - await this.writeResponse(id, 'decline'); - } - return; - } - if (this.stopped) { - return; - } + private async processRequest( + request: SessConsentRequest, + respond: (response: ConsentResponse) => Promise, + ): Promise { if (request.revision !== this.options.expectedRevision || !this.options.isEnabled()) { - await this.writeResponse(id, 'decline'); + await respond('decline'); return; } try { if (this.options.getDismissedRevision() === request.revision) { - await this.writeResponse(id, 'decline'); + await respond('decline'); return; } } catch { @@ -135,33 +75,27 @@ export class SessConsentService { } catch { choice = 'dismiss'; } - if (this.stopped) { + if (!this.running) { return; } if (!this.options.isEnabled()) { - await this.writeResponse(id, 'decline'); + await respond('decline'); return; } if (choice === 'dontAskAgain') { try { await this.options.rememberDismissedRevision(request.revision); } catch { - if (!this.stopped) { - await this.writeResponse(id, 'decline'); - } + await respond('decline'); return; } - if (this.stopped) { + if (!this.running) { return; } - await this.writeResponse(id, 'decline'); + await respond('decline'); return; } - const response = - !this.stopped && this.options.isEnabled() && choice === 'install' - ? 'approve' - : 'decline'; - await this.writeResponse(id, response); + await respond(choice === 'install' && this.options.isEnabled() ? 'approve' : 'decline'); } private parseRequest(id: string, content: string): SessConsentRequest { @@ -185,64 +119,4 @@ export class SessConsentService { } return { id, revision: lines[2], runtime: lines[3], reason: lines[4] }; } - - private async readBoundedRequest(filePath: string): Promise { - const handle = await fs.open(filePath, 'r'); - try { - const buffer = Buffer.alloc(257); - const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); - if (bytesRead > 256) { - throw new Error('Sess consent request exceeds its size limit.'); - } - return buffer.toString('utf8', 0, bytesRead); - } finally { - await handle.close(); - } - } - - private async writeResponse(id: string, response: 'approve' | 'decline'): Promise { - const write = this.publishResponse(id, response); - this.activeWrites.add(write); - try { - await write; - } finally { - this.activeWrites.delete(write); - } - } - - private async publishResponse(id: string, response: 'approve' | 'decline'): Promise { - const finalPath = path.join(this.options.directory, `${id}.response`); - const temporaryPath = path.join( - this.options.directory, - `${id}.${crypto.randomBytes(8).toString('hex')}.tmp`, - ); - try { - await fs.writeFile(temporaryPath, `${response}\n`, { - encoding: 'utf8', - mode: 0o600, - flag: 'wx', - }); - await this.setOwnerOnlyPermissions(temporaryPath, 0o600); - // Publish in one synchronous step so shutdown/config changes cannot - // interleave between the final grant check and its visible response. - const requestPath = path.join(this.options.directory, `${id}.request`); - if ( - response === 'approve' && - (this.stopped || !this.options.isEnabled() || !fsSync.existsSync(requestPath)) - ) { - await fs.writeFile(temporaryPath, 'decline\n', { encoding: 'utf8', mode: 0o600 }); - await this.setOwnerOnlyPermissions(temporaryPath, 0o600); - } - fsSync.renameSync(temporaryPath, finalPath); - await this.setOwnerOnlyPermissions(finalPath, 0o600); - } catch { - await fs.rm(temporaryPath, { force: true }).catch(() => undefined); - } - } - - private async setOwnerOnlyPermissions(filePath: string, mode: number): Promise { - if (process.platform !== 'win32') { - await fs.chmod(filePath, mode); - } - } } diff --git a/src/session.ts b/src/session.ts index 0305f652c..8f1defce5 100644 --- a/src/session.ts +++ b/src/session.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs'; import * as fsp from 'node:fs/promises'; import { pathExists, readJson } from './fileSystem'; +import { JGD_MINIMUM_VERSION } from './jgdVersion'; import * as path from 'path'; import * as os from 'os'; import * as net from 'net'; @@ -55,6 +56,8 @@ import { ViewerSessionSource, } from './viewerSession'; import { SessConsentChoice, SessConsentService } from './sessConsent'; +import { ToolConsentService } from './toolConsent'; +import { createToolConsentService } from './toolConsentAdapter'; import { assessTerminalStartup, parseTerminalStartupRecord, @@ -869,6 +872,9 @@ export function deploySessionWatcher(extensionPath: string): void { attachConsentService = undefined; attachConsentDirectory = undefined; attachConsentStartup = undefined; + attachToolConsentService = undefined; + attachToolConsentDirectory = undefined; + attachToolConsentStartup = undefined; attachSessionScriptPath = undefined; } attachmentLifecycleGeneration++; @@ -942,6 +948,9 @@ let attachSessionScriptPath: string | undefined; let attachConsentDirectory: string | undefined; let attachConsentService: SessConsentService | undefined; let attachConsentStartup: Promise | undefined; +let attachToolConsentDirectory: string | undefined; +let attachToolConsentService: ToolConsentService | undefined; +let attachToolConsentStartup: Promise | undefined; let attachmentLifecycleGeneration = 0; let attachmentLifecycleStopping = false; let attachScriptPublication: Promise = Promise.resolve(); @@ -1572,10 +1581,13 @@ function buildAttachSessionScript( pipePath: string, sessPath: string, consentDirectory: string, + toolConsentDirectory: string, attachment?: { attachmentId: string; autoActivate: boolean }, ): string { const backend = resolveBackend(); const jgdSocket = getSessionJgdSocket(); + const jgdCanPrompt = + toolConsentDirectory.length > 0 && attachToolConsentService?.canPrompt(backend) === true; return [ 'local({', ' tryCatch({', @@ -1586,6 +1598,12 @@ function buildAttachSessionScript( ` consent_dir <- ${asRStringLiteral(consentDirectory.replace(/\\/g, '/'))}`, ` source_helper <- ${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'sess_source.R')).replace(/\\/g, '/'))}`, ` installer_helper <- ${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'sess-package-install.R')).replace(/\\/g, '/'))}`, + ` tool_root <- ${asRStringLiteral(extensionContext.globalStorageUri.fsPath.replace(/\\/g, '/'))}`, + ` 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, '/'))}`, + ` jgd_can_prompt <- ${jgdCanPrompt ? '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' @@ -1597,7 +1615,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)}, 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, 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'}` : ''})`, ' })', ' invisible(isTRUE(attached))', ' }, error = function(error) {', @@ -1713,6 +1731,71 @@ export async function getSessConsentDirectory(): Promise { return ensureAttachSessConsentService(); } +async function ensureAttachToolConsentService( + generation = attachmentLifecycleGeneration, +): Promise { + if (!isAttachmentLifecycleCurrent(generation)) { + return ''; + } + if (attachToolConsentStartup) { + return attachToolConsentStartup; + } + if (attachToolConsentService && attachToolConsentDirectory) { + return attachToolConsentDirectory; + } + + const startup = (async (): Promise => { + const directory = path.join( + extensionContext.globalStorageUri.fsPath, + 'tmp', + 'attach', + 'tool-consent', + crypto.randomBytes(16).toString('hex'), + ); + let service: ToolConsentService | undefined; + try { + service = createToolConsentService( + directory, + () => config().get('sessionWatcher') === true, + extensionContext.globalState, + 200, + ); + await service.start(); + if (!isAttachmentLifecycleCurrent(generation)) { + await service.stop(); + await removePathIfExists(directory); + return ''; + } + attachToolConsentDirectory = directory; + attachToolConsentService = service; + return directory; + } catch (error) { + await service?.stop().catch(() => undefined); + await removePathIfExists(directory); + console.error('[session] Failed to start managed tool consent service', error); + return ''; + } + })(); + attachToolConsentStartup = startup; + try { + return await startup; + } finally { + if (attachToolConsentStartup === startup) { + attachToolConsentStartup = undefined; + } + } +} + +/** Return the dedicated request directory for managed R tool installation consent. */ +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; +} + function validateAttachmentEndpoint(endpoint: unknown): asserts endpoint is string { if (typeof endpoint !== 'string' || endpoint.length === 0 || endpoint.includes('\0')) { throw new TypeError('A non-empty local sess IPC endpoint is required.'); @@ -1759,6 +1842,7 @@ async function prepareAttachmentCore( .asAbsolutePath(path.join('dist', 'resources', 'sess')) .replace(/\\/g, '/'); const consentDirectory = await ensureAttachSessConsentService(generation); + const toolConsentDirectory = await ensureAttachToolConsentService(generation); if (!isAttachmentLifecycleCurrent(generation)) { throw new Error('The session watcher shut down while preparing sess attachment.'); } @@ -1770,7 +1854,7 @@ async function prepareAttachmentCore( try { await fsp.writeFile( temporaryPath, - buildAttachSessionScript(endpoint, sessPath, consentDirectory, { + buildAttachSessionScript(endpoint, sessPath, consentDirectory, toolConsentDirectory, { attachmentId, autoActivate, }), @@ -1832,6 +1916,7 @@ async function getAttachSessionCommandCore(): Promise { .asAbsolutePath(path.join('dist', 'resources', 'sess')) .replace(/\\/g, '/'); const consentDirectory = await ensureAttachSessConsentService(generation); + const toolConsentDirectory = await ensureAttachToolConsentService(generation); if (!isAttachmentLifecycleCurrent(generation)) { throw new Error('The session watcher is shutting down.'); } @@ -1848,7 +1933,12 @@ async function getAttachSessionCommandCore(): Promise { try { await fsp.writeFile( temporaryPath, - buildAttachSessionScript(pipePath, sessPath, consentDirectory), + buildAttachSessionScript( + pipePath, + sessPath, + consentDirectory, + toolConsentDirectory, + ), { encoding: 'utf-8', mode: 0o600, flag: 'wx' }, ); await setOwnerOnlyPermissions(temporaryPath); @@ -1909,6 +1999,9 @@ export async function shutdownSessionWatcher(): Promise { const consentStartup = attachConsentStartup; const consentService = attachConsentService; const consentDirectory = attachConsentDirectory; + const toolConsentStartup = attachToolConsentStartup; + const toolConsentService = attachToolConsentService; + const toolConsentDirectory = attachToolConsentDirectory; const serverStartup = globalSessionServerStartup; const pipePathAtShutdown = globalPipePath; const serverAtShutdown = globalSessionServer; @@ -1961,6 +2054,8 @@ export async function shutdownSessionWatcher(): Promise { await manualCleanup.catch(() => undefined); await consentStartup?.catch(() => undefined); await consentService?.stop(); + await toolConsentStartup?.catch(() => undefined); + await toolConsentService?.stop(); if (attachConsentService === consentService) { attachConsentService = undefined; } @@ -1973,6 +2068,18 @@ export async function shutdownSessionWatcher(): Promise { if (attachConsentStartup === consentStartup) { attachConsentStartup = undefined; } + if (attachToolConsentService === toolConsentService) { + attachToolConsentService = undefined; + } + if (toolConsentDirectory) { + await removePathIfExists(toolConsentDirectory); + if (attachToolConsentDirectory === toolConsentDirectory) { + attachToolConsentDirectory = undefined; + } + } + if (attachToolConsentStartup === toolConsentStartup) { + attachToolConsentStartup = undefined; + } // Await the startup snapshot so a server published by it can be closed below. await serverStartup?.catch(() => undefined); const pipePath = pipePathAtShutdown; diff --git a/src/test/node/toolConsent.test.ts b/src/test/node/toolConsent.test.ts new file mode 100644 index 000000000..a8554ad9e --- /dev/null +++ b/src/test/node/toolConsent.test.ts @@ -0,0 +1,328 @@ +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + parseToolConsentRequest, + ToolConsentChoice, + ToolConsentRequest, + ToolConsentService, +} from '../../toolConsent'; + +function requestContent(id: string, backend: 'jgd' | 'auto', version = '1.2.0'): string { + return [ + 'vscode-r-package-consent-v1', + id, + 'linux-x86_64|4.5', + backend, + '1', + 'jgd', + '0.1.0', + version, + 'https://cloud.r-project.org', + 'source', + 'yes', + '', + ].join('\n'); +} + +async function waitForFile(file: string): Promise { + const deadline = Date.now() + 3000; + while (Date.now() < deadline) { + try { + return await fs.readFile(file, 'utf8'); + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + throw new Error(`Timed out waiting for ${file}`); +} + +suite('managed package consent bridge', () => { + let directory: string; + let autoPromptEnabled: boolean; + let failPreferenceSave: boolean; + let choice: ToolConsentChoice; + let prompts: ToolConsentRequest[]; + let sessionState: { autoPrompted: boolean }; + const services: ToolConsentService[] = []; + + setup(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), 'vscode-r-package-consent-')); + autoPromptEnabled = true; + failPreferenceSave = false; + choice = 'install'; + prompts = []; + sessionState = { autoPrompted: false }; + }); + + teardown(async () => { + await Promise.all(services.splice(0).map((service) => service.stop())); + await fs.rm(directory, { recursive: true, force: true }); + }); + + function service( + prompt?: (request: ToolConsentRequest) => Promise, + requestDirectory = directory, + ): ToolConsentService { + const result = new ToolConsentService({ + directory: requestDirectory, + isEnabled: () => true, + isAutoPromptEnabled: () => autoPromptEnabled, + sessionState, + rememberAutoPromptDismissal: () => { + if (failPreferenceSave) { + return Promise.reject(new Error('global state unavailable')); + } + autoPromptEnabled = false; + return Promise.resolve(); + }, + prompt: async (request) => { + prompts.push(request); + return prompt ? prompt(request) : choice; + }, + intervalMs: 5, + }); + services.push(result); + return result; + } + + async function writeRequest( + id: string, + backend: 'jgd' | 'auto', + version?: string, + requestDirectory = directory, + ): Promise { + const request = path.join(requestDirectory, `${id}.request`); + await fs.writeFile(request, requestContent(id, backend, version)); + 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); + for (const id of ['1'.repeat(32), '2'.repeat(32)]) { + await writeRequest(id, 'jgd'); + assert.strictEqual( + await waitForFile(path.join(directory, `${id}.response`)), + 'approve\n', + ); + await fs.rm(path.join(directory, `${id}.request`), { force: true }); + await fs.rm(path.join(directory, `${id}.response`), { force: true }); + } + assert.strictEqual(prompts.length, 2); + assert.strictEqual(autoPromptEnabled, true); + assert.deepStrictEqual(prompts[0].packages, [ + { + name: 'jgd', + minimumVersion: '0.1.0', + version: '1.2.0', + repository: 'https://cloud.r-project.org', + packageType: 'source', + sourceBuild: true, + }, + ]); + }); + + test('parses package plans written with Windows CRLF line endings', () => { + const id = 'b'.repeat(32); + const request = parseToolConsentRequest( + id, + requestContent(id, 'jgd').replace(/\n/g, '\r\n'), + ); + assert.strictEqual(request.backend, 'jgd'); + assert.strictEqual(request.packages[0].sourceBuild, true); + }); + + test('automatic prompt is limited to one per host session across services', async () => { + const first = service(); + await first.start(); + const firstId = '3'.repeat(32); + choice = 'notNow'; + await writeRequest(firstId, 'auto'); + 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); + 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); + const secondId = '4'.repeat(32); + await writeRequest(secondId, 'auto', undefined, secondDirectory); + assert.strictEqual( + await waitForFile(path.join(secondDirectory, `${secondId}.response`)), + 'decline\n', + ); + assert.strictEqual(prompts.length, 1); + await secondService.stop(); + + // A fresh state represents a new VS Code extension-host session. + sessionState = { autoPrompted: false }; + 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); + choice = 'install'; + const thirdId = '5'.repeat(32); + await writeRequest(thirdId, 'auto', undefined, newHostDirectory); + assert.strictEqual( + await waitForFile(path.join(newHostDirectory, `${thirdId}.response`)), + 'approve\n', + ); + assert.strictEqual(prompts.length, 2); + }); + + test("only Don't ask again persists suppression; explicit requests ignore it", async () => { + const broker = service(); + await broker.start(); + choice = 'dontAskAgain'; + const firstId = '6'.repeat(32); + assert.strictEqual(broker.canPrompt('auto'), true); + await writeRequest(firstId, 'auto'); + 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); + + choice = 'install'; + const explicitId = '7'.repeat(32); + await writeRequest(explicitId, 'jgd'); + assert.strictEqual( + await waitForFile(path.join(directory, `${explicitId}.response`)), + 'approve\n', + ); + assert.strictEqual(prompts.length, 2); + await broker.stop(); + + sessionState = { autoPrompted: false }; + 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); + assert.strictEqual( + await waitForFile(path.join(nextHostDirectory, `${suppressedId}.response`)), + 'decline\n', + ); + assert.strictEqual(prompts.length, 2); + }); + + test('parallel automatic requests show one prompt and a changed plan cannot be approved', async () => { + let finishPrompt!: (value: ToolConsentChoice) => void; + const broker = service( + () => + new Promise((resolve) => { + finishPrompt = resolve; + }), + ); + const firstId = '7'.repeat(32); + const secondId = '8'.repeat(32); + await writeRequest(firstId, 'auto'); + await writeRequest(secondId, 'auto'); + await broker.start(); + const deadline = Date.now() + 3000; + while (prompts.length === 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.strictEqual(prompts.length, 1); + const promptedId = prompts[0].id; + const declinedId = promptedId === firstId ? secondId : firstId; + assert.strictEqual( + await waitForFile(path.join(directory, `${declinedId}.response`)), + 'decline\n', + ); + await fs.writeFile( + path.join(directory, `${promptedId}.request`), + requestContent(promptedId, 'auto', '1.3.0'), + ); + finishPrompt('install'); + assert.strictEqual( + await waitForFile(path.join(directory, `${promptedId}.response`)), + 'decline\n', + ); + }); + + test('a failed automatic dismissal save never grants installation', async () => { + failPreferenceSave = true; + choice = 'dontAskAgain'; + const broker = service(); + await broker.start(); + const id = '9'.repeat(32); + await writeRequest(id, 'auto'); + assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'decline\n'); + assert.strictEqual(prompts.length, 1); + assert.strictEqual(autoPromptEnabled, true); + }); + + 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; + choice = result; + sessionState = { autoPrompted: false }; + 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); + const expected = result === 'install' ? 'approve\n' : 'decline\n'; + assert.strictEqual( + await waitForFile(path.join(hostDirectory, `${id}.response`)), + expected, + ); + assert.strictEqual(autoPromptEnabled, true); + } + }); + + test('shutdown while an automatic prompt is pending cannot publish a late approval', async () => { + let finishPrompt!: (value: ToolConsentChoice) => void; + let promptStarted!: () => void; + let dismissals = 0; + const started = new Promise((resolve) => { + promptStarted = resolve; + }); + const broker = new ToolConsentService({ + directory, + isEnabled: () => true, + isAutoPromptEnabled: () => true, + sessionState, + rememberAutoPromptDismissal: () => { + dismissals++; + return Promise.resolve(); + }, + prompt: async (request) => { + prompts.push(request); + promptStarted(); + return new Promise((resolve) => { + finishPrompt = resolve; + }); + }, + intervalMs: 5, + }); + services.push(broker); + await broker.start(); + const id = 'a'.repeat(32); + await writeRequest(id, 'auto'); + await started; + await broker.stop(); + finishPrompt('install'); + assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'decline\n'); + assert.strictEqual(prompts.length, 1); + assert.strictEqual(dismissals, 0); + }); +}); diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index 4b582ebd5..e4dcb068a 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -1,3 +1,4 @@ +import { JGD_MINIMUM_VERSION } from '../../jgdVersion'; import * as vscode from 'vscode'; import * as sinon from 'sinon'; import * as assert from 'assert'; @@ -18,6 +19,7 @@ import * as processTree from '../../processTree'; import * as extension from '../../extension'; import * as plotViewer from '../../plotViewer'; import { SessConsentService } from '../../sessConsent'; +import { ToolConsentService } from '../../toolConsent'; import { initializeHtmlWidgetViewers, shutdownHtmlWidgetViewers } from '../../webViewer'; import type { RSessionApi } from '../../api'; @@ -988,6 +990,18 @@ suite('Session Communication', () => { assert.match(source, /auto_activate = TRUE/); assert.match(source, /tryCatch\(/); assert.match(source, /use_rstudioapi = (TRUE|FALSE)/); + assert.match(source, /tool_consent_dir <- "[^"]+"/); + assert.match(source, /tool_root <- "[^"]+"/); + 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, /jgd_can_prompt <- (?:TRUE|FALSE)/); + assert.match(source, /jgd_helper = jgd_helper/); + assert.match(source, /jgd_can_prompt = jgd_can_prompt/); + assert.ok( + source.includes(`jgd_minimum_version = ${JSON.stringify(JGD_MINIMUM_VERSION)}`), + ); const backgroundSource = await fsp.readFile(backgroundPreparation.scriptPath, 'utf8'); assert.ok( backgroundSource.includes( @@ -1243,6 +1257,7 @@ suite('Session Communication', () => { if (!oldPreparation) { return; } + const oldToolDirectory = await session.getToolConsentDirectory(); let enterStop!: () => void; let releaseStop!: () => void; @@ -1279,8 +1294,12 @@ suite('Session Communication', () => { await oldShutdown; return; } + const newToolDirectory = await session.getToolConsentDirectory(); + assert.notStrictEqual(newToolDirectory, oldToolDirectory); releaseStop(); await oldShutdown; + assert.ok(await pathExists(newToolDirectory)); + assert.strictEqual(await pathExists(oldToolDirectory), false); assert.ok( await pathExists(newPreparation.scriptPath), 'old shutdown must not remove a later preparation', @@ -1351,6 +1370,36 @@ suite('Session Communication', () => { ]); }); + test('a failed optional tool consent service does not prevent sess attachment preparation', async () => { + sandbox.stub(extension, 'enableSessionWatcher').value(true); + sandbox + .stub(ToolConsentService.prototype, 'start') + .rejects(new Error('tool directory unavailable')); + const preparation = await session.prepareAttachment({ + connection: { transport: 'sess', endpoint: localSessEndpoint('tool-unavailable') }, + }); + assert.ok(preparation); + const source = await fsp.readFile(preparation!.scriptPath, 'utf8'); + assert.match(source, /tool_consent_dir <- ""/); + assert.match(source, /vscode_r_attach_sess\(/); + preparation!.dispose(); + }); + + 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(); + assert.ok(sessDirectory); + assert.ok(toolDirectory); + assert.notStrictEqual(sessDirectory, toolDirectory); + assert.ok(await pathExists(sessDirectory)); + assert.ok(await pathExists(toolDirectory)); + + await session.shutdownSessionWatcher(); + + assert.strictEqual(await pathExists(sessDirectory), false); + assert.strictEqual(await pathExists(toolDirectory), false); + }); + test('reconnecting terminals preserve the selected terminal in either attach order', async () => { const endpoint = await session.getGlobalPipePath(); const selected = { processId: Promise.resolve(46240) }; @@ -1421,7 +1470,7 @@ suite('Session Communication', () => { const scriptContent = await fsp.readFile(scriptPath, 'utf8'); assert.match( scriptContent, - /vscode_r_attach_sess\(endpoint, sess_src, managed_root, consent_dir, source_helper, installer_helper, "native", use_rstudioapi = (?:TRUE|FALSE)\)/, + /vscode_r_attach_sess\(endpoint, sess_src, managed_root, consent_dir, source_helper, installer_helper, "native", .*use_rstudioapi = (?:TRUE|FALSE)\)/, ); assert.doesNotMatch(scriptContent, /Sys\.(?:setenv|unsetenv)\(JGD_SOCKET/); }); diff --git a/src/test/suite/sessionTerminalLifecycle.test.ts b/src/test/suite/sessionTerminalLifecycle.test.ts index 557beb314..014c7e218 100644 --- a/src/test/suite/sessionTerminalLifecycle.test.ts +++ b/src/test/suite/sessionTerminalLifecycle.test.ts @@ -132,6 +132,7 @@ suite('Session Terminal Lifecycle', () => { } as unknown as vscode.WorkspaceConfiguration); sandbox.stub(util, 'getRterm').resolves(process.execPath); sandbox.stub(session, 'getSessConsentDirectory').resolves('/unused-test-consent'); + sandbox.stub(session, 'getToolConsentDirectory').resolves('/unused-tool-consent'); sandbox.stub(session, 'createSessionDiscoveryFile').resolves(discoveryFile); sandbox.stub(session, 'updateTerminalSessionDiscoveryFile').resolves(); sandbox.stub(vscode.window, 'terminals').value([terminal]); diff --git a/src/test/suite/terminal.test.ts b/src/test/suite/terminal.test.ts index 975c7e9bc..2c92b3550 100644 --- a/src/test/suite/terminal.test.ts +++ b/src/test/suite/terminal.test.ts @@ -1,3 +1,4 @@ +import { JGD_MINIMUM_VERSION } from '../../jgdVersion'; import * as vscode from 'vscode'; import * as sinon from 'sinon'; import * as assert from 'assert'; @@ -75,6 +76,7 @@ suite('R Terminal', () => { consentDirectoryStub = sandbox .stub(session, 'getSessConsentDirectory') .resolves('/unused-test-consent'); + sandbox.stub(session, 'getToolConsentDirectory').resolves('/unused-tool-consent'); session.deploySessionWatcher(extension_root); await session.getGlobalPipePath(); }); @@ -838,6 +840,27 @@ suite('R Terminal', () => { const options = createTerminal.firstCall.args[0] as vscode.TerminalOptions; assert.strictEqual(options.shellPath, process.execPath); assert.strictEqual(options.env?.['VSCODE_R_SESS_CONSENT_DIRECTORY'], '/target-R-consent'); + assert.strictEqual( + options.env?.['VSCODE_R_TOOL_CONSENT_DIRECTORY'], + '/unused-tool-consent', + ); + assert.strictEqual( + options.env?.['VSCODE_R_TOOL_ROOT'], + extension.extensionContext.globalStorageUri.fsPath, + ); + assert.ok( + options.env?.['VSCODE_R_TOOL_LIBRARY_HELPER']?.endsWith( + path.join('R', 'tool-library.R'), + ), + ); + assert.ok( + options.env?.['VSCODE_R_TOOL_CONSENT_HELPER']?.endsWith( + 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.strictEqual(options.env?.['VSCODE_R_JGD_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 new file mode 100644 index 000000000..67ad3a149 --- /dev/null +++ b/src/test/suite/toolConsentAdapter.test.ts @@ -0,0 +1,88 @@ +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'; + +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], + ]); + 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); + prompt.onSecondCall().resolves('Install package' as never); + const broker = createToolConsentService(directory, () => true, globalState, 5); + try { + await broker.start(); + assert.strictEqual(broker.canPrompt('auto'), false); + assert.strictEqual(broker.canPrompt('jgd'), 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()) { + const id = String(index + 1).repeat(32); + const content = [ + 'vscode-r-package-consent-v1', + id, + 'linux-x86_64|4.5', + backend, + '1', + 'jgd', + JGD_MINIMUM_VERSION, + '1.2.0', + 'https://cloud.r-project.org', + backend === 'auto' ? 'source' : 'win.binary', + backend === 'auto' ? 'yes' : 'no', + '', + ].join('\n'); + await fs.writeFile(path.join(directory, id + '.request'), content); + const response = path.join(directory, id + '.response'); + const deadline = Date.now() + 3000; + while (true) { + try { + assert.strictEqual( + await fs.readFile(response, 'utf8'), + backend === 'auto' ? 'decline\n' : 'approve\n', + ); + break; + } catch (error) { + if ( + (error as NodeJS.ErrnoException).code !== 'ENOENT' || + Date.now() > deadline + ) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + } + assert.deepStrictEqual(update.args, [ + ['jgdInstallPromptDismissedMinimumVersion', JGD_MINIMUM_VERSION], + ]); + assert.strictEqual(broker.canPrompt('auto'), false); + assert.strictEqual(broker.canPrompt('jgd'), true); + assert.strictEqual(prompt.callCount, 2); + assert.ok(prompt.firstCall.args[0].includes('(source; minimum ')); + assert.ok(prompt.secondCall.args[0].includes('(binary; minimum ')); + } finally { + await broker.stop(); + sandbox.restore(); + await fs.rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/src/toolConsent.ts b/src/toolConsent.ts new file mode 100644 index 000000000..02ef238fa --- /dev/null +++ b/src/toolConsent.ts @@ -0,0 +1,182 @@ +import { ConsentResponse, FileConsentService } from './fileConsent'; + +export type ToolConsentBackend = 'jgd' | 'auto'; +export type ToolConsentChoice = 'install' | 'notNow' | 'dontAskAgain' | 'dismiss'; + +export interface ToolConsentSessionState { + autoPrompted: boolean; +} + +export interface ToolPackagePlan { + name: string; + minimumVersion: string; + version: string; + repository: string; + packageType: string; + sourceBuild: boolean; +} + +export interface ToolConsentRequest { + id: string; + runtime: string; + backend: ToolConsentBackend; + packages: ToolPackagePlan[]; +} + +export interface ToolConsentServiceOptions { + directory: string; + isEnabled: () => boolean; + isAutoPromptEnabled: () => boolean; + sessionState: ToolConsentSessionState; + rememberAutoPromptDismissal: () => Promise; + prompt: (request: ToolConsentRequest) => Promise; + intervalMs?: number; +} + +const REQUEST_NAME = /^[A-Za-z0-9_-]{1,64}\.request$/; +const RUNTIME = /^[A-Za-z0-9_.-]+\|[0-9]+\.[0-9]+$/; +const PACKAGE_NAME = /^[A-Za-z][A-Za-z0-9.]*$/; +const PACKAGE_VERSION = /^[0-9]+(?:[.-][0-9]+)*$/; +const PACKAGE_TYPE = /^[A-Za-z0-9_.-]+$/; +const MAX_REQUEST_BYTES = 16 * 1024; +const MAX_PACKAGES = 16; + +export function parseToolConsentRequest(id: string, content: string): ToolConsentRequest { + if (Buffer.byteLength(content, 'utf8') > MAX_REQUEST_BYTES || !content.endsWith('\n')) { + throw new Error('Invalid package consent request size or termination.'); + } + const normalized = content.replace(/\r\n/g, '\n'); + if (normalized.includes('\r')) { + throw new Error('Invalid package consent line ending.'); + } + const lines = normalized.slice(0, -1).split('\n'); + if (lines.length < 5 || lines[0] !== 'vscode-r-package-consent-v1' || lines[1] !== id) { + throw new Error('Malformed package consent request.'); + } + const runtime = lines[2]; + const backend = lines[3]; + const count = Number(lines[4]); + if ( + !RUNTIME.test(runtime) || + (backend !== 'jgd' && backend !== 'auto') || + !/^(?:[1-9]|1[0-6])$/.test(lines[4]) || + count < 1 || + count > MAX_PACKAGES || + lines.length !== 5 + count * 6 + ) { + throw new Error('Malformed package consent request header.'); + } + const packages: ToolPackagePlan[] = []; + for (let offset = 5; offset < lines.length; offset += 6) { + const [name, minimumVersion, version, repository, packageType, sourceBuild] = lines.slice( + offset, + offset + 6, + ); + if ( + !PACKAGE_NAME.test(name) || + !PACKAGE_VERSION.test(minimumVersion) || + !PACKAGE_VERSION.test(version) || + Buffer.byteLength(repository, 'utf8') > 2048 || + !repository || + // eslint-disable-next-line no-control-regex -- repository values must not contain control characters + /[\u0000-\u001f\u007f]/.test(repository) || + !PACKAGE_TYPE.test(packageType) || + (sourceBuild !== 'yes' && sourceBuild !== 'no') + ) { + throw new Error('Malformed package consent request package entry.'); + } + packages.push({ + name, + minimumVersion, + version, + repository, + packageType, + sourceBuild: sourceBuild === 'yes', + }); + } + return { id, runtime, backend, packages }; +} + +/** Bridges a fixed R package plan to a one-time VS Code decision. Use a directory dedicated to this service. */ +export class ToolConsentService { + private readonly bridge: FileConsentService; + private running = false; + + constructor(private readonly options: ToolConsentServiceOptions) { + this.bridge = new FileConsentService({ + directory: options.directory, + requestName: REQUEST_NAME, + maxBytes: MAX_REQUEST_BYTES, + intervalMs: options.intervalMs, + isEnabled: options.isEnabled, + parseRequest: parseToolConsentRequest, + processRequest: (request, respond) => this.processRequest(request, respond), + }); + } + + start(): Promise { + this.running = true; + return this.bridge.start(); + } + + stop(): Promise { + this.running = false; + return this.bridge.stop(); + } + + canPrompt(backend: string): boolean { + if (!this.running || !this.options.isEnabled()) { + return false; + } + if (backend === 'jgd') { + return true; + } + return ( + backend === 'auto' && + this.options.isAutoPromptEnabled() && + !this.options.sessionState.autoPrompted + ); + } + + 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)) { + await respond('decline'); + return; + } + if (automatic) { + this.options.sessionState.autoPrompted = true; + } + if (!this.running || !this.options.isEnabled()) { + await respond('decline'); + return; + } + let choice: ToolConsentChoice = 'dismiss'; + try { + choice = await this.options.prompt(request); + } catch { + choice = 'dismiss'; + } + if (!this.running || !this.options.isEnabled()) { + await respond('decline'); + return; + } + if (automatic && choice === 'dontAskAgain') { + try { + await this.options.rememberAutoPromptDismissal(); + } catch { + await respond('decline'); + return; + } + } + const accepted = choice === 'install' && this.running && this.options.isEnabled(); + await respond(accepted ? 'approve' : 'decline'); + } +} diff --git a/src/toolConsentAdapter.ts b/src/toolConsentAdapter.ts new file mode 100644 index 000000000..bd120034e --- /dev/null +++ b/src/toolConsentAdapter.ts @@ -0,0 +1,75 @@ +import * as vscode from 'vscode'; +import { JGD_MINIMUM_VERSION } from './jgdVersion'; +import { + ToolConsentChoice, + ToolConsentRequest, + ToolConsentService, + ToolConsentSessionState, +} from './toolConsent'; + +const JGD_INSTALL_PROMPT_DISMISSED_MINIMUM_VERSION = 'jgdInstallPromptDismissedMinimumVersion'; +// Shared by services in this extension host; a new host starts with a fresh state. +const hostSessionState: ToolConsentSessionState = { autoPrompted: false }; + +function formatPlan(request: ToolConsentRequest): string { + const [platform, version] = request.runtime.split('|'); + const rows = request.packages.map( + (item) => + `• ${item.name} ${item.version} (${item.packageType === 'source' ? 'source' : 'binary'}; minimum ${item.minimumVersion})\n Repository: ${item.repository}`, + ); + 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.`; +} + +export function createToolConsentService( + directory: string, + isEnabled: () => boolean, + globalState: vscode.Memento, + intervalMs?: number, +): ToolConsentService { + return new ToolConsentService({ + directory, + isEnabled, + isAutoPromptEnabled: () => + globalState.get(JGD_INSTALL_PROMPT_DISMISSED_MINIMUM_VERSION) !== + JGD_MINIMUM_VERSION, + sessionState: hostSessionState, + rememberAutoPromptDismissal: async () => { + await globalState.update( + JGD_INSTALL_PROMPT_DISMISSED_MINIMUM_VERSION, + JGD_MINIMUM_VERSION, + ); + }, + intervalMs, + prompt: async (request): Promise => { + const install = 'Install package'; + const notNow = 'Not now'; + const dontAskAgain = "Don't ask again"; + const choice = + request.backend === 'auto' + ? await vscode.window.showWarningMessage( + formatPlan(request), + install, + notNow, + dontAskAgain, + ) + : await vscode.window.showWarningMessage(formatPlan(request), install, notNow); + if (choice === install) { + return 'install'; + } + if (choice === notNow) { + return 'notNow'; + } + if (choice === dontAskAgain) { + return 'dontAskAgain'; + } + return 'dismiss'; + }, + }); +}