From 9b7f401f6ca0dc97d04ade479d283504b18c4c7b Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 01:39:59 +0000 Subject: [PATCH 01/21] feat: add managed tool library helpers --- R/tool-library.R | 244 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 R/tool-library.R diff --git a/R/tool-library.R b/R/tool-library.R new file mode 100644 index 000000000..38261fb6c --- /dev/null +++ b/R/tool-library.R @@ -0,0 +1,244 @@ +# 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) +} + +# Install only after the caller has obtained consent. The repository is selected +# by the R session; callers cannot supply package archives or arbitrary URLs. +# 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. +tool_library_install <- function(package, minimum_version, root, + repos = getOption("repos"), timeout_seconds = 300) { + .tool_validate_request(package, minimum_version) + initial <- tool_library_check(package, minimum_version, root) + if (!initial$install_required) return(invisible(initial)) + 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") + 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), + 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] + library <- initial$library + .tool_with_library_lock(library, { + current <- tool_library_check(package, minimum_version, root) + if (!current$install_required) return(invisible(current)) + 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.") + } + 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 = repositories, + available = available, dependencies = FALSE, + type = package_type, quiet = TRUE) + version <- .tool_version_at(package, library) + if (!.tool_version_ok(version, minimum_version)) { + stop("The repository version does not satisfy the requested minimum version.") + } + .tool_verify_child(package, minimum_version, library) + invisible(list(package = package, version = version, source = "managed", + install_required = FALSE, library = library)) + }, error = function(error) { + stop("Could not update the managed ", package, + " package. Close other R sessions using it and try again. ", + 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 <- loadNamespace(package, lib.loc = selected$library) + 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 +} From 373b2714ac424b03569c10467eb6e8e8cfdbe376 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 01:40:17 +0000 Subject: [PATCH 02/21] test: cover managed tool library installation and loading --- .github/workflows/main.yml | 2 + R/tests/tool-library.R | 144 +++++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 147 insertions(+) create mode 100644 R/tests/tool-library.R diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index af863e8bb..0568d1bc5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -38,6 +38,8 @@ 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: Install R test dependencies run: install.packages(c("remotes", "pkgbuild", "languageserver", "lintr", "renv")) shell: Rscript {0} diff --git a/R/tests/tool-library.R b/R/tests/tool-library.R new file mode 100644 index 000000000..a6ba341cb --- /dev/null +++ b/R/tests/tool-library.R @@ -0,0 +1,144 @@ +# Base-R coverage for the managed tool library. All packages are local fixtures. +source("R/tool-library.R") + +local({ + root <- tempfile("tool-library-") + dir.create(root) + 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://", 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"))) + + 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") + 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"))) + 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/package.json b/package.json index ecab96194..1f65866de 100644 --- a/package.json +++ b/package.json @@ -2610,6 +2610,7 @@ "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:interactive": "pnpm run pretest && pnpm run test:node", "test:interactive:examples": "pnpm run compile && tsc && node src/test/examples/run.cjs" }, From b832d7c5b7960dccdc6ec5989adbf6a34edf59aa Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 01:46:09 +0000 Subject: [PATCH 03/21] fix: distinguish tool installation and update failures --- R/tests/tool-library.R | 19 +++++++++++++++++++ R/tool-library.R | 6 ++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/R/tests/tool-library.R b/R/tests/tool-library.R index a6ba341cb..fac22e926 100644 --- a/R/tests/tool-library.R +++ b/R/tests/tool-library.R @@ -90,6 +90,25 @@ local({ # 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"))) diff --git a/R/tool-library.R b/R/tool-library.R index 38261fb6c..f73b70741 100644 --- a/R/tool-library.R +++ b/R/tool-library.R @@ -200,6 +200,7 @@ tool_library_install <- function(package, minimum_version, root, stop("Refusing to install ", package, " because it has non-base package dependencies.") } + 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({ @@ -214,8 +215,9 @@ tool_library_install <- function(package, minimum_version, root, invisible(list(package = package, version = version, source = "managed", install_required = FALSE, library = library)) }, error = function(error) { - stop("Could not update the managed ", package, - " package. Close other R sessions using it and try again. ", + 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) From d87efc637cc692bea0614bd51cde214046bc7ece Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 01:47:27 +0000 Subject: [PATCH 04/21] test: normalize managed tool library fixture paths --- R/tests/tool-library.R | 1 + 1 file changed, 1 insertion(+) diff --git a/R/tests/tool-library.R b/R/tests/tool-library.R index fac22e926..501356cb3 100644 --- a/R/tests/tool-library.R +++ b/R/tests/tool-library.R @@ -4,6 +4,7 @@ source("R/tool-library.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) From 64a012433c609ca33684d0c28e60a4baa8649e78 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 02:00:25 +0000 Subject: [PATCH 05/21] refactor: share file-based installation consent transport --- src/fileConsent.ts | 183 ++++++++++++++++++++++++++++++++++++++++++ src/sessConsent.ts | 196 ++++++++------------------------------------- 2 files changed, 216 insertions(+), 163 deletions(-) create mode 100644 src/fileConsent.ts diff --git a/src/fileConsent.ts b/src/fileConsent.ts new file mode 100644 index 000000000..1074a3bc7 --- /dev/null +++ b/src/fileConsent.ts @@ -0,0 +1,183 @@ +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/sessConsent.ts b/src/sessConsent.ts index 3b6426738..0494036be 100644 --- a/src/sessConsent.ts +++ b/src/sessConsent.ts @@ -1,7 +1,4 @@ -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'; +import { ConsentResponse, FileConsentService } from './fileConsent'; export type SessConsentReason = 'missing' | 'mismatch'; @@ -28,101 +25,44 @@ const REQUEST_NAME = /^[A-Za-z0-9_-]{1,64}\.request$/; const REVISION = /^git-tree:(?:[a-f0-9]{40}|[a-f0-9]{64})$/; const RUNTIME = /^[A-Za-z0-9_.-]+\|[0-9]+\.[0-9]+$/; -/** Bridges the target R process's bounded consent request to a VS Code prompt. */ +/** Bridges sess requests to VS Code. Use a directory dedicated to this service. */ export class SessConsentService { - private timer: NodeJS.Timeout | undefined; - private stopped = true; - private readonly pending = new Map>(); - 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,23 @@ export class SessConsentService { } catch { choice = 'dismiss'; } - if (this.stopped) { - return; - } + 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) { - return; - } - await this.writeResponse(id, 'decline'); + if (!this.running) return; + 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 +115,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); - } - } } From 1b6c96534d3f5d5cda9b9df102e63caf71d557c0 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 02:00:33 +0000 Subject: [PATCH 06/21] feat: request consent for managed tool installation plans --- .github/workflows/main.yml | 2 + R/tests/tool-consent.R | 142 ++++++++++++++++++ R/tests/tool-library.R | 22 +++ R/tool-consent.R | 129 ++++++++++++++++ R/tool-library.R | 115 ++++++++++++--- package.json | 8 + src/test/node/toolConsent.test.ts | 236 ++++++++++++++++++++++++++++++ src/toolConsent.ts | 166 +++++++++++++++++++++ src/toolConsentAdapter.ts | 60 ++++++++ 9 files changed, 862 insertions(+), 18 deletions(-) create mode 100644 R/tests/tool-consent.R create mode 100644 R/tool-consent.R create mode 100644 src/test/node/toolConsent.test.ts create mode 100644 src/toolConsent.ts create mode 100644 src/toolConsentAdapter.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0568d1bc5..b21fe2d7f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -40,6 +40,8 @@ jobs: 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} 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 index 501356cb3..c0c2e3de6 100644 --- a/R/tests/tool-library.R +++ b/R/tests/tool-library.R @@ -1,5 +1,6 @@ # 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-") @@ -113,6 +114,27 @@ local({ 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")) + invalid_plan <- frozen_plan + invalid_plan$version <- "1.3.0" + stopifnot(inherits(tryCatch(.tool_validate_install_plan(invalid_plan, "jgd", "1.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)) diff --git a/R/tool-consent.R b/R/tool-consent.R new file mode 100644 index 000000000..8580e057e --- /dev/null +++ b/R/tool-consent.R @@ -0,0 +1,129 @@ +# 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) + } + .tool_validate_install_plan(plan, plan$package, plan$minimum_version, plan$root) + 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) { + available <- tool_library_check(package, minimum_version, root) + if (!available$install_required) return(invisible(available)) + plan <- 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 index f73b70741..516b3b866 100644 --- a/R/tool-library.R +++ b/R/tool-library.R @@ -148,22 +148,20 @@ tool_library_check <- function(package, minimum_version, root) { invisible(TRUE) } -# Install only after the caller has obtained consent. The repository is selected -# by the R session; callers cannot supply package archives or arbitrary URLs. -# 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. -tool_library_install <- function(package, minimum_version, root, - repos = getOption("repos"), timeout_seconds = 300) { +# 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(invisible(initial)) + 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") @@ -171,7 +169,8 @@ tool_library_install <- function(package, minimum_version, root, package_type } candidates <- lapply(package_types, function(type) { - available <- tryCatch(utils::available.packages(repos = repositories, type = type), + available <- tryCatch(utils::available.packages(repos = repositories, type = type, + fields = "NeedsCompilation"), error = function(e) matrix(character(), nrow = 0L)) list(type = type, available = available) }) @@ -191,25 +190,105 @@ tool_library_install <- function(package, minimum_version, root, package_type <- selected$type available <- selected$available row <- available[package, , drop = TRUE] - library <- initial$library + 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) || + !identical(plan$library, tool_library_path(root)) || + !.tool_version_ok(plan$version, minimum_version) || + !is.matrix(plan$available) || !package %in% rownames(plan$available)) { + stop("The approved tool installation plan is invalid or does not match the request.", call. = FALSE) + } + row <- plan$available[package, , drop = TRUE] + if (!is.character(plan$repositories) || !length(plan$repositories) || + anyNA(plan$repositories) || any(!nzchar(plan$repositories)) || + !grepl("^(source|win\\.binary|mac\\.binary(\\.[A-Za-z0-9_-]+)?)$", + plan$package_type) || + !.tool_dependencies_allowed(as.data.frame(as.list(row), stringsAsFactors = FALSE))) { + stop("The approved tool installation plan metadata is inconsistent.", call. = FALSE) + } + repository <- if ("Repository" %in% names(row) && !is.na(row[["Repository"]])) { + unname(row[["Repository"]]) + } else { + unname(plan$repositories[[1L]]) + } + if (!identical(unname(row[["Version"]]), plan$version) || + !identical(plan$repository, repository)) { + stop("The approved tool installation plan metadata is inconsistent.", call. = FALSE) + } + needs_compilation <- if ("NeedsCompilation" %in% names(row)) row[["NeedsCompilation"]] else NA_character_ + expected_source_build <- identical(plan$package_type, "source") && + (is.na(needs_compilation) || !nzchar(needs_compilation) || + identical(tolower(needs_compilation), "yes")) + if (!identical(plan$source_build, expected_source_build)) { + stop("The approved tool installation plan build metadata is inconsistent.", 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)) - 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.") - } + .tool_validate_install_plan(plan, package, minimum_version, root) 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 = repositories, + 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 (!.tool_version_ok(version, minimum_version)) { - stop("The repository version does not satisfy the requested minimum version.") + 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", diff --git a/package.json b/package.json index 1f65866de..e6b313a92 100644 --- a/package.json +++ b/package.json @@ -2311,6 +2311,13 @@ ], "markdownDescription": "The canonical setting for selecting the plot backend. `standard` uses the VS Code static plot viewer; `native` leaves R's configured graphics device untouched.\n\nWhen set to `auto`, the best available backend is used (JGD if installed, then httpgd, then standard). For backwards compatibility, setting `#r.plot.useHttpgd#` to `true` selects httpgd while this setting remains `auto`; workspace settings take precedence over user settings, with an explicit backend value preferred within the same scope." }, + "r.plot.jgd.installationPrompt": { + "type": "boolean", + "default": true, + "scope": "application", + "ignoreSync": true, + "markdownDescription": "Show the one-time prompt to install an optional package when `#r.plot.backend#` is `auto`. vscode-R turns this off after the prompt is shown. Set this to `true` in User Settings to show the automatic prompt again. Explicitly selecting the `jgd` backend prompts whenever installation is needed, regardless of this setting." + }, "r.plot.jgd.historyLimit": { "type": "number", "default": 50, @@ -2611,6 +2618,7 @@ "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:interactive": "pnpm run pretest && pnpm run test:node", "test:interactive:examples": "pnpm run compile && tsc && node src/test/examples/run.cjs" }, diff --git a/src/test/node/toolConsent.test.ts b/src/test/node/toolConsent.test.ts new file mode 100644 index 000000000..5866a7ba8 --- /dev/null +++ b/src/test/node/toolConsent.test.ts @@ -0,0 +1,236 @@ +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[]; + const services: ToolConsentService[] = []; + + setup(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), 'vscode-r-package-consent-')); + autoPromptEnabled = true; + failPreferenceSave = false; + choice = 'install'; + prompts = []; + }); + + 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): ToolConsentService { + const result = new ToolConsentService({ + directory, + isEnabled: () => true, + isAutoPromptEnabled: () => autoPromptEnabled, + rememberAutoPromptShown: async () => { + if (failPreferenceSave) throw new Error('settings unavailable'); + autoPromptEnabled = false; + }, + 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): Promise { + const request = path.join(directory, `${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(); + 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 remembered, explicit jgd still prompts, and settings can reset it', async () => { + const first = service(); + await first.start(); + const firstId = '3'.repeat(32); + await writeRequest(firstId, 'auto'); + assert.strictEqual(await waitForFile(path.join(directory, `${firstId}.response`)), 'approve\n'); + assert.strictEqual(autoPromptEnabled, false); + await first.stop(); + + await fs.rm(path.join(directory, `${firstId}.request`), { force: true }); + await fs.rm(path.join(directory, `${firstId}.response`), { force: true }); + const restarted = service(); + await restarted.start(); + const secondId = '4'.repeat(32); + await writeRequest(secondId, 'auto'); + assert.strictEqual(await waitForFile(path.join(directory, `${secondId}.response`)), 'decline\n'); + assert.strictEqual(prompts.length, 1); + + const explicitId = '5'.repeat(32); + await writeRequest(explicitId, 'jgd'); + assert.strictEqual(await waitForFile(path.join(directory, `${explicitId}.response`)), 'approve\n'); + assert.strictEqual(prompts.length, 2); + assert.strictEqual(autoPromptEnabled, false); + + autoPromptEnabled = true; + const resetId = '6'.repeat(32); + await writeRequest(resetId, 'auto'); + assert.strictEqual(await waitForFile(path.join(directory, `${resetId}.response`)), 'approve\n'); + assert.strictEqual(prompts.length, 3); + }); + + 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 secondResponse = waitForFile(path.join(directory, `${secondId}.response`)); + const deadline = Date.now() + 3000; + while (prompts.length === 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.strictEqual(prompts.length, 1); + assert.strictEqual(prompts[0].id, firstId); + assert.strictEqual(await secondResponse, 'decline\n'); + await fs.writeFile(path.join(directory, `${firstId}.request`), requestContent(firstId, 'auto', '1.3.0')); + finishPrompt('install'); + assert.strictEqual(await waitForFile(path.join(directory, `${firstId}.response`)), 'decline\n'); + }); + + test('automatic prompt is skipped when persisting the one-time setting fails', async () => { + failPreferenceSave = true; + 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, 0); + assert.strictEqual(autoPromptEnabled, true); + }); + + test('declined automatic choices are remembered without suppressing explicit jgd prompts', async () => { + const broker = service(); + await broker.start(); + const automaticChoices: ToolConsentChoice[] = ['notNow', 'dismiss', 'dontAskAgain']; + for (const [index, automaticChoice] of automaticChoices.entries()) { + autoPromptEnabled = true; + choice = automaticChoice; + const automaticId = String(index + 1).repeat(31) + String(index + 1); + await writeRequest(automaticId, 'auto'); + assert.strictEqual( + await waitForFile(path.join(directory, `${automaticId}.response`)), + 'decline\n', + ); + assert.strictEqual(autoPromptEnabled, false); + + choice = 'install'; + const explicitId = String(index + 4).repeat(31) + String(index + 4); + await writeRequest(explicitId, 'jgd'); + assert.strictEqual( + await waitForFile(path.join(directory, `${explicitId}.response`)), + 'approve\n', + ); + } + assert.deepStrictEqual(prompts.map((request) => request.backend), [ + 'auto', 'jgd', 'auto', 'jgd', 'auto', 'jgd', + ]); + }); + + test('shutdown during automatic preference save never opens the prompt', async () => { + let finishSave!: () => void; + let saveStarted!: () => void; + const started = new Promise((resolve) => { saveStarted = resolve; }); + const broker = new ToolConsentService({ + directory, + isEnabled: () => true, + isAutoPromptEnabled: () => true, + rememberAutoPromptShown: () => new Promise((resolve) => { + finishSave = resolve; + saveStarted(); + }), + prompt: async (request) => { + prompts.push(request); + return 'install'; + }, + intervalMs: 5, + }); + services.push(broker); + await broker.start(); + const id = 'a'.repeat(32); + await writeRequest(id, 'auto'); + await started; + await broker.stop(); + finishSave(); + assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'decline\n'); + assert.strictEqual(prompts.length, 0); + }); +}); diff --git a/src/toolConsent.ts b/src/toolConsent.ts new file mode 100644 index 000000000..0a697171c --- /dev/null +++ b/src/toolConsent.ts @@ -0,0 +1,166 @@ +import { ConsentResponse, FileConsentService } from './fileConsent'; + +export type ToolConsentBackend = 'jgd' | 'auto'; +export type ToolConsentChoice = 'install' | 'notNow' | 'dontAskAgain' | 'dismiss'; + +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; + rememberAutoPromptShown: () => 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 || + /[\u0000-\u001f\u007f]/.test(repository) || + comparePackageVersions(version, minimumVersion) < 0 || + !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 }; +} + +function comparePackageVersions(left: string, right: string): number { + const parse = (version: string) => version.split(/[.-]/).map(Number); + const a = parse(left); + const b = parse(right); + for (let index = 0; index < Math.max(a.length, b.length); index++) { + const difference = (a[index] ?? 0) - (b[index] ?? 0); + if (difference !== 0) return difference; + } + return 0; +} + +/** 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 autoPromptInFlight = false; + 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(); + } + + 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 (automatic && (!this.options.isAutoPromptEnabled() || this.autoPromptInFlight)) { + await respond('decline'); + return; + } + if (automatic) this.autoPromptInFlight = true; + if (automatic) { + try { + await this.options.rememberAutoPromptShown(); + } catch { + this.autoPromptInFlight = false; + await respond('decline'); + return; + } + } + if (!this.running || !this.options.isEnabled()) { + if (automatic) this.autoPromptInFlight = false; + await respond('decline'); + return; + } + let choice: ToolConsentChoice = 'dismiss'; + try { + choice = await this.options.prompt(request); + } catch { + choice = 'dismiss'; + } + if (!this.running || !this.options.isEnabled()) { + if (automatic) this.autoPromptInFlight = false; + await respond('decline'); + return; + } + if (automatic) { + this.autoPromptInFlight = false; + } + 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..bed84c962 --- /dev/null +++ b/src/toolConsentAdapter.ts @@ -0,0 +1,60 @@ +import * as vscode from 'vscode'; +import { ToolConsentChoice, ToolConsentRequest, ToolConsentService } from './toolConsent'; + +const AUTO_PROMPT_SETTING = 'plot.jgd.installationPrompt'; + +function formatPlan(request: ToolConsentRequest): string { + const rows = request.packages.map( + (item) => `• ${item.name} ${item.version} (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 ${request.runtime}?\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, + intervalMs?: number, +): ToolConsentService { + return new ToolConsentService({ + directory, + isEnabled, + isAutoPromptEnabled: () => + vscode.workspace.getConfiguration('r').get(AUTO_PROMPT_SETTING, true), + rememberAutoPromptShown: async () => { + await vscode.workspace.getConfiguration('r').update( + AUTO_PROMPT_SETTING, + false, + vscode.ConfigurationTarget.Global, + ); + }, + 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'; + }, + }); +} + +/** Set `r.plot.jgd.installationPrompt` to true to show automatic prompts again. */ +export async function resetAutomaticToolPrompt(): Promise { + await vscode.workspace.getConfiguration('r').update( + AUTO_PROMPT_SETTING, + true, + vscode.ConfigurationTarget.Global, + ); +} + From b868ece4ca44b57cef612005d3d7ea52eedee6d1 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 02:03:31 +0000 Subject: [PATCH 07/21] fix: normalize repository URLs and simplify tool plan validation --- R/tests/tool-library.R | 6 ++---- R/tool-consent.R | 1 - R/tool-library.R | 30 +----------------------------- 3 files changed, 3 insertions(+), 34 deletions(-) diff --git a/R/tests/tool-library.R b/R/tests/tool-library.R index c0c2e3de6..123a6a965 100644 --- a/R/tests/tool-library.R +++ b/R/tests/tool-library.R @@ -44,7 +44,7 @@ local({ 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://", normalizePath(file.path(root, "repo"), winslash = "/")) + 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) @@ -130,9 +130,7 @@ local({ frozen_install <- tool_library_install("jgd", "1.0.0", root_path, repos = character(), plan = frozen_plan) stopifnot(identical(frozen_install$version, "1.2.0")) - invalid_plan <- frozen_plan - invalid_plan$version <- "1.3.0" - stopifnot(inherits(tryCatch(.tool_validate_install_plan(invalid_plan, "jgd", "1.0.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)) diff --git a/R/tool-consent.R b/R/tool-consent.R index 8580e057e..1d0834844 100644 --- a/R/tool-consent.R +++ b/R/tool-consent.R @@ -9,7 +9,6 @@ !(is.character(backend) && length(backend) == 1L && backend %in% c("jgd", "auto"))) { stop("Invalid managed tool consent request.", call. = FALSE) } - .tool_validate_install_plan(plan, plan$package, plan$minimum_version, plan$root) runtime_minor <- strsplit(R.version$minor, ".", fixed = TRUE)[[1L]][1L] runtime <- paste(R.version$platform, paste(R.version$major, runtime_minor, sep = "."), sep = "|") diff --git a/R/tool-library.R b/R/tool-library.R index 516b3b866..4b74fcb87 100644 --- a/R/tool-library.R +++ b/R/tool-library.R @@ -223,36 +223,9 @@ tool_library_plan <- function(package, minimum_version, root, repos = getOption( if (!inherits(plan, "vscode_r_tool_install_plan") || !identical(plan$package, package) || !identical(plan$minimum_version, minimum_version) || - !identical(plan$root, root) || - !identical(plan$library, tool_library_path(root)) || - !.tool_version_ok(plan$version, minimum_version) || - !is.matrix(plan$available) || !package %in% rownames(plan$available)) { + !identical(plan$root, root)) { stop("The approved tool installation plan is invalid or does not match the request.", call. = FALSE) } - row <- plan$available[package, , drop = TRUE] - if (!is.character(plan$repositories) || !length(plan$repositories) || - anyNA(plan$repositories) || any(!nzchar(plan$repositories)) || - !grepl("^(source|win\\.binary|mac\\.binary(\\.[A-Za-z0-9_-]+)?)$", - plan$package_type) || - !.tool_dependencies_allowed(as.data.frame(as.list(row), stringsAsFactors = FALSE))) { - stop("The approved tool installation plan metadata is inconsistent.", call. = FALSE) - } - repository <- if ("Repository" %in% names(row) && !is.na(row[["Repository"]])) { - unname(row[["Repository"]]) - } else { - unname(plan$repositories[[1L]]) - } - if (!identical(unname(row[["Version"]]), plan$version) || - !identical(plan$repository, repository)) { - stop("The approved tool installation plan metadata is inconsistent.", call. = FALSE) - } - needs_compilation <- if ("NeedsCompilation" %in% names(row)) row[["NeedsCompilation"]] else NA_character_ - expected_source_build <- identical(plan$package_type, "source") && - (is.na(needs_compilation) || !nzchar(needs_compilation) || - identical(tolower(needs_compilation), "yes")) - if (!identical(plan$source_build, expected_source_build)) { - stop("The approved tool installation plan build metadata is inconsistent.", call. = FALSE) - } invisible(TRUE) } @@ -277,7 +250,6 @@ tool_library_install <- function(package, minimum_version, root, .tool_with_library_lock(library, { current <- tool_library_check(package, minimum_version, root) if (!current$install_required) return(invisible(current)) - .tool_validate_install_plan(plan, package, minimum_version, root) 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.") From 40fae1a896b8106fa87eba409af3432369ecfe05 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 02:05:12 +0000 Subject: [PATCH 08/21] fix: limit automatic tool prompts per extension host session --- package.json | 2 +- src/test/node/toolConsent.test.ts | 157 +++++++++++++++++++----------- src/toolConsent.ts | 44 ++++----- src/toolConsentAdapter.ts | 24 ++--- 4 files changed, 131 insertions(+), 96 deletions(-) diff --git a/package.json b/package.json index e6b313a92..38ec8a908 100644 --- a/package.json +++ b/package.json @@ -2316,7 +2316,7 @@ "default": true, "scope": "application", "ignoreSync": true, - "markdownDescription": "Show the one-time prompt to install an optional package when `#r.plot.backend#` is `auto`. vscode-R turns this off after the prompt is shown. Set this to `true` in User Settings to show the automatic prompt again. Explicitly selecting the `jgd` backend prompts whenever installation is needed, regardless of this setting." + "markdownDescription": "Show one automatic prompt per vscode-R extension-host session when `#r.plot.backend#` is `auto`. Choosing `Don't ask again` disables automatic prompts in User Settings; set this to `true` to allow prompts again in a later session. `Not now`, dismissal, and installation do not change this setting. Explicitly selecting the `jgd` backend prompts whenever installation is needed, regardless of this setting." }, "r.plot.jgd.historyLimit": { "type": "number", diff --git a/src/test/node/toolConsent.test.ts b/src/test/node/toolConsent.test.ts index 5866a7ba8..089e8540c 100644 --- a/src/test/node/toolConsent.test.ts +++ b/src/test/node/toolConsent.test.ts @@ -44,6 +44,7 @@ suite('managed package consent bridge', () => { let failPreferenceSave: boolean; let choice: ToolConsentChoice; let prompts: ToolConsentRequest[]; + let sessionState: { autoPrompted: boolean }; const services: ToolConsentService[] = []; setup(async () => { @@ -52,6 +53,7 @@ suite('managed package consent bridge', () => { failPreferenceSave = false; choice = 'install'; prompts = []; + sessionState = { autoPrompted: false }; }); teardown(async () => { @@ -59,12 +61,16 @@ suite('managed package consent bridge', () => { await fs.rm(directory, { recursive: true, force: true }); }); - function service(prompt?: (request: ToolConsentRequest) => Promise): ToolConsentService { + function service( + prompt?: (request: ToolConsentRequest) => Promise, + requestDirectory = directory, + ): ToolConsentService { const result = new ToolConsentService({ - directory, + directory: requestDirectory, isEnabled: () => true, isAutoPromptEnabled: () => autoPromptEnabled, - rememberAutoPromptShown: async () => { + sessionState, + rememberAutoPromptDismissal: async () => { if (failPreferenceSave) throw new Error('settings unavailable'); autoPromptEnabled = false; }, @@ -78,8 +84,13 @@ suite('managed package consent bridge', () => { return result; } - async function writeRequest(id: string, backend: 'jgd' | 'auto', version?: string): Promise { - const request = path.join(directory, `${id}.request`); + 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; } @@ -112,34 +123,75 @@ suite('managed package consent bridge', () => { assert.strictEqual(request.packages[0].sourceBuild, true); }); - test('automatic prompt is remembered, explicit jgd still prompts, and settings can reset it', async () => { + 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`)), 'approve\n'); - assert.strictEqual(autoPromptEnabled, false); + assert.strictEqual(await waitForFile(path.join(directory, `${firstId}.response`)), 'decline\n'); + assert.strictEqual(autoPromptEnabled, true); + assert.strictEqual(sessionState.autoPrompted, true); await first.stop(); - await fs.rm(path.join(directory, `${firstId}.request`), { force: true }); - await fs.rm(path.join(directory, `${firstId}.response`), { force: true }); - const restarted = service(); - await restarted.start(); + const secondDirectory = path.join(directory, 'another-service'); + await fs.mkdir(secondDirectory); + const secondService = service(undefined, secondDirectory); + await secondService.start(); const secondId = '4'.repeat(32); - await writeRequest(secondId, 'auto'); - assert.strictEqual(await waitForFile(path.join(directory, `${secondId}.response`)), 'decline\n'); + 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(); + 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); + }); - const explicitId = '5'.repeat(32); + 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); + await writeRequest(firstId, 'auto'); + assert.strictEqual(await waitForFile(path.join(directory, `${firstId}.response`)), 'decline\n'); + assert.strictEqual(autoPromptEnabled, false); + + 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); - assert.strictEqual(autoPromptEnabled, false); + 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); autoPromptEnabled = true; - const resetId = '6'.repeat(32); - await writeRequest(resetId, 'auto'); - assert.strictEqual(await waitForFile(path.join(directory, `${resetId}.response`)), 'approve\n'); + sessionState = { autoPrompted: false }; + const resetHostDirectory = path.join(directory, 'reset-host'); + await fs.mkdir(resetHostDirectory); + const resetHost = service(undefined, resetHostDirectory); + await resetHost.start(); + const resetId = '9'.repeat(32); + await writeRequest(resetId, 'auto', undefined, resetHostDirectory); + assert.strictEqual(await waitForFile(path.join(resetHostDirectory, `${resetId}.response`)), 'approve\n'); assert.strictEqual(prompts.length, 3); }); @@ -166,60 +218,52 @@ suite('managed package consent bridge', () => { assert.strictEqual(await waitForFile(path.join(directory, `${firstId}.response`)), 'decline\n'); }); - test('automatic prompt is skipped when persisting the one-time setting fails', async () => { + 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, 0); + assert.strictEqual(prompts.length, 1); assert.strictEqual(autoPromptEnabled, true); }); - test('declined automatic choices are remembered without suppressing explicit jgd prompts', async () => { - const broker = service(); - await broker.start(); - const automaticChoices: ToolConsentChoice[] = ['notNow', 'dismiss', 'dontAskAgain']; - for (const [index, automaticChoice] of automaticChoices.entries()) { + test('Not now, dismiss, and install do not change the persistent setting', async () => { + for (const [index, result] of (['notNow', 'dismiss', 'install'] as const).entries()) { autoPromptEnabled = true; - choice = automaticChoice; - const automaticId = String(index + 1).repeat(31) + String(index + 1); - await writeRequest(automaticId, 'auto'); - assert.strictEqual( - await waitForFile(path.join(directory, `${automaticId}.response`)), - 'decline\n', - ); - assert.strictEqual(autoPromptEnabled, false); - - choice = 'install'; - const explicitId = String(index + 4).repeat(31) + String(index + 4); - await writeRequest(explicitId, 'jgd'); - assert.strictEqual( - await waitForFile(path.join(directory, `${explicitId}.response`)), - 'approve\n', - ); + 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); } - assert.deepStrictEqual(prompts.map((request) => request.backend), [ - 'auto', 'jgd', 'auto', 'jgd', 'auto', 'jgd', - ]); }); - test('shutdown during automatic preference save never opens the prompt', async () => { - let finishSave!: () => void; - let saveStarted!: () => void; - const started = new Promise((resolve) => { saveStarted = resolve; }); + 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, - rememberAutoPromptShown: () => new Promise((resolve) => { - finishSave = resolve; - saveStarted(); - }), + sessionState, + rememberAutoPromptDismissal: async () => { + dismissals++; + }, prompt: async (request) => { prompts.push(request); - return 'install'; + promptStarted(); + return new Promise((resolve) => { finishPrompt = resolve; }); }, intervalMs: 5, }); @@ -229,8 +273,9 @@ suite('managed package consent bridge', () => { await writeRequest(id, 'auto'); await started; await broker.stop(); - finishSave(); + finishPrompt('install'); assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'decline\n'); - assert.strictEqual(prompts.length, 0); + assert.strictEqual(prompts.length, 1); + assert.strictEqual(dismissals, 0); }); }); diff --git a/src/toolConsent.ts b/src/toolConsent.ts index 0a697171c..e504b3c91 100644 --- a/src/toolConsent.ts +++ b/src/toolConsent.ts @@ -3,6 +3,10 @@ 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; @@ -23,7 +27,8 @@ export interface ToolConsentServiceOptions { directory: string; isEnabled: () => boolean; isAutoPromptEnabled: () => boolean; - rememberAutoPromptShown: () => Promise; + sessionState: ToolConsentSessionState; + rememberAutoPromptDismissal: () => Promise; prompt: (request: ToolConsentRequest) => Promise; intervalMs?: number; } @@ -68,7 +73,6 @@ export function parseToolConsentRequest(id: string, content: string): ToolConsen Buffer.byteLength(repository, 'utf8') > 2048 || !repository || /[\u0000-\u001f\u007f]/.test(repository) || - comparePackageVersions(version, minimumVersion) < 0 || !PACKAGE_TYPE.test(packageType) || (sourceBuild !== 'yes' && sourceBuild !== 'no') ) { @@ -79,21 +83,9 @@ export function parseToolConsentRequest(id: string, content: string): ToolConsen return { id, runtime, backend, packages }; } -function comparePackageVersions(left: string, right: string): number { - const parse = (version: string) => version.split(/[.-]/).map(Number); - const a = parse(left); - const b = parse(right); - for (let index = 0; index < Math.max(a.length, b.length); index++) { - const difference = (a[index] ?? 0) - (b[index] ?? 0); - if (difference !== 0) return difference; - } - return 0; -} - /** 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 autoPromptInFlight = false; private running = false; constructor(private readonly options: ToolConsentServiceOptions) { @@ -127,22 +119,16 @@ export class ToolConsentService { return; } const automatic = request.backend === 'auto'; - if (automatic && (!this.options.isAutoPromptEnabled() || this.autoPromptInFlight)) { + if (automatic && ( + !this.options.isAutoPromptEnabled() || this.options.sessionState.autoPrompted + )) { await respond('decline'); return; } - if (automatic) this.autoPromptInFlight = true; if (automatic) { - try { - await this.options.rememberAutoPromptShown(); - } catch { - this.autoPromptInFlight = false; - await respond('decline'); - return; - } + this.options.sessionState.autoPrompted = true; } if (!this.running || !this.options.isEnabled()) { - if (automatic) this.autoPromptInFlight = false; await respond('decline'); return; } @@ -153,12 +139,16 @@ export class ToolConsentService { choice = 'dismiss'; } if (!this.running || !this.options.isEnabled()) { - if (automatic) this.autoPromptInFlight = false; await respond('decline'); return; } - if (automatic) { - this.autoPromptInFlight = false; + 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 index bed84c962..37b5bf37c 100644 --- a/src/toolConsentAdapter.ts +++ b/src/toolConsentAdapter.ts @@ -1,9 +1,17 @@ import * as vscode from 'vscode'; -import { ToolConsentChoice, ToolConsentRequest, ToolConsentService } from './toolConsent'; +import { + ToolConsentChoice, + ToolConsentRequest, + ToolConsentService, + ToolConsentSessionState, +} from './toolConsent'; const AUTO_PROMPT_SETTING = 'plot.jgd.installationPrompt'; +// 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} (minimum ${item.minimumVersion})\n Repository: ${item.repository}`, ); @@ -13,7 +21,7 @@ function formatPlan(request: ToolConsentRequest): string { const modeNotice = request.backend === 'auto' ? '\n\nAutomatic plot backend setup is optional; declining uses the available fallback.' : ''; - return `Install these R packages for R ${request.runtime}?\n\n${rows.join('\n')}${buildNotice}${modeNotice}\n\nPackages are installed in a vscode-R-managed library. Existing user and project libraries are not changed.`; + return `Install these R packages for R ${version} (${platform})?\n\n${rows.join('\n')}${buildNotice}${modeNotice}\n\nPackages are installed in a vscode-R-managed library. Existing user and project libraries are not changed.`; } export function createToolConsentService( @@ -26,7 +34,8 @@ export function createToolConsentService( isEnabled, isAutoPromptEnabled: () => vscode.workspace.getConfiguration('r').get(AUTO_PROMPT_SETTING, true), - rememberAutoPromptShown: async () => { + sessionState: hostSessionState, + rememberAutoPromptDismissal: async () => { await vscode.workspace.getConfiguration('r').update( AUTO_PROMPT_SETTING, false, @@ -49,12 +58,3 @@ export function createToolConsentService( }); } -/** Set `r.plot.jgd.installationPrompt` to true to show automatic prompts again. */ -export async function resetAutomaticToolPrompt(): Promise { - await vscode.workspace.getConfiguration('r').update( - AUTO_PROMPT_SETTING, - true, - vscode.ConfigurationTarget.Global, - ); -} - From 018284b3ae675a249e824105771c20674fa4bc9e Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 02:18:37 +0000 Subject: [PATCH 09/21] fix: resolve consent lint and test ordering failures --- src/fileConsent.ts | 42 ++++++++--- src/sessConsent.ts | 8 ++- src/test/node/toolConsent.test.ts | 114 ++++++++++++++++++++++-------- src/toolConsent.ts | 29 ++++++-- src/toolConsentAdapter.ts | 43 ++++++----- 5 files changed, 169 insertions(+), 67 deletions(-) diff --git a/src/fileConsent.ts b/src/fileConsent.ts index 1074a3bc7..b7f3c246c 100644 --- a/src/fileConsent.ts +++ b/src/fileConsent.ts @@ -31,7 +31,9 @@ export class FileConsentService { constructor(private readonly options: FileConsentServiceOptions) {} async start(): Promise { - if (!this.stopped) return; + if (!this.stopped) { + return; + } await fs.mkdir(this.options.directory, { recursive: true, mode: 0o700 }); await this.setOwnerOnlyPermissions(this.options.directory, 0o700); this.stopped = false; @@ -52,16 +54,21 @@ export class FileConsentService { } private async scan(): Promise { - if (this.stopped) return; + if (this.stopped) { + return; + } let names: string[]; try { names = await fs.readdir(this.options.directory); } catch { return; } - if (this.stopped) return; + if (this.stopped) { + return; + } const present = new Set( - names.filter((name) => this.options.requestName.test(name)) + names + .filter((name) => this.options.requestName.test(name)) .map((name) => name.slice(0, -'.request'.length)), ); for (const id of this.seen) { @@ -71,9 +78,13 @@ export class FileConsentService { } } for (const name of names) { - if (!this.options.requestName.test(name)) continue; + if (!this.options.requestName.test(name)) { + continue; + } const id = name.slice(0, -'.request'.length); - if (this.pending.has(id) || this.seen.has(id)) continue; + if (this.pending.has(id) || this.seen.has(id)) { + continue; + } this.seen.add(id); const work = this.processFile(id).finally(() => { this.pending.delete(id); @@ -89,7 +100,9 @@ export class FileConsentService { try { content = await this.readBoundedRequest(requestPath); } catch { - if (!this.stopped) await this.respond(id, 'decline'); + if (!this.stopped) { + await this.respond(id, 'decline'); + } return; } this.contents.set(id, content); @@ -120,7 +133,9 @@ export class FileConsentService { 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.'); + if (bytesRead > this.options.maxBytes) { + throw new Error('Consent request exceeds its size limit.'); + } return buffer.toString('utf8', 0, bytesRead); } finally { await handle.close(); @@ -128,7 +143,9 @@ export class FileConsentService { } private async respond(id: string, response: ConsentResponse): Promise { - if (this.responses.has(id)) return; + if (this.responses.has(id)) { + return; + } this.responses.add(id); const write = this.publishResponse(id, response, this.contents.get(id)); this.activeWrites.add(write); @@ -157,7 +174,8 @@ export class FileConsentService { }); await this.setOwnerOnlyPermissions(temporaryPath, 0o600); const requestPath = path.join(this.options.directory, `${id}.request`); - let publish = response === 'decline' || + let publish = + response === 'decline' || (!this.stopped && this.options.isEnabled() && fsSync.existsSync(requestPath)); if (publish && response === 'approve' && expectedContent !== undefined) { try { @@ -178,6 +196,8 @@ export class FileConsentService { } private async setOwnerOnlyPermissions(filePath: string, mode: number): Promise { - if (process.platform !== 'win32') await fs.chmod(filePath, mode); + if (process.platform !== 'win32') { + await fs.chmod(filePath, mode); + } } } diff --git a/src/sessConsent.ts b/src/sessConsent.ts index 0494036be..e3ae807c1 100644 --- a/src/sessConsent.ts +++ b/src/sessConsent.ts @@ -75,7 +75,9 @@ export class SessConsentService { } catch { choice = 'dismiss'; } - if (!this.running) return; + if (!this.running) { + return; + } if (!this.options.isEnabled()) { await respond('decline'); return; @@ -87,7 +89,9 @@ export class SessConsentService { await respond('decline'); return; } - if (!this.running) return; + if (!this.running) { + return; + } await respond('decline'); return; } diff --git a/src/test/node/toolConsent.test.ts b/src/test/node/toolConsent.test.ts index 089e8540c..9b2f2e077 100644 --- a/src/test/node/toolConsent.test.ts +++ b/src/test/node/toolConsent.test.ts @@ -70,9 +70,12 @@ suite('managed package consent bridge', () => { isEnabled: () => true, isAutoPromptEnabled: () => autoPromptEnabled, sessionState, - rememberAutoPromptDismissal: async () => { - if (failPreferenceSave) throw new Error('settings unavailable'); + rememberAutoPromptDismissal: () => { + if (failPreferenceSave) { + return Promise.reject(new Error('settings unavailable')); + } autoPromptEnabled = false; + return Promise.resolve(); }, prompt: async (request) => { prompts.push(request); @@ -100,25 +103,33 @@ suite('managed package consent bridge', () => { await broker.start(); 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'); + 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, - }]); + 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')); + const request = parseToolConsentRequest( + id, + requestContent(id, 'jgd').replace(/\n/g, '\r\n'), + ); assert.strictEqual(request.backend, 'jgd'); assert.strictEqual(request.packages[0].sourceBuild, true); }); @@ -129,7 +140,10 @@ suite('managed package consent bridge', () => { const firstId = '3'.repeat(32); choice = 'notNow'; await writeRequest(firstId, 'auto'); - assert.strictEqual(await waitForFile(path.join(directory, `${firstId}.response`)), 'decline\n'); + assert.strictEqual( + await waitForFile(path.join(directory, `${firstId}.response`)), + 'decline\n', + ); assert.strictEqual(autoPromptEnabled, true); assert.strictEqual(sessionState.autoPrompted, true); await first.stop(); @@ -140,7 +154,10 @@ suite('managed package consent bridge', () => { await secondService.start(); const secondId = '4'.repeat(32); await writeRequest(secondId, 'auto', undefined, secondDirectory); - assert.strictEqual(await waitForFile(path.join(secondDirectory, `${secondId}.response`)), 'decline\n'); + assert.strictEqual( + await waitForFile(path.join(secondDirectory, `${secondId}.response`)), + 'decline\n', + ); assert.strictEqual(prompts.length, 1); await secondService.stop(); @@ -153,7 +170,10 @@ suite('managed package consent bridge', () => { 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( + await waitForFile(path.join(newHostDirectory, `${thirdId}.response`)), + 'approve\n', + ); assert.strictEqual(prompts.length, 2); }); @@ -163,13 +183,19 @@ suite('managed package consent bridge', () => { choice = 'dontAskAgain'; const firstId = '6'.repeat(32); await writeRequest(firstId, 'auto'); - assert.strictEqual(await waitForFile(path.join(directory, `${firstId}.response`)), 'decline\n'); + assert.strictEqual( + await waitForFile(path.join(directory, `${firstId}.response`)), + 'decline\n', + ); assert.strictEqual(autoPromptEnabled, false); choice = 'install'; const explicitId = '7'.repeat(32); await writeRequest(explicitId, 'jgd'); - assert.strictEqual(await waitForFile(path.join(directory, `${explicitId}.response`)), 'approve\n'); + assert.strictEqual( + await waitForFile(path.join(directory, `${explicitId}.response`)), + 'approve\n', + ); assert.strictEqual(prompts.length, 2); await broker.stop(); @@ -180,7 +206,10 @@ suite('managed package consent bridge', () => { 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( + await waitForFile(path.join(nextHostDirectory, `${suppressedId}.response`)), + 'decline\n', + ); assert.strictEqual(prompts.length, 2); autoPromptEnabled = true; @@ -191,31 +220,46 @@ suite('managed package consent bridge', () => { await resetHost.start(); const resetId = '9'.repeat(32); await writeRequest(resetId, 'auto', undefined, resetHostDirectory); - assert.strictEqual(await waitForFile(path.join(resetHostDirectory, `${resetId}.response`)), 'approve\n'); + assert.strictEqual( + await waitForFile(path.join(resetHostDirectory, `${resetId}.response`)), + 'approve\n', + ); assert.strictEqual(prompts.length, 3); }); 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 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 secondResponse = waitForFile(path.join(directory, `${secondId}.response`)); const deadline = Date.now() + 3000; while (prompts.length === 0 && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 5)); } assert.strictEqual(prompts.length, 1); - assert.strictEqual(prompts[0].id, firstId); - assert.strictEqual(await secondResponse, 'decline\n'); - await fs.writeFile(path.join(directory, `${firstId}.request`), requestContent(firstId, 'auto', '1.3.0')); + 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, `${firstId}.response`)), 'decline\n'); + assert.strictEqual( + await waitForFile(path.join(directory, `${promptedId}.response`)), + 'decline\n', + ); }); test('a failed automatic dismissal save never grants installation', async () => { @@ -242,7 +286,10 @@ suite('managed package consent bridge', () => { 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( + await waitForFile(path.join(hostDirectory, `${id}.response`)), + expected, + ); assert.strictEqual(autoPromptEnabled, true); } }); @@ -251,19 +298,24 @@ suite('managed package consent bridge', () => { let finishPrompt!: (value: ToolConsentChoice) => void; let promptStarted!: () => void; let dismissals = 0; - const started = new Promise((resolve) => { promptStarted = resolve; }); + const started = new Promise((resolve) => { + promptStarted = resolve; + }); const broker = new ToolConsentService({ directory, isEnabled: () => true, isAutoPromptEnabled: () => true, sessionState, - rememberAutoPromptDismissal: async () => { + rememberAutoPromptDismissal: () => { dismissals++; + return Promise.resolve(); }, prompt: async (request) => { prompts.push(request); promptStarted(); - return new Promise((resolve) => { finishPrompt = resolve; }); + return new Promise((resolve) => { + finishPrompt = resolve; + }); }, intervalMs: 5, }); diff --git a/src/toolConsent.ts b/src/toolConsent.ts index e504b3c91..1ffebc8c6 100644 --- a/src/toolConsent.ts +++ b/src/toolConsent.ts @@ -46,7 +46,9 @@ export function parseToolConsentRequest(id: string, content: string): ToolConsen 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.'); + 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.'); @@ -58,27 +60,39 @@ export function parseToolConsentRequest(id: string, content: string): ToolConsen !RUNTIME.test(runtime) || (backend !== 'jgd' && backend !== 'auto') || !/^(?:[1-9]|1[0-6])$/.test(lines[4]) || - count < 1 || count > MAX_PACKAGES || + 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); + 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' }); + packages.push({ + name, + minimumVersion, + version, + repository, + packageType, + sourceBuild: sourceBuild === 'yes', + }); } return { id, runtime, backend, packages }; } @@ -119,9 +133,10 @@ export class ToolConsentService { return; } const automatic = request.backend === 'auto'; - if (automatic && ( - !this.options.isAutoPromptEnabled() || this.options.sessionState.autoPrompted - )) { + if ( + automatic && + (!this.options.isAutoPromptEnabled() || this.options.sessionState.autoPrompted) + ) { await respond('decline'); return; } diff --git a/src/toolConsentAdapter.ts b/src/toolConsentAdapter.ts index 37b5bf37c..947c3a6d5 100644 --- a/src/toolConsentAdapter.ts +++ b/src/toolConsentAdapter.ts @@ -13,14 +13,16 @@ 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} (minimum ${item.minimumVersion})\n Repository: ${item.repository}`, + (item) => + `• ${item.name} ${item.version} (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.' - : ''; + 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.`; } @@ -36,25 +38,34 @@ export function createToolConsentService( vscode.workspace.getConfiguration('r').get(AUTO_PROMPT_SETTING, true), sessionState: hostSessionState, rememberAutoPromptDismissal: async () => { - await vscode.workspace.getConfiguration('r').update( - AUTO_PROMPT_SETTING, - false, - vscode.ConfigurationTarget.Global, - ); + await vscode.workspace + .getConfiguration('r') + .update(AUTO_PROMPT_SETTING, false, vscode.ConfigurationTarget.Global); }, 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'; + 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'; }, }); } - From 7727e2d06ec3d8a591c43865db67dc78894d479f Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 02:48:43 +0000 Subject: [PATCH 10/21] feat: prepare managed jgd before terminal session connection --- R/attach_sess.R | 44 +++++++++++++++- R/prepare-jgd.R | 60 ++++++++++++++++++++++ R/profile.R | 29 +++++++++++ R/tool-consent.R | 15 +++++- README.md | 26 ++++++++++ src/rTerminal.ts | 13 +++++ src/session.ts | 103 ++++++++++++++++++++++++++++++++++++-- src/toolConsentAdapter.ts | 4 +- 8 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 R/prepare-jgd.R diff --git a/R/attach_sess.R b/R/attach_sess.R index 3b2849a84..bc1ca46f8 100644 --- a/R/attach_sess.R +++ b/R/attach_sess.R @@ -185,7 +185,18 @@ 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 = "")) { if (!is.null(attachment_id) && !(is.character(attachment_id) && length(attachment_id) == 1L && !is.na(attachment_id) && @@ -232,6 +243,37 @@ vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, if (is.null(ns)) { return(invisible(FALSE)) } + previous_jgd_socket <- Sys.getenv("JGD_SOCKET", unset = NA_character_) + on.exit({ + if (is.na(previous_jgd_socket)) { + Sys.unsetenv("JGD_SOCKET") + } else { + Sys.setenv(JGD_SOCKET = previous_jgd_socket) + } + }, add = TRUE) + jgd_ready <- FALSE + if (nzchar(jgd_helper) && file.exists(jgd_helper)) { + jgd_setup <- new.env(parent = environment()) + tryCatch(sys.source(jgd_helper, envir = jgd_setup), error = function(error) { + message("vscode-R could not load managed jgd setup resources: ", + conditionMessage(error)) + }) + if (is.function(jgd_setup$vscode_r_prepare_jgd)) { + jgd_ready <- isTRUE(tryCatch( + jgd_setup$vscode_r_prepare_jgd( + plot_backend, tool_root, tool_consent_directory, + tool_library_helper, tool_consent_helper + ), + error = function(error) { + message("vscode-R could not prepare optional jgd plot support: ", + conditionMessage(error)) + FALSE + }, + interrupt = function(error) FALSE + )) + } + } + if (!jgd_ready && plot_backend %in% c("jgd", "auto")) Sys.unsetenv("JGD_SOCKET") connect <- get("connect", envir = ns, inherits = FALSE) args <- list(endpoint = endpoint, plot_backend = plot_backend, use_rstudioapi = use_rstudioapi) diff --git a/R/prepare-jgd.R b/R/prepare-jgd.R new file mode 100644 index 000000000..ad1b6e6ce --- /dev/null +++ b/R/prepare-jgd.R @@ -0,0 +1,60 @@ +# Keep the jgd minimum in one R-side location so consent and installation agree. +VSCODE_R_JGD_MINIMUM_VERSION <- "0.1.0" + +# 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 = "") +) { + if (!is.character(plot_backend) || length(plot_backend) != 1L || + is.na(plot_backend) || !plot_backend %in% c("jgd", "auto")) { + return(FALSE) + } + if (!nzchar(Sys.getenv("JGD_SOCKET", unset = ""))) { + return(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) + } + prepared <- tool_environment$tool_library_prepare( + "jgd", + VSCODE_R_JGD_MINIMUM_VERSION, + tool_root, + consent_directory, + plot_backend, + consent_timeout_seconds = 10, + install_timeout_seconds = 10, + resolution_timeout_seconds = 5 + ) + 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", VSCODE_R_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..28c6f5cfe 100644 --- a/R/profile.R +++ b/R/profile.R @@ -50,6 +50,35 @@ invisible(local({ } plot_backend <- Sys.getenv("SESS_PLOT_BACKEND", "auto") + old_jgd_socket <- Sys.getenv("JGD_SOCKET", unset = NA_character_) + on.exit({ + if (is.na(old_jgd_socket)) { + Sys.unsetenv("JGD_SOCKET") + } else { + Sys.setenv(JGD_SOCKET = old_jgd_socket) + } + }, add = TRUE) + jgd_helper <- Sys.getenv("VSCODE_R_JGD_HELPER", unset = "") + jgd_ready <- FALSE + if (nzchar(jgd_helper) && file.exists(jgd_helper)) { + jgd_setup <- new.env(parent = environment()) + tryCatch(sys.source(jgd_helper, envir = jgd_setup), error = function(error) { + message("vscode-R could not load managed jgd setup resources: ", + conditionMessage(error)) + }) + if (is.function(jgd_setup$vscode_r_prepare_jgd)) { + jgd_ready <- isTRUE(tryCatch( + jgd_setup$vscode_r_prepare_jgd(plot_backend), + error = function(error) { + message("vscode-R could not prepare optional jgd plot support: ", + conditionMessage(error)) + FALSE + }, + interrupt = function(error) FALSE + )) + } + } + if (!jgd_ready && plot_backend %in% c("jgd", "auto")) Sys.unsetenv("JGD_SOCKET") 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) diff --git a/R/tool-consent.R b/R/tool-consent.R index 1d0834844..fc5080496 100644 --- a/R/tool-consent.R +++ b/R/tool-consent.R @@ -115,10 +115,21 @@ tool_library_prepare <- function(package, minimum_version, root, consent_directory, backend, repos = getOption("repos"), consent_timeout_seconds = 180, - install_timeout_seconds = 300) { + install_timeout_seconds = 300, + resolution_timeout_seconds = 5) { available <- tool_library_check(package, minimum_version, root) if (!available$install_required) return(invisible(available)) - plan <- tool_library_plan(package, minimum_version, root, repos) + 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) diff --git a/README.md b/README.md index 3d9e82979..386054fd2 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,32 @@ Set `r.plot.backend` to `"native"` to use R's configured graphics device without opening the VS Code plot viewer automatically. The `"standard"` backend continues to display static plots in VS Code. +### Managed jgd installation + +Normal R terminals and manual session attachment can offer to install `jgd` into +a vscode-R-managed library when the JGD plot viewer is available. An existing +compatible package in your R libraries takes precedence. Managed installation +uses your configured R package repositories, leaves user and project libraries +untouched, and does not change `.libPaths()`. + +With `r.plot.backend` set to `"auto"`, the offer appears at most once per VS Code +window session. **Not now** or closing the notification postpones it until a +later window session. **Don't ask again** disables automatic offers; set +`r.plot.jgd.installationPrompt` to `true` in User Settings to enable them again. +Explicitly selecting `"jgd"` offers installation whenever setup requires it. + +The offer waits up to 10 seconds for approval before continuing with an available +fallback plot backend. Once approved, installation runs to completion and may +require compilation tools if the repository supplies a source package. Declining +or a setup failure allows the R terminal and session watcher to continue. To +retry after a timeout, select `"jgd"` and attach the session again. + +The managed library is shared by R sessions using the same platform and R +major/minor version. Close other R sessions using the managed package before +approving an update. Updating a loaded package can fail because of locked DLLs +on Windows or leave other sessions using inconsistent package files. Restart +affected R sessions before retrying. + ## Features * Snippets for R and R Markdown. diff --git a/src/rTerminal.ts b/src/rTerminal.ts index 3d4cd02e7..0c59e6ed1 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -17,6 +17,7 @@ import { deferWorkspaceRefresh, getGlobalPipePath, getSessConsentDirectory, + getToolConsentDirectory, isTerminalClosed, removeTerminalDiscoveryFile, updateTerminalSessionDiscoveryFile, @@ -312,6 +313,7 @@ export async function makeTerminalOptions(resource?: vscode.Uri): 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,6 +1580,7 @@ function buildAttachSessionScript( pipePath: string, sessPath: string, consentDirectory: string, + toolConsentDirectory: string, attachment?: { attachmentId: string; autoActivate: boolean }, ): string { const backend = resolveBackend(); @@ -1586,6 +1595,11 @@ 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, '/'))}`, ` 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 +1611,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, 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 +1727,65 @@ 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, + 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(); +} + 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 +1832,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 +1844,7 @@ async function prepareAttachmentCore( try { await fsp.writeFile( temporaryPath, - buildAttachSessionScript(endpoint, sessPath, consentDirectory, { + buildAttachSessionScript(endpoint, sessPath, consentDirectory, toolConsentDirectory, { attachmentId, autoActivate, }), @@ -1832,6 +1906,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 +1923,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 +1989,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 +2044,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 +2058,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/toolConsentAdapter.ts b/src/toolConsentAdapter.ts index 947c3a6d5..beb55643c 100644 --- a/src/toolConsentAdapter.ts +++ b/src/toolConsentAdapter.ts @@ -23,7 +23,9 @@ function formatPlan(request: ToolConsentRequest): string { 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.`; + const timingNotice = + '\n\nR setup waits up to 10 seconds for approval. To retry later, select the jgd plot backend and attach again.'; + return `Install these R packages for R ${version} (${platform})?\n\n${rows.join('\n')}${buildNotice}${modeNotice}${timingNotice}\n\nPackages are installed in a vscode-R-managed library. Existing user and project libraries are not changed.`; } export function createToolConsentService( From df36aa1a029833685ba5cf636039667f9a7afd82 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 02:48:44 +0000 Subject: [PATCH 11/21] test: cover managed jgd startup and attachment --- .github/workflows/main.yml | 2 + R/tests/prepare-jgd.R | 267 ++++++++++++++++++ package.json | 1 + src/test/suite/session.test.ts | 45 ++- .../suite/sessionTerminalLifecycle.test.ts | 1 + src/test/suite/terminal.test.ts | 20 ++ 6 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 R/tests/prepare-jgd.R diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b21fe2d7f..d3c8b29d5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -42,6 +42,8 @@ jobs: run: pnpm run test:tool-library - name: Verify managed tool consent run: pnpm run test:tool-consent + - name: Verify managed jgd preparation + run: pnpm run test:prepare-jgd - name: Install R test dependencies run: install.packages(c("remotes", "pkgbuild", "languageserver", "lintr", "renv")) shell: Rscript {0} diff --git a/R/tests/prepare-jgd.R b/R/tests/prepare-jgd.R new file mode 100644 index 000000000..ca59cf058 --- /dev/null +++ b/R/tests/prepare-jgd.R @@ -0,0 +1,267 @@ +# 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) + + 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)) writeLines('jgd', path)", + " 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(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") + repo <- paste0("file:///", sub("^/", "", normalizePath(file.path(root, "repo"), winslash = "/"))) + runner <- file.path(root, "run.R") + writeLines(c( + "args <- commandArgs(TRUE); mode <- args[[1L]]; root <- args[[2L]]; repo <- args[[3L]];", + "result <- args[[4L]];", + "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);", + "Sys.setenv(JGD_SOCKET='fixture-socket');", + "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(...) {", + " 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 <- 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')));", + "tool_library_helper <- if (mode == 'parallel') file.path(root, 'instrumented-tool-library.R') else", + " file.path(getwd(), 'R', 'tool-library.R');", + "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=file.path(getwd(), 'R', 'prepare-jgd.R'));", + "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), 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(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")))) + } + } + + 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(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"))) + 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(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("", "FALSE", "standard"))) + + for (mode in c("old", "offline")) { + result <- file.path(root, paste0(mode, "-result")) + observed <- run_child(mode, result) + stopifnot(identical(observed, c("", if (mode == "old") "TRUE" else "FALSE", "standard"))) + stopifnot(length(list.files(file.path(root, mode, "consent"), pattern = "\\.request$")) == 0L) + } + + 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(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")) + }, 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( + "vscode_r_prepare_sess <- function(...) { ns <- new.env(); ns$connect <- function(...) {", + paste0(" writeLines(c(Sys.getenv('JGD_SOCKET', unset=''), ", + "as.character(requireNamespace('jgd', quietly=TRUE))), ", + "", 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")), ","), + 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(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("", "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/package.json b/package.json index 38ec8a908..184f6c7fb 100644 --- a/package.json +++ b/package.json @@ -2619,6 +2619,7 @@ "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/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index 4b582ebd5..f384e78ef 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -18,6 +18,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 +989,13 @@ 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_helper = jgd_helper/); const backgroundSource = await fsp.readFile(backgroundPreparation.scriptPath, 'utf8'); assert.ok( backgroundSource.includes( @@ -1243,6 +1251,7 @@ suite('Session Communication', () => { if (!oldPreparation) { return; } + const oldToolDirectory = await session.getToolConsentDirectory(); let enterStop!: () => void; let releaseStop!: () => void; @@ -1279,8 +1288,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 +1364,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 +1464,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..7e0872865 100644 --- a/src/test/suite/terminal.test.ts +++ b/src/test/suite/terminal.test.ts @@ -75,6 +75,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 +839,25 @@ 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'))); sinon.assert.notCalled(getRpath); sinon.assert.notCalled(queryR); sinon.assert.notCalled(installTask); From bf7ffab82b2568d435a9a268d4a55863aae6b160 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 03:01:49 +0000 Subject: [PATCH 12/21] fix: align managed jgd consent with session startup --- R/attach_sess.R | 82 ++++++++++++++++++------------- R/prepare-jgd.R | 51 +++++++++++++++++-- R/profile.R | 33 ++----------- R/tests/prepare-jgd.R | 34 ++++++++++++- R/tool-consent.R | 2 +- README.md | 11 +++-- src/rTerminal.ts | 3 ++ src/session.ts | 10 +++- src/test/node/toolConsent.test.ts | 9 ++++ src/test/suite/session.test.ts | 2 + src/test/suite/terminal.test.ts | 1 + src/toolConsent.ts | 19 +++++-- src/toolConsentAdapter.ts | 4 +- 13 files changed, 178 insertions(+), 83 deletions(-) diff --git a/R/attach_sess.R b/R/attach_sess.R index bc1ca46f8..7e0a6397a 100644 --- a/R/attach_sess.R +++ b/R/attach_sess.R @@ -1,5 +1,46 @@ # 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") +) { + if (!is.function(connect)) stop("connect must be a function.", call. = FALSE) + if (!plot_backend %in% c("jgd", "auto") || + !nzchar(Sys.getenv("JGD_SOCKET", unset = ""))) { + return(connect()) + } + 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 + )) + } + + previous_socket <- Sys.getenv("JGD_SOCKET", unset = NA_character_) + on.exit({ + if (is.na(previous_socket)) Sys.unsetenv("JGD_SOCKET") else + Sys.setenv(JGD_SOCKET = previous_socket) + }, add = TRUE) + Sys.unsetenv("JGD_SOCKET") + connect() +} + vscode_r_prepare_sess <- function(pkg_path, managed_root, consent_dir, source_helper, installer_helper, timeout_seconds = 180, @@ -196,7 +237,8 @@ vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, tool_consent_helper = Sys.getenv( "VSCODE_R_TOOL_CONSENT_HELPER", unset = "" ), - jgd_helper = Sys.getenv("VSCODE_R_JGD_HELPER", unset = "")) { + jgd_helper = Sys.getenv("VSCODE_R_JGD_HELPER", unset = ""), + jgd_can_prompt = TRUE) { if (!is.null(attachment_id) && !(is.character(attachment_id) && length(attachment_id) == 1L && !is.na(attachment_id) && @@ -243,37 +285,6 @@ vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, if (is.null(ns)) { return(invisible(FALSE)) } - previous_jgd_socket <- Sys.getenv("JGD_SOCKET", unset = NA_character_) - on.exit({ - if (is.na(previous_jgd_socket)) { - Sys.unsetenv("JGD_SOCKET") - } else { - Sys.setenv(JGD_SOCKET = previous_jgd_socket) - } - }, add = TRUE) - jgd_ready <- FALSE - if (nzchar(jgd_helper) && file.exists(jgd_helper)) { - jgd_setup <- new.env(parent = environment()) - tryCatch(sys.source(jgd_helper, envir = jgd_setup), error = function(error) { - message("vscode-R could not load managed jgd setup resources: ", - conditionMessage(error)) - }) - if (is.function(jgd_setup$vscode_r_prepare_jgd)) { - jgd_ready <- isTRUE(tryCatch( - jgd_setup$vscode_r_prepare_jgd( - plot_backend, tool_root, tool_consent_directory, - tool_library_helper, tool_consent_helper - ), - error = function(error) { - message("vscode-R could not prepare optional jgd plot support: ", - conditionMessage(error)) - FALSE - }, - interrupt = function(error) FALSE - )) - } - } - if (!jgd_ready && plot_backend %in% c("jgd", "auto")) Sys.unsetenv("JGD_SOCKET") connect <- get("connect", envir = ns, inherits = FALSE) args <- list(endpoint = endpoint, plot_backend = plot_backend, use_rstudioapi = use_rstudioapi) @@ -283,7 +294,12 @@ 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() do.call(connect, args), + jgd_helper, tool_root, tool_consent_directory, + tool_library_helper, tool_consent_helper, jgd_can_prompt + ) } if (profile_process) { diff --git a/R/prepare-jgd.R b/R/prepare-jgd.R index ad1b6e6ce..1e7e9d44c 100644 --- a/R/prepare-jgd.R +++ b/R/prepare-jgd.R @@ -1,6 +1,39 @@ # Keep the jgd minimum in one R-side location so consent and installation agree. VSCODE_R_JGD_MINIMUM_VERSION <- "0.1.0" +# Keep socket fallback, preparation, connection, and restoration in one place. +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") +) { + if (!is.function(connect)) stop("connect must be a function.", call. = FALSE) + if (!plot_backend %in% c("jgd", "auto") || !nzchar(Sys.getenv("JGD_SOCKET", unset = ""))) { + return(connect()) + } + previous_socket <- Sys.getenv("JGD_SOCKET", unset = NA_character_) + on.exit({ + if (is.na(previous_socket)) { + Sys.unsetenv("JGD_SOCKET") + } else { + Sys.setenv(JGD_SOCKET = previous_socket) + } + }, add = TRUE) + ready <- isTRUE(vscode_r_prepare_jgd( + plot_backend, tool_root, consent_directory, + library_helper, consent_helper, can_prompt + )) + if (!ready) { + # An old loaded jgd namespace still satisfies requireNamespace; hide the socket so hooks choose fallback. + Sys.unsetenv("JGD_SOCKET") + } + connect() +} + # 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( @@ -8,7 +41,8 @@ vscode_r_prepare_jgd <- function( 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 = "") + consent_helper = Sys.getenv("VSCODE_R_TOOL_CONSENT_HELPER", unset = ""), + can_prompt = identical(Sys.getenv("VSCODE_R_JGD_CAN_PROMPT", unset = "TRUE"), "TRUE") ) { if (!is.character(plot_backend) || length(plot_backend) != 1L || is.na(plot_backend) || !plot_backend %in% c("jgd", "auto")) { @@ -17,6 +51,9 @@ vscode_r_prepare_jgd <- function( if (!nzchar(Sys.getenv("JGD_SOCKET", unset = ""))) { 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.") @@ -28,15 +65,21 @@ vscode_r_prepare_jgd <- function( 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", VSCODE_R_JGD_MINIMUM_VERSION, tool_root + ))) + } prepared <- tool_environment$tool_library_prepare( "jgd", VSCODE_R_JGD_MINIMUM_VERSION, tool_root, consent_directory, plot_backend, - consent_timeout_seconds = 10, - install_timeout_seconds = 10, - resolution_timeout_seconds = 5 + 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.") diff --git a/R/profile.R b/R/profile.R index 28c6f5cfe..86098c1b0 100644 --- a/R/profile.R +++ b/R/profile.R @@ -50,38 +50,11 @@ invisible(local({ } plot_backend <- Sys.getenv("SESS_PLOT_BACKEND", "auto") - old_jgd_socket <- Sys.getenv("JGD_SOCKET", unset = NA_character_) - on.exit({ - if (is.na(old_jgd_socket)) { - Sys.unsetenv("JGD_SOCKET") - } else { - Sys.setenv(JGD_SOCKET = old_jgd_socket) - } - }, add = TRUE) - jgd_helper <- Sys.getenv("VSCODE_R_JGD_HELPER", unset = "") - jgd_ready <- FALSE - if (nzchar(jgd_helper) && file.exists(jgd_helper)) { - jgd_setup <- new.env(parent = environment()) - tryCatch(sys.source(jgd_helper, envir = jgd_setup), error = function(error) { - message("vscode-R could not load managed jgd setup resources: ", - conditionMessage(error)) - }) - if (is.function(jgd_setup$vscode_r_prepare_jgd)) { - jgd_ready <- isTRUE(tryCatch( - jgd_setup$vscode_r_prepare_jgd(plot_backend), - error = function(error) { - message("vscode-R could not prepare optional jgd plot support: ", - conditionMessage(error)) - FALSE - }, - interrupt = function(error) FALSE - )) - } - } - if (!jgd_ready && plot_backend %in% c("jgd", "auto")) Sys.unsetenv("JGD_SOCKET") 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() { + connect(use_rstudioapi = use_rstudioapi, plot_backend = plot_backend) + }) isTRUE(result) } diff --git a/R/tests/prepare-jgd.R b/R/tests/prepare-jgd.R index ca59cf058..d05f3e94c 100644 --- a/R/tests/prepare-jgd.R +++ b/R/tests/prepare-jgd.R @@ -41,6 +41,8 @@ local({ 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 = "/"))) runner <- file.path(root, "run.R") writeLines(c( @@ -70,14 +72,21 @@ local({ " .libPaths(c(old, .Library)); loadNamespace('jgd', lib.loc=old);", "}", "if (mode == 'offline') options(repos=c(CRAN=file.path(root, 'missing-repo')));", - "tool_library_helper <- if (mode == 'parallel') file.path(root, 'instrumented-tool-library.R') else", + "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 == '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=file.path(getwd(), 'R', 'prepare-jgd.R'));", + " jgd_helper=file.path(getwd(), 'R', 'prepare-jgd.R'),", + " jgd_can_prompt=!(mode %in% c('no-prompt', 'no-prompt-compatible')));", "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), result);" @@ -165,6 +174,25 @@ local({ 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("", "FALSE", "standard"))) + } else { + stopifnot(identical(observed, c("fixture-socket", "TRUE", "jgd"))) + 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) @@ -207,6 +235,7 @@ local({ 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(...) {", paste0(" writeLines(c(Sys.getenv('JGD_SOCKET', unset=''), ", "as.character(requireNamespace('jgd', quietly=TRUE))), ", @@ -232,6 +261,7 @@ local({ 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',", 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')"), diff --git a/R/tool-consent.R b/R/tool-consent.R index fc5080496..6c2e33a4d 100644 --- a/R/tool-consent.R +++ b/R/tool-consent.R @@ -116,7 +116,7 @@ tool_library_prepare <- function(package, minimum_version, root, consent_directo backend, repos = getOption("repos"), consent_timeout_seconds = 180, install_timeout_seconds = 300, - resolution_timeout_seconds = 5) { + resolution_timeout_seconds = 30) { available <- tool_library_check(package, minimum_version, root) if (!available$install_required) return(invisible(available)) plan <- local({ diff --git a/README.md b/README.md index 386054fd2..7bdece204 100644 --- a/README.md +++ b/README.md @@ -99,11 +99,12 @@ later window session. **Don't ask again** disables automatic offers; set `r.plot.jgd.installationPrompt` to `true` in User Settings to enable them again. Explicitly selecting `"jgd"` offers installation whenever setup requires it. -The offer waits up to 10 seconds for approval before continuing with an available -fallback plot backend. Once approved, installation runs to completion and may -require compilation tools if the repository supplies a source package. Declining -or a setup failure allows the R terminal and session watcher to continue. To -retry after a timeout, select `"jgd"` and attach the session again. +R waits for the installation decision before connecting the session watcher; +code sent during setup stays queued. Once approved, installation may require +compilation tools if the repository supplies a source package. Declining or a +setup failure allows the R terminal and session watcher to continue with an +available fallback plot backend. When automatic offers are suppressed, setup +uses an existing compatible package or falls back without querying repositories. The managed library is shared by R sessions using the same platform and R major/minor version. Close other R sessions using the managed package before diff --git a/src/rTerminal.ts b/src/rTerminal.ts index 0c59e6ed1..016f3cd51 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -18,6 +18,7 @@ import { getGlobalPipePath, getSessConsentDirectory, getToolConsentDirectory, + canPromptForToolConsent, isTerminalClosed, removeTerminalDiscoveryFile, updateTerminalSessionDiscoveryFile, @@ -314,6 +315,7 @@ export async function makeTerminalOptions(resource?: vscode.Uri): Promise 0 && attachToolConsentService?.canPrompt(backend) === true; return [ 'local({', ' tryCatch({', @@ -1600,6 +1602,7 @@ function buildAttachSessionScript( ` 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' @@ -1611,7 +1614,7 @@ function buildAttachSessionScript( ]), ' source(startup_helper, local = TRUE)', ' source(attach_helper, local = TRUE)', - ` vscode_r_attach_sess(endpoint, sess_src, managed_root, consent_dir, source_helper, installer_helper, ${asRStringLiteral(backend)}, tool_root = tool_root, tool_consent_directory = tool_consent_dir, tool_library_helper = tool_library_helper, tool_consent_helper = tool_consent_helper, jgd_helper = jgd_helper, 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, 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) {', @@ -1786,6 +1789,11 @@ 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.'); diff --git a/src/test/node/toolConsent.test.ts b/src/test/node/toolConsent.test.ts index 9b2f2e077..f11f42c53 100644 --- a/src/test/node/toolConsent.test.ts +++ b/src/test/node/toolConsent.test.ts @@ -101,6 +101,9 @@ suite('managed package consent bridge', () => { 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( @@ -146,12 +149,14 @@ suite('managed package consent bridge', () => { ); 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( @@ -167,6 +172,7 @@ suite('managed package consent bridge', () => { 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); @@ -182,12 +188,15 @@ suite('managed package consent bridge', () => { await broker.start(); choice = 'dontAskAgain'; const firstId = '6'.repeat(32); + assert.strictEqual(broker.canPrompt('auto'), true); await writeRequest(firstId, 'auto'); assert.strictEqual( 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); diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index f384e78ef..2fab7a1e3 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -995,7 +995,9 @@ suite('Session Communication', () => { 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/); const backgroundSource = await fsp.readFile(backgroundPreparation.scriptPath, 'utf8'); assert.ok( backgroundSource.includes( diff --git a/src/test/suite/terminal.test.ts b/src/test/suite/terminal.test.ts index 7e0872865..4078d64f2 100644 --- a/src/test/suite/terminal.test.ts +++ b/src/test/suite/terminal.test.ts @@ -858,6 +858,7 @@ suite('R Terminal', () => { ), ); assert.ok(options.env?.['VSCODE_R_JGD_HELPER']?.endsWith(path.join('R', 'prepare-jgd.R'))); + 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/toolConsent.ts b/src/toolConsent.ts index 1ffebc8c6..02ef238fa 100644 --- a/src/toolConsent.ts +++ b/src/toolConsent.ts @@ -124,6 +124,20 @@ export class ToolConsentService { 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, @@ -133,10 +147,7 @@ export class ToolConsentService { return; } const automatic = request.backend === 'auto'; - if ( - automatic && - (!this.options.isAutoPromptEnabled() || this.options.sessionState.autoPrompted) - ) { + if (!this.canPrompt(request.backend)) { await respond('decline'); return; } diff --git a/src/toolConsentAdapter.ts b/src/toolConsentAdapter.ts index beb55643c..947c3a6d5 100644 --- a/src/toolConsentAdapter.ts +++ b/src/toolConsentAdapter.ts @@ -23,9 +23,7 @@ function formatPlan(request: ToolConsentRequest): string { request.backend === 'auto' ? '\n\nAutomatic plot backend setup is optional; declining uses the available fallback.' : ''; - const timingNotice = - '\n\nR setup waits up to 10 seconds for approval. To retry later, select the jgd plot backend and attach again.'; - return `Install these R packages for R ${version} (${platform})?\n\n${rows.join('\n')}${buildNotice}${modeNotice}${timingNotice}\n\nPackages are installed in a vscode-R-managed library. Existing user and project libraries are not changed.`; + return `Install these R packages for R ${version} (${platform})?\n\n${rows.join('\n')}${buildNotice}${modeNotice}\n\nPackages are installed in a vscode-R-managed library. Existing user and project libraries are not changed.`; } export function createToolConsentService( From 3485d1991ccd311ec1e601d805333cae297cf55f Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 03:21:12 +0000 Subject: [PATCH 13/21] fix: store automatic jgd prompt dismissal in global state --- README.md | 6 +- package.json | 7 -- src/session.ts | 1 + src/test/node/toolConsent.test.ts | 18 +---- src/test/suite/toolConsentAdapter.test.ts | 81 +++++++++++++++++++++++ src/toolConsentAdapter.ts | 10 ++- 6 files changed, 91 insertions(+), 32 deletions(-) create mode 100644 src/test/suite/toolConsentAdapter.test.ts diff --git a/README.md b/README.md index 7bdece204..bf2cc51e5 100644 --- a/README.md +++ b/README.md @@ -95,9 +95,9 @@ untouched, and does not change `.libPaths()`. With `r.plot.backend` set to `"auto"`, the offer appears at most once per VS Code window session. **Not now** or closing the notification postpones it until a -later window session. **Don't ask again** disables automatic offers; set -`r.plot.jgd.installationPrompt` to `true` in User Settings to enable them again. -Explicitly selecting `"jgd"` offers installation whenever setup requires it. +later window session. **Don't ask again** disables automatic offers. Explicitly +selecting `"jgd"` offers installation whenever setup requires it, even after +choosing **Don't ask again**. R waits for the installation decision before connecting the session watcher; code sent during setup stays queued. Once approved, installation may require diff --git a/package.json b/package.json index 184f6c7fb..2524ea83b 100644 --- a/package.json +++ b/package.json @@ -2311,13 +2311,6 @@ ], "markdownDescription": "The canonical setting for selecting the plot backend. `standard` uses the VS Code static plot viewer; `native` leaves R's configured graphics device untouched.\n\nWhen set to `auto`, the best available backend is used (JGD if installed, then httpgd, then standard). For backwards compatibility, setting `#r.plot.useHttpgd#` to `true` selects httpgd while this setting remains `auto`; workspace settings take precedence over user settings, with an explicit backend value preferred within the same scope." }, - "r.plot.jgd.installationPrompt": { - "type": "boolean", - "default": true, - "scope": "application", - "ignoreSync": true, - "markdownDescription": "Show one automatic prompt per vscode-R extension-host session when `#r.plot.backend#` is `auto`. Choosing `Don't ask again` disables automatic prompts in User Settings; set this to `true` to allow prompts again in a later session. `Not now`, dismissal, and installation do not change this setting. Explicitly selecting the `jgd` backend prompts whenever installation is needed, regardless of this setting." - }, "r.plot.jgd.historyLimit": { "type": "number", "default": 50, diff --git a/src/session.ts b/src/session.ts index a79660c88..98916377b 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1756,6 +1756,7 @@ async function ensureAttachToolConsentService( service = createToolConsentService( directory, () => config().get('sessionWatcher') === true, + extensionContext.globalState, 200, ); await service.start(); diff --git a/src/test/node/toolConsent.test.ts b/src/test/node/toolConsent.test.ts index f11f42c53..a8554ad9e 100644 --- a/src/test/node/toolConsent.test.ts +++ b/src/test/node/toolConsent.test.ts @@ -72,7 +72,7 @@ suite('managed package consent bridge', () => { sessionState, rememberAutoPromptDismissal: () => { if (failPreferenceSave) { - return Promise.reject(new Error('settings unavailable')); + return Promise.reject(new Error('global state unavailable')); } autoPromptEnabled = false; return Promise.resolve(); @@ -220,20 +220,6 @@ suite('managed package consent bridge', () => { 'decline\n', ); assert.strictEqual(prompts.length, 2); - - autoPromptEnabled = true; - sessionState = { autoPrompted: false }; - const resetHostDirectory = path.join(directory, 'reset-host'); - await fs.mkdir(resetHostDirectory); - const resetHost = service(undefined, resetHostDirectory); - await resetHost.start(); - const resetId = '9'.repeat(32); - await writeRequest(resetId, 'auto', undefined, resetHostDirectory); - assert.strictEqual( - await waitForFile(path.join(resetHostDirectory, `${resetId}.response`)), - 'approve\n', - ); - assert.strictEqual(prompts.length, 3); }); test('parallel automatic requests show one prompt and a changed plan cannot be approved', async () => { @@ -283,7 +269,7 @@ suite('managed package consent bridge', () => { assert.strictEqual(autoPromptEnabled, true); }); - test('Not now, dismiss, and install do not change the persistent setting', async () => { + 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; diff --git a/src/test/suite/toolConsentAdapter.test.ts b/src/test/suite/toolConsentAdapter.test.ts new file mode 100644 index 000000000..b5a157a51 --- /dev/null +++ b/src/test/suite/toolConsentAdapter.test.ts @@ -0,0 +1,81 @@ +import * as assert from 'assert'; +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("Don't ask again saves global state and explicit jgd still prompts", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'vscode-r-tool-adapter-')); + const sandbox = sinon.createSandbox(); + const values = new Map([['jgdInstallPromptDismissed', true]]); + 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); + // Start the request flow with no previously saved dismissal. + values.clear(); + 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', + '0.1.0', + '1.2.0', + 'https://cloud.r-project.org', + 'source', + 'yes', + '', + ].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, [['jgdInstallPromptDismissed', true]]); + assert.strictEqual(broker.canPrompt('auto'), false); + assert.strictEqual(broker.canPrompt('jgd'), true); + assert.strictEqual(prompt.callCount, 2); + } finally { + await broker.stop(); + sandbox.restore(); + await fs.rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/src/toolConsentAdapter.ts b/src/toolConsentAdapter.ts index 947c3a6d5..d1baf9db2 100644 --- a/src/toolConsentAdapter.ts +++ b/src/toolConsentAdapter.ts @@ -6,7 +6,7 @@ import { ToolConsentSessionState, } from './toolConsent'; -const AUTO_PROMPT_SETTING = 'plot.jgd.installationPrompt'; +const JGD_INSTALL_PROMPT_DISMISSED = 'jgdInstallPromptDismissed'; // Shared by services in this extension host; a new host starts with a fresh state. const hostSessionState: ToolConsentSessionState = { autoPrompted: false }; @@ -29,18 +29,16 @@ function formatPlan(request: ToolConsentRequest): string { export function createToolConsentService( directory: string, isEnabled: () => boolean, + globalState: vscode.Memento, intervalMs?: number, ): ToolConsentService { return new ToolConsentService({ directory, isEnabled, - isAutoPromptEnabled: () => - vscode.workspace.getConfiguration('r').get(AUTO_PROMPT_SETTING, true), + isAutoPromptEnabled: () => globalState.get(JGD_INSTALL_PROMPT_DISMISSED) !== true, sessionState: hostSessionState, rememberAutoPromptDismissal: async () => { - await vscode.workspace - .getConfiguration('r') - .update(AUTO_PROMPT_SETTING, false, vscode.ConfigurationTarget.Global); + await globalState.update(JGD_INSTALL_PROMPT_DISMISSED, true); }, intervalMs, prompt: async (request): Promise => { From 779aecf566b5fd123ca23ae0f535e9bfe90cc4b7 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 03:23:18 +0000 Subject: [PATCH 14/21] docs: shorten managed jgd installation notes --- README.md | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/README.md b/README.md index bf2cc51e5..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. @@ -85,33 +85,6 @@ Set `r.plot.backend` to `"native"` to use R's configured graphics device without opening the VS Code plot viewer automatically. The `"standard"` backend continues to display static plots in VS Code. -### Managed jgd installation - -Normal R terminals and manual session attachment can offer to install `jgd` into -a vscode-R-managed library when the JGD plot viewer is available. An existing -compatible package in your R libraries takes precedence. Managed installation -uses your configured R package repositories, leaves user and project libraries -untouched, and does not change `.libPaths()`. - -With `r.plot.backend` set to `"auto"`, the offer appears at most once per VS Code -window session. **Not now** or closing the notification postpones it until a -later window session. **Don't ask again** disables automatic offers. Explicitly -selecting `"jgd"` offers installation whenever setup requires it, even after -choosing **Don't ask again**. - -R waits for the installation decision before connecting the session watcher; -code sent during setup stays queued. Once approved, installation may require -compilation tools if the repository supplies a source package. Declining or a -setup failure allows the R terminal and session watcher to continue with an -available fallback plot backend. When automatic offers are suppressed, setup -uses an existing compatible package or falls back without querying repositories. - -The managed library is shared by R sessions using the same platform and R -major/minor version. Close other R sessions using the managed package before -approving an update. Updating a loaded package can fail because of locked DLLs -on Windows or leave other sessions using inconsistent package files. Restart -affected R sessions before retrying. - ## Features * Snippets for R and R Markdown. From f670f27d370bda1598b5af32061ffe05cbc05394 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 03:30:53 +0000 Subject: [PATCH 15/21] fix: scope jgd prompt dismissal to the minimum version --- R/attach_sess.R | 12 ++++++++---- R/prepare-jgd.R | 17 ++++++++--------- R/tests/prepare-jgd.R | 7 +++++-- src/jgdVersion.ts | 2 ++ src/rTerminal.ts | 3 +++ src/session.ts | 4 +++- src/test/suite/session.test.ts | 4 ++++ src/test/suite/terminal.test.ts | 2 ++ src/test/suite/toolConsentAdapter.test.ts | 17 +++++++++++------ src/toolConsentAdapter.ts | 12 +++++++++--- 10 files changed, 55 insertions(+), 25 deletions(-) create mode 100644 src/jgdVersion.ts diff --git a/R/attach_sess.R b/R/attach_sess.R index 7e0a6397a..a7a833251 100644 --- a/R/attach_sess.R +++ b/R/attach_sess.R @@ -8,7 +8,8 @@ vscode_r_with_jgd <- function( 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") + 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") || @@ -28,7 +29,7 @@ vscode_r_with_jgd <- function( 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 + tool_library_helper, tool_consent_helper, can_prompt, minimum_version )) } @@ -238,7 +239,10 @@ vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, "VSCODE_R_TOOL_CONSENT_HELPER", unset = "" ), jgd_helper = Sys.getenv("VSCODE_R_JGD_HELPER", unset = ""), - jgd_can_prompt = TRUE) { + 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) && @@ -298,7 +302,7 @@ vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, plot_backend, function() do.call(connect, args), jgd_helper, tool_root, tool_consent_directory, - tool_library_helper, tool_consent_helper, jgd_can_prompt + tool_library_helper, tool_consent_helper, jgd_can_prompt, jgd_minimum_version ) } diff --git a/R/prepare-jgd.R b/R/prepare-jgd.R index 1e7e9d44c..91b6763a2 100644 --- a/R/prepare-jgd.R +++ b/R/prepare-jgd.R @@ -1,6 +1,3 @@ -# Keep the jgd minimum in one R-side location so consent and installation agree. -VSCODE_R_JGD_MINIMUM_VERSION <- "0.1.0" - # Keep socket fallback, preparation, connection, and restoration in one place. vscode_r_connect_with_jgd <- function( plot_backend, @@ -9,7 +6,8 @@ vscode_r_connect_with_jgd <- function( 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") + 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") || !nzchar(Sys.getenv("JGD_SOCKET", unset = ""))) { @@ -25,7 +23,7 @@ vscode_r_connect_with_jgd <- function( }, add = TRUE) ready <- isTRUE(vscode_r_prepare_jgd( plot_backend, tool_root, consent_directory, - library_helper, consent_helper, can_prompt + library_helper, consent_helper, can_prompt, minimum_version )) if (!ready) { # An old loaded jgd namespace still satisfies requireNamespace; hide the socket so hooks choose fallback. @@ -42,7 +40,8 @@ vscode_r_prepare_jgd <- function( 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") + 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")) { @@ -68,12 +67,12 @@ vscode_r_prepare_jgd <- function( 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", VSCODE_R_JGD_MINIMUM_VERSION, tool_root + "jgd", minimum_version, tool_root ))) } prepared <- tool_environment$tool_library_prepare( "jgd", - VSCODE_R_JGD_MINIMUM_VERSION, + minimum_version, tool_root, consent_directory, plot_backend, @@ -86,7 +85,7 @@ vscode_r_prepare_jgd <- function( return(FALSE) } namespace <- tool_environment$tool_library_load( - "jgd", VSCODE_R_JGD_MINIMUM_VERSION, tool_root + "jgd", minimum_version, tool_root ) if (is.null(namespace)) { message("vscode-R could not load a compatible jgd package; using the available plot fallback.") diff --git a/R/tests/prepare-jgd.R b/R/tests/prepare-jgd.R index d05f3e94c..caa1ab117 100644 --- a/R/tests/prepare-jgd.R +++ b/R/tests/prepare-jgd.R @@ -54,7 +54,8 @@ local({ "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);", - "Sys.setenv(JGD_SOCKET='fixture-socket');", + # 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);", @@ -86,7 +87,8 @@ local({ " tool_consent_directory=consent, tool_library_helper=tool_library_helper,", " tool_consent_helper=file.path(getwd(), 'R', 'tool-consent.R'),", " jgd_helper=file.path(getwd(), 'R', 'prepare-jgd.R'),", - " jgd_can_prompt=!(mode %in% c('no-prompt', 'no-prompt-compatible')));", + " jgd_can_prompt=!(mode %in% c('no-prompt', 'no-prompt-compatible')),", + " 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), result);" @@ -262,6 +264,7 @@ local({ 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')"), 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 016f3cd51..d517f36af 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -1,5 +1,7 @@ 'use strict'; +import { JGD_MINIMUM_VERSION } from './jgdVersion'; + import * as path from 'path'; import * as crypto from 'node:crypto'; import { getMigratedSetting } from './configuration'; @@ -354,6 +356,7 @@ export async function makeTerminalOptions(resource?: vscode.Uri): Promise('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) {', diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index 2fab7a1e3..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'; @@ -998,6 +999,9 @@ suite('Session Communication', () => { 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( diff --git a/src/test/suite/terminal.test.ts b/src/test/suite/terminal.test.ts index 4078d64f2..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'; @@ -858,6 +859,7 @@ suite('R Terminal', () => { ), ); 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); diff --git a/src/test/suite/toolConsentAdapter.test.ts b/src/test/suite/toolConsentAdapter.test.ts index b5a157a51..39f2192a8 100644 --- a/src/test/suite/toolConsentAdapter.test.ts +++ b/src/test/suite/toolConsentAdapter.test.ts @@ -1,4 +1,5 @@ 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'; @@ -7,10 +8,12 @@ import * as vscode from 'vscode'; import { createToolConsentService } from '../../toolConsentAdapter'; suite('Managed package consent persistence', () => { - test("Don't ask again saves global state and explicit jgd still prompts", async () => { + 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([['jgdInstallPromptDismissed', true]]); + 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, @@ -28,8 +31,8 @@ suite('Managed package consent persistence', () => { await broker.start(); assert.strictEqual(broker.canPrompt('auto'), false); assert.strictEqual(broker.canPrompt('jgd'), true); - // Start the request flow with no previously saved dismissal. - values.clear(); + // 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); @@ -40,7 +43,7 @@ suite('Managed package consent persistence', () => { backend, '1', 'jgd', - '0.1.0', + JGD_MINIMUM_VERSION, '1.2.0', 'https://cloud.r-project.org', 'source', @@ -68,7 +71,9 @@ suite('Managed package consent persistence', () => { } } } - assert.deepStrictEqual(update.args, [['jgdInstallPromptDismissed', true]]); + 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); diff --git a/src/toolConsentAdapter.ts b/src/toolConsentAdapter.ts index d1baf9db2..ceb6ed04c 100644 --- a/src/toolConsentAdapter.ts +++ b/src/toolConsentAdapter.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import { JGD_MINIMUM_VERSION } from './jgdVersion'; import { ToolConsentChoice, ToolConsentRequest, @@ -6,7 +7,7 @@ import { ToolConsentSessionState, } from './toolConsent'; -const JGD_INSTALL_PROMPT_DISMISSED = 'jgdInstallPromptDismissed'; +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 }; @@ -35,10 +36,15 @@ export function createToolConsentService( return new ToolConsentService({ directory, isEnabled, - isAutoPromptEnabled: () => globalState.get(JGD_INSTALL_PROMPT_DISMISSED) !== true, + isAutoPromptEnabled: () => + globalState.get(JGD_INSTALL_PROMPT_DISMISSED_MINIMUM_VERSION) !== + JGD_MINIMUM_VERSION, sessionState: hostSessionState, rememberAutoPromptDismissal: async () => { - await globalState.update(JGD_INSTALL_PROMPT_DISMISSED, true); + await globalState.update( + JGD_INSTALL_PROMPT_DISMISSED_MINIMUM_VERSION, + JGD_MINIMUM_VERSION, + ); }, intervalMs, prompt: async (request): Promise => { From 628fef2981135024955c84ee608984779cdbd69c Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 03:32:27 +0000 Subject: [PATCH 16/21] style: group jgd version imports with local modules --- src/rTerminal.ts | 3 +-- src/session.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/rTerminal.ts b/src/rTerminal.ts index d517f36af..713862a41 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -1,10 +1,9 @@ 'use strict'; -import { JGD_MINIMUM_VERSION } from './jgdVersion'; - 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'; diff --git a/src/session.ts b/src/session.ts index 606d07f70..8f1defce5 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,10 +1,9 @@ 'use strict'; -import { JGD_MINIMUM_VERSION } from './jgdVersion'; - 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'; From 181a1236711e34827205c142cecfb60b9252010d Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 03:40:52 +0000 Subject: [PATCH 17/21] fix: clarify managed package recovery and consent format --- R/tests/tool-library.R | 19 +++++++++++++++++++ R/tool-library.R | 8 +++++++- src/test/suite/toolConsentAdapter.test.ts | 6 ++++-- src/toolConsentAdapter.ts | 2 +- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/R/tests/tool-library.R b/R/tests/tool-library.R index 123a6a965..c720e4816 100644 --- a/R/tests/tool-library.R +++ b/R/tests/tool-library.R @@ -56,6 +56,25 @@ local({ 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") diff --git a/R/tool-library.R b/R/tool-library.R index 4b74fcb87..4936aff3d 100644 --- a/R/tool-library.R +++ b/R/tool-library.R @@ -286,7 +286,13 @@ tool_library_load <- function(package, minimum_version, root) { } if (selected$source == "loaded") return(asNamespace(package)) if (selected$install_required) return(NULL) - namespace <- loadNamespace(package, lib.loc = selected$library) + 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)) diff --git a/src/test/suite/toolConsentAdapter.test.ts b/src/test/suite/toolConsentAdapter.test.ts index 39f2192a8..67ad3a149 100644 --- a/src/test/suite/toolConsentAdapter.test.ts +++ b/src/test/suite/toolConsentAdapter.test.ts @@ -46,8 +46,8 @@ suite('Managed package consent persistence', () => { JGD_MINIMUM_VERSION, '1.2.0', 'https://cloud.r-project.org', - 'source', - 'yes', + backend === 'auto' ? 'source' : 'win.binary', + backend === 'auto' ? 'yes' : 'no', '', ].join('\n'); await fs.writeFile(path.join(directory, id + '.request'), content); @@ -77,6 +77,8 @@ suite('Managed package consent persistence', () => { 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(); diff --git a/src/toolConsentAdapter.ts b/src/toolConsentAdapter.ts index ceb6ed04c..bd120034e 100644 --- a/src/toolConsentAdapter.ts +++ b/src/toolConsentAdapter.ts @@ -15,7 +15,7 @@ function formatPlan(request: ToolConsentRequest): string { const [platform, version] = request.runtime.split('|'); const rows = request.packages.map( (item) => - `• ${item.name} ${item.version} (minimum ${item.minimumVersion})\n Repository: ${item.repository}`, + `• ${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.' From bb14b551630dbaef24a2aa2e764bd59d15f893cc Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 07:25:37 +0000 Subject: [PATCH 18/21] fix: preserve jgd preparation decisions through discovery reconnects --- R/attach_sess.R | 20 +- R/prepare-jgd.R | 23 +- R/profile.R | 5 +- R/tests/attach_sess.R | 4 +- R/tests/prepare-jgd.R | 223 ++++++++++++++++-- sess/R/hooks.R | 16 +- sess/R/server.R | 27 ++- sess/inst/tinytest/test-attachment-metadata.R | 15 +- sess/inst/tinytest/test-native-plot.R | 31 +++ sess/inst/tinytest/test-reconnect.R | 15 ++ sess/man/connect.Rd | 15 +- sess/man/runtime_start.Rd | 6 +- 12 files changed, 328 insertions(+), 72 deletions(-) diff --git a/R/attach_sess.R b/R/attach_sess.R index a7a833251..500c6dd40 100644 --- a/R/attach_sess.R +++ b/R/attach_sess.R @@ -12,9 +12,8 @@ vscode_r_with_jgd <- function( 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") || - !nzchar(Sys.getenv("JGD_SOCKET", unset = ""))) { - return(connect()) + 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) && @@ -32,14 +31,8 @@ vscode_r_with_jgd <- function( tool_library_helper, tool_consent_helper, can_prompt, minimum_version )) } - - previous_socket <- Sys.getenv("JGD_SOCKET", unset = NA_character_) - on.exit({ - if (is.na(previous_socket)) Sys.unsetenv("JGD_SOCKET") else - Sys.setenv(JGD_SOCKET = previous_socket) - }, add = TRUE) - Sys.unsetenv("JGD_SOCKET") - connect() + 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, @@ -300,7 +293,10 @@ vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, } vscode_r_with_jgd( plot_backend, - function() do.call(connect, args), + 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 ) diff --git a/R/prepare-jgd.R b/R/prepare-jgd.R index 91b6763a2..ca5a8b80f 100644 --- a/R/prepare-jgd.R +++ b/R/prepare-jgd.R @@ -1,4 +1,4 @@ -# Keep socket fallback, preparation, connection, and restoration in one place. +# Keep optional jgd preparation and its result shared by terminal connections. vscode_r_connect_with_jgd <- function( plot_backend, connect, @@ -10,26 +10,14 @@ vscode_r_connect_with_jgd <- function( 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") || !nzchar(Sys.getenv("JGD_SOCKET", unset = ""))) { - return(connect()) + if (!plot_backend %in% c("jgd", "auto")) { + return(connect(allow_jgd = TRUE)) } - previous_socket <- Sys.getenv("JGD_SOCKET", unset = NA_character_) - on.exit({ - if (is.na(previous_socket)) { - Sys.unsetenv("JGD_SOCKET") - } else { - Sys.setenv(JGD_SOCKET = previous_socket) - } - }, add = TRUE) ready <- isTRUE(vscode_r_prepare_jgd( plot_backend, tool_root, consent_directory, library_helper, consent_helper, can_prompt, minimum_version )) - if (!ready) { - # An old loaded jgd namespace still satisfies requireNamespace; hide the socket so hooks choose fallback. - Sys.unsetenv("JGD_SOCKET") - } - connect() + connect(allow_jgd = ready) } # Prepare optional plot support without making sess startup depend on CRAN access @@ -47,9 +35,6 @@ vscode_r_prepare_jgd <- function( is.na(plot_backend) || !plot_backend %in% c("jgd", "auto")) { return(FALSE) } - if (!nzchar(Sys.getenv("JGD_SOCKET", unset = ""))) { - 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) } diff --git a/R/profile.R b/R/profile.R index 86098c1b0..937402003 100644 --- a/R/profile.R +++ b/R/profile.R @@ -52,8 +52,9 @@ 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 <- vscode_r_with_jgd(plot_backend, function() { - 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 index caa1ab117..37548d2c7 100644 --- a/R/tests/prepare-jgd.R +++ b/R/tests/prepare-jgd.R @@ -5,6 +5,17 @@ local({ 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(paste0("R_LIBS_USER=", shQuote(user_library)), + paste0("R_LIBS_SITE=", shQuote(site_library)), + paste0("R_LIBS=", shQuote(libraries)), + paste0("VSCODE_R_TEST_AMBIENT_LIBRARY=", shQuote(ambient_library))) + } source_package <- function(version) { package <- file.path(root, paste0("jgd-", version)) @@ -18,7 +29,7 @@ local({ "fixture_device <- function() 'jgd'", "jgd <- function() {", " path <- Sys.getenv('JGD_TEST_DEVICE_LOG', '')", - " if (nzchar(path)) writeLines('jgd', path)", + " if (nzchar(path)) cat('jgd\\n', file=path, append=TRUE)", " invisible(NULL)", "}" ), file.path(package, "R", "api.R")) @@ -27,6 +38,7 @@ local({ on.exit(setwd(previous), add = TRUE) output <- system2(file.path(R.home("bin"), "R"), c("CMD", "build", "--no-build-vignettes", shQuote(basename(package))), + env = isolated_child_env(paste0("build-", version)), stdout = TRUE, stderr = TRUE) status <- attr(output, "status") if (!is.null(status) && status != 0L) stop(paste(output, collapse = "\n")) @@ -44,10 +56,59 @@ local({ 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(file.path(R.home("bin"), "R"), + c("CMD", "INSTALL", paste0("--library=", shQuote(ambient_library)), + shQuote(file.path(root, "jgd-0.1.0"))), + env = isolated_child_env("ambient-install"), + 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(file.path(R.home("bin"), "R"), + c("CMD", "INSTALL", paste0("--library=", shQuote(sess_library)), + shQuote(file.path(getwd(), "sess"))), + env = isolated_child_env("sess-install", dependency_library), + 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'));", @@ -59,12 +120,14 @@ local({ "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(...) {", + "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 <- nzchar(connect_seen$socket) && connect_seen$loaded;", + " 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') {", @@ -73,9 +136,11 @@ local({ " .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);", @@ -86,12 +151,14 @@ local({ " '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=file.path(getwd(), 'R', 'prepare-jgd.R'),", - " jgd_can_prompt=!(mode %in% c('no-prompt', 'no-prompt-compatible')),", + " 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), result);" + "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") @@ -99,6 +166,7 @@ local({ child_args <- c("--vanilla", "--slave", paste0("--file=", runner), "--args", mode, root, repo, result) output <- system2(r_binary, shQuote(child_args), + env = isolated_child_env(mode), stdout = TRUE, stderr = TRUE) status <- attr(output, "status") if (!is.null(status) && status != 0L) stop(paste(output, collapse = "\n")) @@ -132,12 +200,126 @@ local({ } } + 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(r_binary, shQuote(args), wait = wait, + env = isolated_child_env(paste0("real-", mode), + paste(sess_library, dependency_library, sep = .Platform$path.sep)), + 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(r_binary, shQuote(approved_args), wait = FALSE, + env = isolated_child_env("approved"), stdout = file.path(root, "approved-out"), stderr = file.path(root, "approved-err")) request <- wait_requests(file.path(approved_root, "consent"), 1L) @@ -151,7 +333,7 @@ local({ })) stop("Approved jgd attach did not finish: ", paste(logs, collapse = "\n")) } - stopifnot(identical(readLines(approved_result), c("fixture-socket", "TRUE", "jgd"))) + 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") @@ -160,6 +342,7 @@ local({ declined_args <- c("--vanilla", "--slave", paste0("--file=", runner), "--args", "declined", declined_root, repo, declined_result) declined_process <- system2(r_binary, shQuote(declined_args), wait = FALSE, + env = isolated_child_env("declined"), stdout = file.path(root, "declined-out"), stderr = file.path(root, "declined-err")) request <- wait_requests(file.path(declined_root, "consent"), 1L) @@ -167,12 +350,18 @@ local({ 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("", "FALSE", "standard"))) + stopifnot(identical(readLines(declined_result), c("fixture-socket", "FALSE", "standard", "FALSE"))) - for (mode in c("old", "offline")) { + 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) - stopifnot(identical(observed, c("", if (mode == "old") "TRUE" else "FALSE", "standard"))) + 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) } @@ -186,9 +375,9 @@ local({ result <- file.path(root, paste0(mode, "-result")) observed <- run_child(mode, result) if (mode == "no-prompt") { - stopifnot(identical(observed, c("", "FALSE", "standard"))) + stopifnot(identical(observed, c("fixture-socket", "FALSE", "standard", "FALSE"))) } else { - stopifnot(identical(observed, c("fixture-socket", "TRUE", "jgd"))) + 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) @@ -211,6 +400,7 @@ local({ child_args <- c("--vanilla", "--slave", paste0("--file=", runner), "--args", "parallel", parallel_root, repo, parallel_result[[index]]) system2(r_binary, shQuote(child_args), wait = FALSE, + env = isolated_child_env(paste0("parallel-", index)), stdout = file.path(parallel_root, paste0(index, "-out")), stderr = file.path(parallel_root, paste0(index, "-err"))) }) @@ -220,7 +410,7 @@ local({ 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")) + identical(readLines(path, warn = FALSE), c("fixture-socket", "TRUE", "jgd", "TRUE")) }, FALSE))) stopifnot(length(readLines(file.path(parallel_root, "verify-log"), warn = FALSE)) == 1L) @@ -238,9 +428,9 @@ local({ 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(...) {", + "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(requireNamespace('jgd', quietly=TRUE)), as.character(allow_jgd)), ", "", r_literal(profile_log), ")"), " TRUE", "}; ns }" @@ -272,6 +462,7 @@ local({ ), startup_profile) profile_result <- file.path(profile_root, "result") profile_process <- system2(r_binary, shQuote(c("--vanilla", "--slave", paste0("--file=", startup_profile))), + env = isolated_child_env("profile"), wait = FALSE, stdout = file.path(profile_root, "stdout"), stderr = file.path(profile_root, "stderr")) request <- wait_requests(profile_consent, 1L) @@ -279,7 +470,7 @@ local({ 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("", "FALSE"))) + 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" && 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) } From 15a51ed753baf19cd5d67c095759e491d975db8d Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 07:28:50 +0000 Subject: [PATCH 19/21] chore: bump sess development version for allow_jgd --- sess/DESCRIPTION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 0709010e9661cfb1c4b1774085f1e4d6eaba0420 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 07:32:02 +0000 Subject: [PATCH 20/21] ci: run managed jgd preparation tests after installing sess The preparation tests now drive real sess connections and need the sess dependencies installed by the build step. --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d3c8b29d5..517e1cef3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -42,12 +42,12 @@ jobs: run: pnpm run test:tool-library - name: Verify managed tool consent run: pnpm run test:tool-consent - - name: Verify managed jgd preparation - run: pnpm run test:prepare-jgd - 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") From 7872c96a078f2bce1fa0be12e4db01a3ee082abe Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 25 Oct 2026 07:40:35 +0000 Subject: [PATCH 21/21] fix: inherit child R test environments on Windows --- R/tests/prepare-jgd.R | 110 ++++++++++++++++++++++++++---------------- 1 file changed, 68 insertions(+), 42 deletions(-) diff --git a/R/tests/prepare-jgd.R b/R/tests/prepare-jgd.R index 37548d2c7..c06180a89 100644 --- a/R/tests/prepare-jgd.R +++ b/R/tests/prepare-jgd.R @@ -11,10 +11,26 @@ local({ 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(paste0("R_LIBS_USER=", shQuote(user_library)), - paste0("R_LIBS_SITE=", shQuote(site_library)), - paste0("R_LIBS=", shQuote(libraries)), - paste0("VSCODE_R_TEST_AMBIENT_LIBRARY=", shQuote(ambient_library))) + 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) { @@ -36,10 +52,12 @@ local({ writeLines(c("export(fixture_device)", "export(jgd)"), file.path(package, "NAMESPACE")) previous <- setwd(root) on.exit(setwd(previous), add = TRUE) - output <- system2(file.path(R.home("bin"), "R"), - c("CMD", "build", "--no-build-vignettes", shQuote(basename(package))), - env = isolated_child_env(paste0("build-", version)), - stdout = TRUE, stderr = 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")) @@ -57,11 +75,13 @@ local({ 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(file.path(R.home("bin"), "R"), - c("CMD", "INSTALL", paste0("--library=", shQuote(ambient_library)), - shQuote(file.path(root, "jgd-0.1.0"))), - env = isolated_child_env("ambient-install"), - stdout = TRUE, stderr = TRUE) + 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")) @@ -94,11 +114,13 @@ local({ } sess_library <- file.path(root, "sess-library") dir.create(sess_library) - sess_install <- system2(file.path(R.home("bin"), "R"), - c("CMD", "INSTALL", paste0("--library=", shQuote(sess_library)), - shQuote(file.path(getwd(), "sess"))), - env = isolated_child_env("sess-install", dependency_library), - stdout = TRUE, stderr = TRUE) + 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")) @@ -165,9 +187,8 @@ local({ run_child <- function(mode, result) { child_args <- c("--vanilla", "--slave", paste0("--file=", runner), "--args", mode, root, repo, result) - output <- system2(r_binary, shQuote(child_args), - env = isolated_child_env(mode), - stdout = TRUE, stderr = TRUE) + 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) @@ -274,11 +295,13 @@ local({ run_real_child <- function(mode, result, wait = TRUE) { args <- c("--vanilla", "--slave", paste0("--file=", real_runner), "--args", mode, root, repo, result, sess_library) - system2(r_binary, shQuote(args), wait = wait, - env = isolated_child_env(paste0("real-", mode), - paste(sess_library, dependency_library, sep = .Platform$path.sep)), - stdout = if (wait) TRUE else file.path(root, paste0(mode, "-out")), - stderr = if (wait) TRUE else file.path(root, paste0(mode, "-err"))) + 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")) { @@ -318,10 +341,10 @@ local({ 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(r_binary, shQuote(approved_args), wait = FALSE, - env = isolated_child_env("approved"), - stdout = file.path(root, "approved-out"), - stderr = file.path(root, "approved-err")) + 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 @@ -341,10 +364,10 @@ local({ 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(r_binary, shQuote(declined_args), wait = FALSE, - env = isolated_child_env("declined"), - stdout = file.path(root, "declined-out"), - stderr = file.path(root, "declined-err")) + 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 @@ -399,10 +422,11 @@ local({ 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(r_binary, shQuote(child_args), wait = FALSE, - env = isolated_child_env(paste0("parallel-", index)), - stdout = file.path(parallel_root, paste0(index, "-out")), - stderr = file.path(parallel_root, paste0(index, "-err"))) + 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") @@ -461,10 +485,12 @@ local({ "source(file.path(getwd(), 'R', 'profile.R'))" ), startup_profile) profile_result <- file.path(profile_root, "result") - profile_process <- system2(r_binary, shQuote(c("--vanilla", "--slave", paste0("--file=", startup_profile))), - env = isolated_child_env("profile"), - wait = FALSE, stdout = file.path(profile_root, "stdout"), - stderr = file.path(profile_root, "stderr")) + 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