From 936b96258c5421eb8fa7b36711c2318a1f723418 Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Wed, 14 Oct 2026 13:13:57 -0400 Subject: [PATCH 01/14] Add the remote install contracts, product pin and settings Host states, install steps and the host list join the protocol. Product gains the remote tarball pin, and Desktop gains the review.remote.* settings. --- .../code-oss/src/vs/base/common/product.ts | 13 +++ .../vs/review/common/reviewConfiguration.ts | 14 +++- .../common/reviewConfigurationDefaults.ts | 3 + packages/review-protocol/src/contracts.ts | 79 ++++++++++++++++++- 4 files changed, 106 insertions(+), 3 deletions(-) diff --git a/apps/review-desktop/code-oss/src/vs/base/common/product.ts b/apps/review-desktop/code-oss/src/vs/base/common/product.ts index 0f75ee30d..9b2ec97e7 100644 --- a/apps/review-desktop/code-oss/src/vs/base/common/product.ts +++ b/apps/review-desktop/code-oss/src/vs/base/common/product.ts @@ -64,6 +64,17 @@ export type ExtensionVirtualWorkspaceSupport = { readonly override?: boolean; }; +export interface IWhiteboardRemoteArtifact { + readonly url: string; + readonly sha256: string; +} + +export interface IWhiteboardRemoteProduct { + readonly version: string; + readonly 'linux-x64': IWhiteboardRemoteArtifact; + readonly 'linux-arm64': IWhiteboardRemoteArtifact; +} + export interface IProductConfiguration { readonly version: string; readonly date?: string; @@ -78,6 +89,8 @@ export interface IProductConfiguration { */ readonly reviewVersion?: string; + readonly whiteboardRemote?: IWhiteboardRemoteProduct; + readonly nameShort: string; readonly nameLong: string; diff --git a/apps/review-desktop/code-oss/src/vs/review/common/reviewConfiguration.ts b/apps/review-desktop/code-oss/src/vs/review/common/reviewConfiguration.ts index a97a8b908..c605136c0 100644 --- a/apps/review-desktop/code-oss/src/vs/review/common/reviewConfiguration.ts +++ b/apps/review-desktop/code-oss/src/vs/review/common/reviewConfiguration.ts @@ -20,7 +20,7 @@ import { localize } from '../../nls.js'; import { Registry } from '../../platform/registry/common/platform.js'; import { ConfigurationScope, Extensions, type IConfigurationRegistry } from '../../platform/configuration/common/configurationRegistry.js'; -import { REVIEW_CTRL_TAB_CHOICES, REVIEW_CTRL_TAB_SETTING, REVIEW_DOCUMENT_WIDTH_CHOICES, REVIEW_DOCUMENT_WIDTH_SETTING, REVIEW_KEYMAPS, REVIEW_KEYMAP_SETTING, REVIEW_READY_NOTIFICATION_CHOICES, REVIEW_READY_NOTIFICATION_SETTING, REVIEW_REMOTE_HOSTS_ENABLED_SETTING, REVIEW_REMOTE_HOSTS_SETTING, REVIEW_SOFTWARE_MAP_SETTING, REVIEW_STRUCTURAL_DIFF_SETTING, REVIEW_TELEMETRY_SETTING, curatedExtensionConfigurationDefaults, reviewConfigurationDefaults } from './reviewConfigurationDefaults.js'; +import { REVIEW_CTRL_TAB_CHOICES, REVIEW_CTRL_TAB_SETTING, REVIEW_DOCUMENT_WIDTH_CHOICES, REVIEW_DOCUMENT_WIDTH_SETTING, REVIEW_KEYMAPS, REVIEW_KEYMAP_SETTING, REVIEW_READY_NOTIFICATION_CHOICES, REVIEW_READY_NOTIFICATION_SETTING, REVIEW_REMOTE_HOSTS_ENABLED_SETTING, REVIEW_REMOTE_HOSTS_SETTING, REVIEW_REMOTE_INSTALL_MODES, REVIEW_REMOTE_INSTALL_SETTING, REVIEW_SOFTWARE_MAP_SETTING, REVIEW_STRUCTURAL_DIFF_SETTING, REVIEW_TELEMETRY_SETTING, curatedExtensionConfigurationDefaults, reviewConfigurationDefaults } from './reviewConfigurationDefaults.js'; const configurationRegistry = Registry.as(Extensions.Configuration); @@ -95,6 +95,18 @@ configurationRegistry.registerConfiguration({ default: false, description: localize('review.experimental.remoteHosts.enabled', "Connect over SSH to the hosts in `review.remote.hosts`."), }, + [REVIEW_REMOTE_INSTALL_SETTING]: { + type: 'string', + enum: [...REVIEW_REMOTE_INSTALL_MODES], + enumDescriptions: [ + localize('review.remote.install.ask', "Ask once per host before installing."), + localize('review.remote.install.always', "Install without asking."), + localize('review.remote.install.never', "Never install; use the Whiteboard you installed on the host."), + ], + default: 'ask', + scope: ConfigurationScope.APPLICATION, + description: localize('review.remote.install', "Whether Whiteboard installs its own version on a remote host, under ~/.dev/whiteboard-remote, when the host does not have it."), + }, }, }); diff --git a/apps/review-desktop/code-oss/src/vs/review/common/reviewConfigurationDefaults.ts b/apps/review-desktop/code-oss/src/vs/review/common/reviewConfigurationDefaults.ts index b22d1a3c5..3ec61b147 100644 --- a/apps/review-desktop/code-oss/src/vs/review/common/reviewConfigurationDefaults.ts +++ b/apps/review-desktop/code-oss/src/vs/review/common/reviewConfigurationDefaults.ts @@ -28,6 +28,8 @@ export const REVIEW_STRUCTURAL_DIFF_SETTING = 'review.experimental.structuralDif export const REVIEW_SOFTWARE_MAP_SETTING = 'review.experimental.softwareMap.enabled'; export const REVIEW_REMOTE_HOSTS_SETTING = 'review.remote.hosts'; export const REVIEW_REMOTE_HOSTS_ENABLED_SETTING = 'review.experimental.remoteHosts.enabled'; +export const REVIEW_REMOTE_INSTALL_SETTING = 'review.remote.install'; +export const REVIEW_REMOTE_INSTALL_MODES = ['ask', 'always', 'never'] as const; export const REVIEW_KEYMAPS = ['none', 'vim', 'emacs', 'sublime'] as const; export type ReviewKeymap = typeof REVIEW_KEYMAPS[number]; export const REVIEW_CTRL_TAB_SETTING = 'review.tabs.ctrlTab'; @@ -42,6 +44,7 @@ export const reviewConfigurationDefaults = { [REVIEW_SOFTWARE_MAP_SETTING]: false, [REVIEW_REMOTE_HOSTS_SETTING]: [], [REVIEW_REMOTE_HOSTS_ENABLED_SETTING]: false, + [REVIEW_REMOTE_INSTALL_SETTING]: 'ask', [REVIEW_STRUCTURAL_DIFF_SETTING]: true, [REVIEW_TELEMETRY_SETTING]: true, 'telemetry.telemetryLevel': 'off', diff --git a/packages/review-protocol/src/contracts.ts b/packages/review-protocol/src/contracts.ts index de4b8baf1..61eb359e7 100644 --- a/packages/review-protocol/src/contracts.ts +++ b/packages/review-protocol/src/contracts.ts @@ -597,12 +597,49 @@ export interface ReviewRemoteHostsSettings { states(): Promise; set(aliases: string[]): Promise; retry(alias: string): Promise; + install(alias: string): Promise; + agents(alias: string): Promise; + connectAgents( + alias: string, + agents: ReviewRemoteAgentId[], + ): Promise; + uninstall(alias: string): Promise; } +export const REVIEW_REMOTE_WRAPPER_MARK = + "# Written by Whiteboard Desktop, which replaces it with each install."; + export const REVIEW_REMOTE_ATTACH_BEGIN = "WHITEBOARD-REMOTE-BEGIN"; export const REVIEW_REMOTE_ATTACH_END = "WHITEBOARD-REMOTE-END"; +export const REVIEW_REMOTE_VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/; + +export const REVIEW_REMOTE_INSTALL_LOCK = "install.lock"; + +export const REVIEW_REMOTE_LOCK_STALE_SECONDS = 15 * 60; + +export const REVIEW_REMOTE_AGENT_IDS = [ + "claude", + "codex", + "opencode", + "pi", +] as const satisfies readonly ReviewCliInstallTarget[]; + +export type ReviewRemoteAgentId = (typeof REVIEW_REMOTE_AGENT_IDS)[number]; + +export interface ReviewRemoteAgent { + id: ReviewRemoteAgentId; + connected: boolean; + manual?: true; +} + +export interface ReviewRemoteAgentResult { + id: ReviewRemoteAgentId; + connected: boolean; + output: string; +} + /** Workspace attachment identity is independent of the displayed source generation. */ export interface ReviewLanguageEnvironment { /** Absent for a caller on another machine. */ @@ -879,6 +916,13 @@ export interface ReviewServerHealthWithToken extends ReviewServerHealth { commit: string | null; } +export const REVIEW_REMOTE_INSTALL_STEPS = [ + "preparing", + "waiting-for-lock", + "package", + "done", +] as const; + export const ReviewGatewayHostSchema = z.strictObject({ alias: requiredString, endpoint: z @@ -886,10 +930,25 @@ export const ReviewGatewayHostSchema = z.strictObject({ .optional(), problem: z .strictObject({ - state: z.enum(["unreachable", "not-installed", "auth-failed"]), + state: z.enum([ + "unreachable", + "not-installed", + "auth-failed", + "unsupported", + "incompatible", + ]), detail: stringAllowEmpty, }) .optional(), + declined: z.literal(true).optional(), + asking: requiredString.optional(), + installFailure: stringAllowEmpty.optional(), + target: z.enum(["linux-x64", "linux-arm64"]).optional(), + installing: z + .strictObject({ + step: z.enum(REVIEW_REMOTE_INSTALL_STEPS), + }) + .optional(), languageFeatures: z.boolean().optional(), languageFeaturesDetail: stringAllowEmpty.optional(), languageGroups: z @@ -916,12 +975,15 @@ export interface ReviewGatewayHostState { | "duplicate" | "unreachable" | "not-installed" - | "auth-failed"; + | "auth-failed" + | "unsupported" + | "installing"; detail?: string; installCommand?: string; languageFeatures?: boolean; languageFeaturesDetail?: string; languageGroups?: ReviewGatewayHost["languageGroups"]; + declined?: true; } export const ReviewRepositoryIdentitySchema = z.strictObject({ @@ -1037,6 +1099,19 @@ export type ReviewCliInstallTarget = z.infer< typeof ReviewCliInstallTargetSchema >; +export const REVIEW_CLI_INSTALL_TARGET_LABELS: Record< + ReviewCliInstallTarget, + string +> = { + claude: "Claude Code", + codex: "Codex", + cursor: "Cursor", + opencode: "OpenCode", + pi: "Pi", + omp: "oh-my-pi", + copilot: "Copilot CLI", +}; + export const ReviewCliInstallStampSchema = z.object({ consent: z.enum(["granted", "declined", "skipped"], { error: "must be granted, declined, or skipped", From a4583feb50aff5d6ceee9878e839034d5206fe51 Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Wed, 14 Oct 2026 13:13:58 -0400 Subject: [PATCH 02/14] Build a self-contained remote tarball per Linux arch The tarball carries its own Node, the CLI and the VS Code server, with only the target arch's binaries. --- .../scripts/build-remote-tarball.mjs | 497 ++++++++++++++++++ .../scripts/build-remote-tarball.test.mjs | 245 +++++++++ .../scripts/stage-vscode-server.mjs | 12 + .../scripts/stage-vscode-server.test.mjs | 157 ++++++ 4 files changed, 911 insertions(+) create mode 100644 apps/review-desktop/scripts/build-remote-tarball.mjs create mode 100644 apps/review-desktop/scripts/build-remote-tarball.test.mjs create mode 100644 apps/review-desktop/scripts/stage-vscode-server.test.mjs diff --git a/apps/review-desktop/scripts/build-remote-tarball.mjs b/apps/review-desktop/scripts/build-remote-tarball.mjs new file mode 100644 index 000000000..431634228 --- /dev/null +++ b/apps/review-desktop/scripts/build-remote-tarball.mjs @@ -0,0 +1,497 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { + chmod, + cp, + mkdir, + readFile, + readdir, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +import { + downloadPinned, + sha256File, +} from "../../../packages/review/src/pinned-download.ts"; +import { buildRemoteRuntime } from "./build-remote-runtime.mjs"; +import { stageReviewDocs } from "./stage-review-runtime.mjs"; +import { + pruneVscodeServerArch, + stageVscodeServer, +} from "./stage-vscode-server.mjs"; + +export const REMOTE_TARGETS = ["linux-x64", "linux-arm64"]; + +export const REMOTE_TARBALL_MAX_BYTES = 80 * 1024 * 1024; + +export const remoteTarballName = (version, target) => + `whiteboard-remote-${version}-${target}.tar.xz`; + +const appDirectory = path.resolve(import.meta.dirname, ".."); + +const codeOss = path.join(appDirectory, "code-oss"); + +const monorepoRoot = path.resolve(appDirectory, "..", ".."); + +const reviewPackage = path.join(monorepoRoot, "packages", "review"); + +const DEFAULT_CACHE = path.join(codeOss, ".build", "remote-tarball-cache"); + +const ADAPTERS = [ + "@agentclientprotocol/claude-agent-acp", + "@agentclientprotocol/codex-acp", + "pi-acp", +]; + +const SIDECAR = ["sharp", "@img/sharp-wasm32", "@parcel/watcher"]; + +const PACKAGE_DATA = [ + "LICENSE", + "THIRD_PARTY_NOTICES.md", + "onboarding.md", + "instructions", + "tutorial", +]; + +const reviewRequire = createRequire(path.join(reviewPackage, "package.json")); + +const readJson = async (file) => JSON.parse(await readFile(file, "utf8")); + +function installedPackage(name) { + for (const directory of reviewRequire.resolve.paths(name) ?? []) { + const root = path.join(directory, name); + + if (existsSync(path.join(root, "package.json"))) return root; + } + + throw new Error(`${name} is not installed for packages/review`); +} + +export async function pinnedNode( + target, + { cacheDir, dist = "https://nodejs.org/dist" }, +) { + const nvmrc = path.join(codeOss, ".nvmrc"); + const version = (await readFile(nvmrc, "utf8")).trim(); + + if (!/^\d+\.\d+\.\d+$/.test(version)) + throw new Error(`${nvmrc} does not hold an exact Node version.`); + + const sums = path.join(cacheDir, `node-v${version}-SHASUMS256.txt`); + let text = await readFile(sums, "utf8").catch(() => undefined); + + if (text === undefined) { + const url = `${dist}/v${version}/SHASUMS256.txt`; + const response = await fetch(url, { signal: AbortSignal.timeout(60_000) }); + + if (!response.ok) throw new Error(`${url} answered ${response.status}.`); + + text = await response.text(); + await mkdir(cacheDir, { recursive: true }); + await writeFile(sums, text); + } + + const name = `node-v${version}-${target}.tar.xz`; + + const sha256 = text + .split("\n") + .map((line) => line.trim().split(/\s+/)) + .find(([, file]) => file === name)?.[0]; + + if (!sha256 || !/^[0-9a-f]{64}$/.test(sha256)) + throw new Error( + `SHASUMS256.txt for Node ${version} has no line for ${name}.`, + ); + + return { url: `${dist}/v${version}/${name}`, sha256, version }; +} + +async function bundleCli(stage) { + const esbuild = createRequire(path.join(codeOss, "build/package.json"))( + "esbuild", + ); + + const options = { + bundle: true, + platform: "node", + format: "esm", + target: "node24", + banner: { + js: 'import { createRequire as __cr } from "node:module"; const require = __cr(import.meta.url);', + }, + logLevel: "warning", + }; + + await esbuild.build({ + ...options, + entryPoints: [path.join(reviewPackage, "dist", "cli.js")], + outfile: path.join(stage, "dist", "cli.js"), + external: ["sharp", "@parcel/watcher", "@dev.fast/diffr", ...ADAPTERS], + }); + await cp( + path.join(reviewPackage, "dist", "build-info.json"), + path.join(stage, "dist", "build-info.json"), + ); + + for (const name of ADAPTERS) { + const source = installedPackage(name); + const destination = path.join(stage, "node_modules", name); + + await esbuild.build({ + ...options, + entryPoints: [path.join(source, "dist", "index.js")], + outfile: path.join(destination, "dist", "index.js"), + }); + + const { version } = await readJson(path.join(source, "package.json")); + await writeFile( + path.join(destination, "package.json"), + `${JSON.stringify({ name, version, type: "module" }, null, 2)}\n`, + ); + } +} + +async function installSidecar(stage, target, stdio) { + const sidecar = path.join(stage, ".sidecar"); + const modules = path.join(stage, "node_modules"); + const arch = target.replace("linux-", ""); + + const { version: diffr } = await readJson( + path.join(monorepoRoot, "diffr", "diffr-ts", "package.json"), + ); + + const dependencies = { + ...Object.fromEntries( + await Promise.all( + SIDECAR.map(async (name) => [ + name, + (await readJson(path.join(installedPackage(name), "package.json"))) + .version, + ]), + ), + ), + "@dev.fast/diffr": diffr, + }; + + await mkdir(sidecar, { recursive: true }); + await writeFile( + path.join(sidecar, "package.json"), + `${JSON.stringify({ private: true, dependencies }, null, 2)}\n`, + ); + + execFileSync( + "npm", + [ + "install", + "--os", + "linux", + "--cpu", + arch, + "--libc", + "glibc", + "--omit=dev", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--no-package-lock", + "--loglevel=error", + ], + { + cwd: sidecar, + stdio, + env: Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !/^npm_config_/i.test(key), + ), + ), + }, + ); + + const installed = path.join(sidecar, "node_modules"); + + for (const entry of await readdir(installed)) { + if (entry.startsWith(".")) continue; + + const names = entry.startsWith("@") + ? (await readdir(path.join(installed, entry))).map((n) => `${entry}/${n}`) + : [entry]; + + for (const name of names) { + if (/^@img\/sharp-(linux|libvips)-/.test(name)) continue; + + await mkdir(path.dirname(path.join(modules, name)), { recursive: true }); + await rename(path.join(installed, name), path.join(modules, name)); + } + } + + await rm(sidecar, { recursive: true, force: true }); + + for (const binary of [ + `@dev.fast/diffr-${target}/diffr`, + `@parcel/watcher-${target}-glibc/watcher.node`, + ]) + if (!existsSync(path.join(modules, binary))) + throw new Error(`the ${target} sidecar install has no ${binary}`); + + const { version } = await readJson( + path.join(modules, "@dev.fast/diffr/package.json"), + ); + + if (version !== diffr) + throw new Error( + `npm installed @dev.fast/diffr ${version}, not diffr-ts ${diffr}`, + ); +} + +async function stageNode(stage, target, cacheDir) { + const pin = await pinnedNode(target, { cacheDir }); + const name = path.basename(pin.url); + const file = path.join(cacheDir, name); + + await downloadPinned(pin, file); + + const bin = path.join(stage, "bin"); + await mkdir(bin, { recursive: true }); + const top = name.replace(/\.tar\.xz$/, ""); + execFileSync("tar", [ + "-xJf", + file, + "-C", + bin, + "--strip-components=1", + `${top}/bin/node`, + `${top}/LICENSE`, + ]); + await rename(path.join(bin, "bin", "node"), path.join(bin, "node")); + await rm(path.join(bin, "bin"), { recursive: true }); + await rename(path.join(bin, "LICENSE"), path.join(bin, "NODE-LICENSE")); + await chmod(path.join(bin, "node"), 0o755); +} + +async function stagePackageData(stage, stdio) { + execFileSync( + "pnpm", + ["--filter", "@dev.fast/whiteboard", "ensure:tutorial-assets"], + { + cwd: monorepoRoot, + stdio, + env: Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => key !== "TSX_TSCONFIG_PATH", + ), + ), + }, + ); + + for (const name of PACKAGE_DATA) + await cp(path.join(reviewPackage, name), path.join(stage, name), { + recursive: true, + filter: (source) => + path.relative(reviewPackage, source) !== + path.join("tutorial", ".bundle"), + }); + + await stageReviewDocs(stage); +} + +function packTar(file, stageRoot) { + const gnu = execFileSync("tar", ["--version"], { encoding: "utf8" }).includes( + "GNU", + ); + + const flags = gnu + ? ["--owner=0", "--group=0", "--numeric-owner"] + : [ + "--uid=0", + "--gid=0", + "--uname=", + "--gname=", + "--no-xattrs", + "--no-mac-metadata", + "--options=xz:threads=0", + ]; + + execFileSync( + "tar", + ["-cJf", file, ...flags, "-C", stageRoot, "whiteboard-remote"], + { env: { ...process.env, XZ_OPT: "-6 -T0", COPYFILE_DISABLE: "1" } }, + ); +} + +export async function buildRemoteTarball({ + target, + out, + version, + commit, + cacheDir = DEFAULT_CACHE, + stdio = "inherit", +}) { + if (!REMOTE_TARGETS.includes(target)) + throw new Error(`${target} is not one of ${REMOTE_TARGETS.join(", ")}`); + + const output = path.resolve(out); + const stageRoot = path.join(output, `.stage-${target}`); + const stage = path.join(stageRoot, "whiteboard-remote"); + const name = remoteTarballName(version, target); + const file = path.join(output, name); + + await rm(stageRoot, { recursive: true, force: true }); + await mkdir(stage, { recursive: true }); + + try { + execFileSync("pnpm", ["--filter", "@dev.fast/whiteboard", "build"], { + cwd: monorepoRoot, + stdio, + }); + + await bundleCli(stage); + await installSidecar(stage, target, stdio); + + await buildRemoteRuntime({ commit }); + await stageVscodeServer(stage, { commit }); + await pruneVscodeServerArch(stage, target); + + await stageNode(stage, target, cacheDir); + await stagePackageData(stage, stdio); + + await writeFile( + path.join(stage, "package.json"), + `${JSON.stringify( + { + name: "@dev.fast/whiteboard", + version, + type: "module", + bin: { whiteboard: "./dist/cli.js" }, + }, + null, + 2, + )}\n`, + ); + await writeFile( + path.join(stage, "whiteboard"), + [ + "#!/bin/sh", + 'f=$(readlink -f -- "$0" 2>/dev/null || printf \'%s\' "$0")', + 'd=$(CDPATH= cd -- "$(dirname -- "$f")" && pwd)', + 'exec "$d/bin/node" "$d/dist/cli.js" "$@"', + "", + ].join("\n"), + { mode: 0o755 }, + ); + + await rm(file, { force: true }); + packTar(file, stageRoot); + + const { size } = await stat(file); + + if (size > REMOTE_TARBALL_MAX_BYTES) { + await rm(file, { force: true }); + throw new Error( + `${name} is ${size} bytes, over the ${REMOTE_TARBALL_MAX_BYTES}-byte limit`, + ); + } + + const sha256 = await sha256File(file); + await writeFile(`${file}.sha256`, `${sha256} ${name}\n`); + + return { file, sha256, version, target }; + } finally { + await rm(stageRoot, { recursive: true, force: true }); + } +} + +const checkoutState = () => { + const hash = createHash("sha256"); + + for (const args of [ + ["rev-parse", "HEAD"], + ["status", "--porcelain=v1", "-z", "--untracked-files=all"], + ["diff", "HEAD", "--binary"], + ]) + hash.update( + execFileSync("git", ["-C", monorepoRoot, ...args], { + maxBuffer: 256 * 1024 * 1024, + }), + ); + + return hash.digest("hex").slice(0, 32); +}; + +/** This checkout's tarball, built once per checkout state, version and target. */ +export async function buildDevRemoteTarball({ + target, + version, + stdio = "inherit", +}) { + version ??= (await readJson(path.join(reviewPackage, "package.json"))) + .version; + + const dev = path.join(DEFAULT_CACHE, "dev"); + const state = checkoutState(); + const out = path.join(dev, state); + const file = path.join(out, remoteTarballName(version, target)); + + const sha256 = await readFile(`${file}.sha256`, "utf8").then( + (text) => text.split(" ")[0], + () => undefined, + ); + + if (sha256 && existsSync(file)) + return { file, sha256, version, target, state }; + + for (const entry of await readdir(dev).catch(() => [])) + if (entry !== state) await rm(path.join(dev, entry), { recursive: true }); + + const built = await buildRemoteTarball({ + target, + out, + version, + commit: execFileSync("git", ["-C", monorepoRoot, "rev-parse", "HEAD"], { + encoding: "utf8", + }).trim(), + stdio, + }); + + return { ...built, state }; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const { values } = parseArgs({ + options: { + target: { type: "string" }, + out: { type: "string" }, + version: { type: "string" }, + commit: { type: "string" }, + cache: { type: "string" }, + }, + }); + + const { version, commit } = values; + + if (!values.target || !values.out || !version || !commit) + throw new Error( + "usage: build-remote-tarball.mjs --target --out --version --commit [--cache ]", + ); + + const cache = process.env.REVIEW_REMOTE_BUILD_CACHE || values.cache; + + const result = await buildRemoteTarball({ + target: values.target, + out: values.out, + version, + commit, + cacheDir: cache ? path.resolve(cache) : undefined, + stdio: ["ignore", process.stderr, process.stderr], + }); + + console.log(JSON.stringify(result)); +} diff --git a/apps/review-desktop/scripts/build-remote-tarball.test.mjs b/apps/review-desktop/scripts/build-remote-tarball.test.mjs new file mode 100644 index 000000000..def1b1e6a --- /dev/null +++ b/apps/review-desktop/scripts/build-remote-tarball.test.mjs @@ -0,0 +1,245 @@ +import assert from "node:assert/strict"; +import { execFile, execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { + REMOTE_TARBALL_MAX_BYTES, + pinnedNode, + remoteTarballName, +} from "./build-remote-tarball.mjs"; + +const script = fileURLToPath( + new URL("./build-remote-tarball.mjs", import.meta.url), +); + +const roots = []; + +after(async () => { + await Promise.all( + roots.map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +async function tempDir() { + const root = await mkdtemp(path.join(os.tmpdir(), "wb-remote-tarball-")); + roots.push(root); + + return root; +} + +const NODE_VERSION = ( + await readFile(new URL("../code-oss/.nvmrc", import.meta.url), "utf8") +).trim(); + +const ARM64 = "b".repeat(64); + +function fakeShasums(lines) { + const fetchMock = async (url) => { + assert.equal(url, `https://dist.test/v${NODE_VERSION}/SHASUMS256.txt`); + + return new Response( + [ + `${"a".repeat(64)} node-v${NODE_VERSION}-linux-x64.tar.xz`, + `${"c".repeat(64)} node-v${NODE_VERSION}-linux-arm64.tar.gz`, + ...lines, + "", + ].join("\n"), + ); + }; + + return fetchMock; +} + +test("pins the target's Node tarball from SHASUMS256.txt", async (t) => { + t.mock.method( + globalThis, + "fetch", + fakeShasums([`${ARM64} node-v${NODE_VERSION}-linux-arm64.tar.xz`]), + ); + + assert.deepEqual( + await pinnedNode("linux-arm64", { + cacheDir: await tempDir(), + dist: "https://dist.test", + }), + { + url: `https://dist.test/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-arm64.tar.xz`, + sha256: ARM64, + version: NODE_VERSION, + }, + ); +}); + +test("refuses a SHASUMS256.txt without the target's tarball", async (t) => { + t.mock.method(globalThis, "fetch", fakeShasums([])); + + await assert.rejects( + pinnedNode("linux-arm64", { + cacheDir: await tempDir(), + dist: "https://dist.test", + }), + new RegExp( + `no line for node-v${NODE_VERSION.replaceAll(".", "\\.")}-linux-arm64\\.tar\\.xz`, + ), + ); +}); + +const hasDocker = (() => { + try { + execFileSync("docker", ["version"], { stdio: "ignore" }); + + return true; + } catch { + return false; + } +})(); + +test("builds a self-contained tarball for this machine's Linux arch", async () => { + const target = process.arch === "arm64" ? "linux-arm64" : "linux-x64"; + const other = target === "linux-arm64" ? "linux-x64" : "linux-arm64"; + const out = await tempDir(); + + const { stdout } = await promisify(execFile)( + process.execPath, + [ + script, + "--version", + "0.0.0-test", + "--commit", + execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(), + "--target", + target, + "--out", + out, + ], + { + env: { ...process.env, REVIEW_REMOTE_BUILD_CACHE: await tempDir() }, + maxBuffer: 64 * 1024 * 1024, + }, + ); + + const result = JSON.parse(stdout); + assert.equal(result.target, target); + assert.equal( + path.basename(result.file), + remoteTarballName(result.version, target), + ); + + const tarball = await readFile(result.file); + assert.ok( + tarball.length < REMOTE_TARBALL_MAX_BYTES, + `${tarball.length} bytes`, + ); + assert.equal( + createHash("sha256").update(tarball).digest("hex"), + result.sha256, + ); + assert.equal( + await readFile(`${result.file}.sha256`, "utf8"), + `${result.sha256} ${path.basename(result.file)}\n`, + ); + + const entries = execFileSync("tar", ["-tJf", result.file], { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }) + .split("\n") + .filter(Boolean) + .map((entry) => entry.replace(/^\.\//, "")); + + assert.deepEqual( + new Set(entries.map((entry) => entry.split("/")[0])), + new Set(["whiteboard-remote"]), + ); + + const listed = new Set(entries.map((entry) => entry.replace(/\/$/, ""))); + + for (const file of [ + "whiteboard", + "bin/node", + "dist/cli.js", + "package.json", + "vscode-server/out/server-main.js", + `node_modules/@dev.fast/diffr-${target}/diffr`, + `node_modules/@parcel/watcher-${target}-glibc/watcher.node`, + "node_modules/@img/sharp-wasm32", + "node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js", + "node_modules/@agentclientprotocol/codex-acp/dist/index.js", + "node_modules/pi-acp/dist/index.js", + "bin/NODE-LICENSE", + "LICENSE", + "THIRD_PARTY_NOTICES.md", + "onboarding.md", + "instructions/authoring.md", + "tutorial/document.json", + "tutorial/pins.json", + "tutorial/git-stub", + "docs/remote-hosts.md", + ]) + assert.ok(listed.has(`whiteboard-remote/${file}`), file); + + assert.ok( + !entries.some( + (entry) => + entry.includes(`/bin/${other}/`) || + entry.includes(`watcher-${other}-glibc`), + ), + `the tarball holds ${other} binaries`, + ); + assert.ok( + !entries.some((entry) => /\/@img\/sharp-(linux|libvips)-/.test(entry)), + "the tarball holds native sharp", + ); + assert.ok( + !entries.some((entry) => entry.includes("/tutorial/.bundle")), + "the tarball holds tutorial/.bundle", + ); + + if (!hasDocker) return; + + const extracted = await tempDir(); + execFileSync("tar", ["-xJf", result.file, "-C", extracted]); + + const inDebian = (workdir, ...command) => + execFileSync( + "docker", + [ + "run", + "--rm", + "--platform", + target === "linux-x64" ? "linux/amd64" : "linux/arm64", + "-v", + `${extracted}:/t:ro`, + "-w", + workdir, + "debian:12", + ...command, + ], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + + assert.equal( + JSON.parse( + inDebian( + "/", + "sh", + "-c", + "ln -s /t/whiteboard-remote/whiteboard /usr/local/bin/whiteboard && whiteboard version --json", + ), + ).version, + result.version, + ); + inDebian( + "/t/whiteboard-remote", + "bin/node", + "--input-type=module", + "-e", + "await import('sharp'); await import('@parcel/watcher')", + ); +}); diff --git a/apps/review-desktop/scripts/stage-vscode-server.mjs b/apps/review-desktop/scripts/stage-vscode-server.mjs index 42c1c1df0..95af05ead 100644 --- a/apps/review-desktop/scripts/stage-vscode-server.mjs +++ b/apps/review-desktop/scripts/stage-vscode-server.mjs @@ -191,3 +191,15 @@ export async function stageVscodeServer( return destination; } + +export async function pruneVscodeServerArch(packageRoot, target) { + const server = path.join(packageRoot, "vscode-server"); + + for (const other of remoteTargets.filter((t) => t !== target)) { + for (const dir of [ + `out/vs/workbench/api/bin/${other}`, + `node_modules/@parcel/watcher-${other}-glibc`, + ]) + fs.rmSync(path.join(server, dir), { recursive: true, force: true }); + } +} diff --git a/apps/review-desktop/scripts/stage-vscode-server.test.mjs b/apps/review-desktop/scripts/stage-vscode-server.test.mjs new file mode 100644 index 000000000..69debb638 --- /dev/null +++ b/apps/review-desktop/scripts/stage-vscode-server.test.mjs @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { REMOTE_BUILTIN_EXTENSIONS } from "./build-remote-runtime.mjs"; +import { remoteExtensionIds } from "./curated-extensions.manifest.mjs"; +import { stageVscodeServer } from "./stage-vscode-server.mjs"; + +const commit = "a".repeat(40); + +async function write(file, content, mode) { + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, content, { mode }); +} + +async function fixture(t, { runtimeCommit = commit } = {}) { + const root = await mkdtemp(path.join(os.tmpdir(), "whiteboard-stage-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + + const runtime = path.join(root, "runtime"); + await write( + path.join(runtime, "product.json"), + JSON.stringify({ commit: runtimeCommit }), + ); + await write(path.join(runtime, "out/server-main.js"), ""); + await write( + path.join(runtime, "extensions/node_modules/typescript/lib/tsserver.js"), + "", + ); + + for (const name of REMOTE_BUILTIN_EXTENSIONS) + await write(path.join(runtime, "extensions", name, "package.json"), "{}"); + + const tarballs = path.join(root, "tarballs"); + const downloads = []; + + const server = createServer((request, response) => + readFile(path.join(tarballs, path.basename(request.url))).then( + (data) => response.end(data), + () => response.writeHead(404).end(), + ), + ); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + + for (const [name, files] of [ + ["rg", { "bin/linux-x64/rg": "x64", "bin/linux-arm64/rg": "arm64" }], + ["watcher", { "package.json": "{}" }], + ]) { + for (const [file, content] of Object.entries(files)) + await write(path.join(tarballs, name, "package", file), content, 0o755); + + const tarball = path.join(tarballs, `${name}.tgz`); + execFileSync("tar", ["-czf", tarball, "-C", path.join(tarballs, name), "package"]); + downloads.push({ + url: `http://127.0.0.1:${server.address().port}/${name}.tgz`, + sha256: createHash("sha256") + .update(await readFile(tarball)) + .digest("hex"), + copy: + name === "rg" + ? { + "bin/linux-x64/rg": "out/vs/workbench/api/bin/linux-x64/rg", + "bin/linux-arm64/rg": "out/vs/workbench/api/bin/linux-arm64/rg", + } + : { ".": "node_modules/@parcel/watcher" }, + }); + } + + const cacheDir = path.join(root, "cache"); + await mkdir(cacheDir); + + for (const { url, sha256 } of downloads) + await writeFile( + path.join(cacheDir, `${sha256}-${path.basename(url)}`), + await readFile(path.join(tarballs, path.basename(url))), + ); + + return { + output: path.join(root, "output"), + downloads, + cacheDir, + options: { commit, runtime, cacheDir, downloads }, + }; +} + +test("stages the VS Code server with ripgrep, the watcher and the curated extensions", async (t) => { + const { output, options } = await fixture(t); + + const server = await stageVscodeServer(output, options); + + for (const file of [ + "out/server-main.js", + "extensions/node_modules/typescript/lib/tsserver.js", + ...REMOTE_BUILTIN_EXTENSIONS.map((name) => `extensions/${name}/package.json`), + "node_modules/@parcel/watcher/package.json", + ]) + await stat(path.join(server, file)); + + for (const arch of ["x64", "arm64"]) { + const rg = await stat( + path.join(server, `out/vs/workbench/api/bin/linux-${arch}/rg`), + ); + + assert.ok(rg.mode & 0o111, `rg for ${arch} is executable`); + } + + const curated = JSON.parse( + await readFile(path.join(server, "curated.json"), "utf8"), + ); + + assert.deepEqual( + curated.extensions.map((extension) => extension.id), + remoteExtensionIds, + ); + + const go = curated.extensions.find((extension) => extension.id === "golang.go"); + assert.equal(go.tier, "optional"); + assert.equal(go.group, "go"); + + for (const extension of curated.extensions) + for (const target of ["linux-x64", "linux-arm64"]) { + const { url, sha256, size } = extension.targets[target]; + + assert.match(url, /^https:\/\/open-vsx\.org\//); + assert.match(sha256, /^[0-9a-f]{64}$/); + assert.ok(size > 0); + } +}); + +test("refuses a runtime built from another commit", async (t) => { + const { output, options } = await fixture(t, { + runtimeCommit: "0".repeat(40), + }); + + await assert.rejects( + stageVscodeServer(output, options), + /the remote runtime was built from 0{40}/, + ); +}); + +test("deletes a download that fails its checksum", async (t) => { + const { output, options, cacheDir, downloads } = await fixture(t); + + const [rg] = downloads; + rg.sha256 = "0".repeat(64); + const cachedFile = path.join(cacheDir, `${rg.sha256}-${path.basename(rg.url)}`); + + await assert.rejects(stageVscodeServer(output, options), /Checksum mismatch/); + await assert.rejects(stat(cachedFile)); +}); From 6db42aa8dc3fc71dffde675e505b58eada8a974e Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Wed, 14 Oct 2026 13:13:58 -0400 Subject: [PATCH 03/14] Pin the remote tarballs and publish them from CI Release and preview builds stamp each target's URL and sha256 into product.json. The npm package no longer carries the VS Code server. --- .github/workflows/review-desktop-preview.yml | 62 +++++- .github/workflows/review-desktop-release.yml | 71 ++++++- .github/workflows/review-linux-build.yml | 9 + .github/workflows/review-macos-build.yml | 9 + .github/workflows/review-windows-build.yml | 8 + .../scripts/stamp-remote-artifacts.mjs | 109 +++++++++++ .../scripts/stamp-remote-artifacts.test.mjs | 106 +++++++++++ packages/review/package.json | 3 +- scripts/pack-review-cli.mjs | 3 - scripts/pack-review-cli.test.mjs | 176 ++---------------- 10 files changed, 370 insertions(+), 186 deletions(-) create mode 100644 apps/review-desktop/scripts/stamp-remote-artifacts.mjs create mode 100644 apps/review-desktop/scripts/stamp-remote-artifacts.test.mjs diff --git a/.github/workflows/review-desktop-preview.yml b/.github/workflows/review-desktop-preview.yml index 0f4d55ace..77314dfc4 100644 --- a/.github/workflows/review-desktop-preview.yml +++ b/.github/workflows/review-desktop-preview.yml @@ -101,14 +101,16 @@ jobs: exit 1 fi - npm: - name: Publish npm package + remote: + name: Publish remote tarballs and npm package needs: version runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 60 permissions: contents: read id-token: write + outputs: + whiteboard_remote: ${{ steps.remote.outputs.whiteboard_remote }} env: VERSION: ${{ needs.version.outputs.version }} COMMIT: ${{ needs.version.outputs.commit }} @@ -175,11 +177,52 @@ jobs: key: ${{ runner.os }}-code-oss-deps-v3-${{ steps.code-oss-inputs.outputs.digest }} restore-keys: | ${{ runner.os }}-code-oss-deps-v3- - - name: Build the VS Code server + - name: Check for published remote tarballs + if: ${{ !inputs.dry_run }} + id: existing + run: | + mkdir -p remote-artifacts + FOUND=0 + for t in linux-x64 linux-arm64; do + NAME="whiteboard-remote-${VERSION}-${t}.tar.xz.sha256" + STATUS=$(curl -sS --retry 3 --retry-all-errors -o "remote-artifacts/$NAME" -w '%{http_code}' "https://install.dev.fast/releases/${VERSION}/remote/$NAME") + case "$STATUS" in + 200) FOUND=$((FOUND + 1)) ;; + 404) rm -f "remote-artifacts/$NAME" ;; + *) echo "::error::${NAME} answered ${STATUS}" >&2; exit 1 ;; + esac + done + if [ "$FOUND" = 1 ]; then + echo "::error::Only one remote tarball for ${VERSION} is published; fix releases/${VERSION}/remote/ on R2 by hand." >&2 + exit 1 + fi + echo "published=$([ "$FOUND" = 2 ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + - name: Build the remote tarballs + if: ${{ steps.existing.outputs.published != 'true' }} run: | source apps/review-desktop/scripts/code-oss-dependencies.sh ensure_code_oss_dependencies "$PWD/apps/review-desktop" "$PWD/apps/review-desktop/code-oss" - BUILD_SOURCEVERSION="$COMMIT" node apps/review-desktop/scripts/build-remote-runtime.mjs + for t in linux-x64 linux-arm64; do + node apps/review-desktop/scripts/build-remote-tarball.mjs --target "$t" --out remote-artifacts --version "$VERSION" --commit "$COMMIT" + done + # Payloads before the pin: no Desktop build may pin a tarball R2 lacks. + - name: Upload the remote tarballs to R2 + if: ${{ !inputs.dry_run && steps.existing.outputs.published != 'true' }} + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_ENDPOINT_URL: ${{ vars.R2_ENDPOINT_URL }} + AWS_REGION: auto + R2_BUCKET: ${{ vars.R2_RELEASE_BUCKET }} + run: | + PREFIX="s3://${R2_BUCKET}/releases/${VERSION}/remote/" + aws s3 cp remote-artifacts/ "$PREFIX" --recursive --exclude "*" --include "*.tar.xz" --content-type application/x-xz + aws s3 cp remote-artifacts/ "$PREFIX" --recursive --exclude "*" --include "*.sha256" --content-type text/plain + - name: Pin the remote tarballs + id: remote + run: | + PIN=$(node apps/review-desktop/scripts/stamp-remote-artifacts.mjs remote-artifacts "$VERSION" "https://install.dev.fast/releases/${VERSION}/remote") + echo "whiteboard_remote=$PIN" >> "$GITHUB_OUTPUT" - name: Build release tarball run: | jq -n --arg version "$VERSION" --arg commit "$COMMIT" '{$version, $commit}' > release-plan.json @@ -210,17 +253,18 @@ jobs: # build signs with the production repository key rather than a CI key. linux: if: ${{ inputs.platforms == 'all' || inputs.platforms == 'linux' }} - needs: [version, npm] + needs: [version, remote] uses: ./.github/workflows/review-linux-build.yml with: commit: ${{ needs.version.outputs.commit }} version: ${{ needs.version.outputs.version }} + whiteboard_remote: ${{ needs.remote.outputs.whiteboard_remote }} release_signing: ${{ !inputs.dry_run }} secrets: inherit windows: if: ${{ inputs.platforms == 'all' || inputs.platforms == 'windows' }} - needs: [version, npm] + needs: [version, remote] permissions: contents: read id-token: write @@ -228,11 +272,12 @@ jobs: with: commit: ${{ needs.version.outputs.commit }} version: ${{ needs.version.outputs.version }} + whiteboard_remote: ${{ needs.remote.outputs.whiteboard_remote }} sign: true macos: if: ${{ inputs.platforms == 'all' || inputs.platforms == 'macos' }} - needs: [version, npm] + needs: [version, remote] permissions: contents: read actions: read @@ -240,6 +285,7 @@ jobs: with: commit: ${{ needs.version.outputs.commit }} version: ${{ needs.version.outputs.version }} + whiteboard_remote: ${{ needs.remote.outputs.whiteboard_remote }} channel: preview secrets: inherit diff --git a/.github/workflows/review-desktop-release.yml b/.github/workflows/review-desktop-release.yml index dd332e0c2..11f1b681b 100644 --- a/.github/workflows/review-desktop-release.yml +++ b/.github/workflows/review-desktop-release.yml @@ -274,14 +274,16 @@ jobs: --draft \ --generate-notes - npm: - name: Publish npm package + remote: + name: Publish remote tarballs and npm package needs: version runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 60 permissions: - contents: read + contents: write id-token: write + outputs: + whiteboard_remote: ${{ steps.remote.outputs.whiteboard_remote }} env: VERSION: ${{ needs.version.outputs.version }} COMMIT: ${{ needs.version.outputs.commit }} @@ -348,11 +350,59 @@ jobs: key: ${{ runner.os }}-code-oss-deps-v3-${{ steps.code-oss-inputs.outputs.digest }} restore-keys: | ${{ runner.os }}-code-oss-deps-v3- - - name: Build the VS Code server + - name: Check for published remote tarballs + if: ${{ !inputs.dry_run }} + id: existing + run: | + mkdir -p remote-artifacts + FOUND=0 + for t in linux-x64 linux-arm64; do + NAME="whiteboard-remote-${VERSION}-${t}.tar.xz.sha256" + STATUS=$(curl -sS --retry 3 --retry-all-errors -o "remote-artifacts/$NAME" -w '%{http_code}' "https://install.dev.fast/releases/${VERSION}/remote/$NAME") + case "$STATUS" in + 200) FOUND=$((FOUND + 1)) ;; + 404) rm -f "remote-artifacts/$NAME" ;; + *) echo "::error::${NAME} answered ${STATUS}" >&2; exit 1 ;; + esac + done + if [ "$FOUND" = 1 ]; then + echo "::error::Only one remote tarball for ${VERSION} is published; fix releases/${VERSION}/remote/ on R2 by hand." >&2 + exit 1 + fi + echo "published=$([ "$FOUND" = 2 ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + - name: Build the remote tarballs + if: ${{ steps.existing.outputs.published != 'true' }} run: | source apps/review-desktop/scripts/code-oss-dependencies.sh ensure_code_oss_dependencies "$PWD/apps/review-desktop" "$PWD/apps/review-desktop/code-oss" - BUILD_SOURCEVERSION="$COMMIT" node apps/review-desktop/scripts/build-remote-runtime.mjs + for t in linux-x64 linux-arm64; do + node apps/review-desktop/scripts/build-remote-tarball.mjs --target "$t" --out remote-artifacts --version "$VERSION" --commit "$COMMIT" + done + # Release assets, then R2, then the pin: a rerun that finds the tarballs on + # R2 skips both uploads, and no Desktop build may pin a tarball R2 lacks. + - name: Attach the remote tarballs to the GitHub Release + if: ${{ !inputs.dry_run && steps.existing.outputs.published != 'true' }} + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.version.outputs.tag }} + run: gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber remote-artifacts/*.tar.xz remote-artifacts/*.sha256 + - name: Upload the remote tarballs to R2 + if: ${{ !inputs.dry_run && steps.existing.outputs.published != 'true' }} + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_ENDPOINT_URL: ${{ vars.R2_ENDPOINT_URL }} + AWS_REGION: auto + R2_BUCKET: ${{ vars.R2_RELEASE_BUCKET }} + run: | + PREFIX="s3://${R2_BUCKET}/releases/${VERSION}/remote/" + aws s3 cp remote-artifacts/ "$PREFIX" --recursive --exclude "*" --include "*.tar.xz" --content-type application/x-xz + aws s3 cp remote-artifacts/ "$PREFIX" --recursive --exclude "*" --include "*.sha256" --content-type text/plain + - name: Pin the remote tarballs + id: remote + run: | + PIN=$(node apps/review-desktop/scripts/stamp-remote-artifacts.mjs remote-artifacts "$VERSION" "https://install.dev.fast/releases/${VERSION}/remote") + echo "whiteboard_remote=$PIN" >> "$GITHUB_OUTPUT" - name: Build release tarball run: | jq -n --arg version "$VERSION" --arg commit "$COMMIT" '{$version, $commit}' > release-plan.json @@ -381,18 +431,19 @@ jobs: linux: if: ${{ inputs.platforms == 'all' || inputs.platforms == 'linux' }} - needs: [version, npm] + needs: [version, remote] uses: ./.github/workflows/review-linux-build.yml with: commit: ${{ needs.version.outputs.commit }} version: ${{ needs.version.outputs.version }} + whiteboard_remote: ${{ needs.remote.outputs.whiteboard_remote }} release_signing: ${{ !inputs.dry_run }} secrets: inherit # Signs even on a dry run, so the run validates exactly what would ship. windows: if: ${{ inputs.platforms == 'all' || inputs.platforms == 'windows' }} - needs: [version, npm] + needs: [version, remote] permissions: contents: read id-token: write @@ -400,11 +451,12 @@ jobs: with: commit: ${{ needs.version.outputs.commit }} version: ${{ needs.version.outputs.version }} + whiteboard_remote: ${{ needs.remote.outputs.whiteboard_remote }} sign: true macos: if: ${{ inputs.platforms == 'all' || inputs.platforms == 'macos' }} - needs: [version, npm] + needs: [version, remote] permissions: contents: read actions: read @@ -412,6 +464,7 @@ jobs: with: commit: ${{ needs.version.outputs.commit }} version: ${{ needs.version.outputs.version }} + whiteboard_remote: ${{ needs.remote.outputs.whiteboard_remote }} channel: stable secrets: inherit diff --git a/.github/workflows/review-linux-build.yml b/.github/workflows/review-linux-build.yml index 4d7864ebe..e6627b341 100644 --- a/.github/workflows/review-linux-build.yml +++ b/.github/workflows/review-linux-build.yml @@ -9,6 +9,10 @@ on: version: type: string default: "" + whiteboard_remote: + description: The remote job's pin (stamp-remote-artifacts.mjs); required with a version. + type: string + default: "" package_revision: type: string default: "1" @@ -48,6 +52,11 @@ jobs: QUALITY=stable if [[ "$VERSION" == *-preview.* ]]; then QUALITY=preview; fi node apps/review-desktop/scripts/stamp-release-channel.mjs --version "$VERSION" --quality "$QUALITY" + - name: Pin the remote tarballs + if: inputs.version != '' + env: + WHITEBOARD_REMOTE: ${{ inputs.whiteboard_remote }} + run: node apps/review-desktop/scripts/stamp-remote-artifacts.mjs --pinned "$WHITEBOARD_REMOTE" - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: diff --git a/.github/workflows/review-macos-build.yml b/.github/workflows/review-macos-build.yml index 19ce214a2..05e594ab0 100644 --- a/.github/workflows/review-macos-build.yml +++ b/.github/workflows/review-macos-build.yml @@ -12,6 +12,10 @@ on: channel: required: true type: string + whiteboard_remote: + description: The remote job's pin (stamp-remote-artifacts.mjs). + required: true + type: string permissions: contents: read @@ -155,6 +159,11 @@ jobs: --version "$RELEASE_VERSION" \ --quality "${{ inputs.channel }}" + - name: Pin the remote tarballs + env: + WHITEBOARD_REMOTE: ${{ inputs.whiteboard_remote }} + run: node apps/review-desktop/scripts/stamp-remote-artifacts.mjs --pinned "$WHITEBOARD_REMOTE" + - name: Setup pnpm uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 diff --git a/.github/workflows/review-windows-build.yml b/.github/workflows/review-windows-build.yml index 8644e59b7..ad6fdf84d 100644 --- a/.github/workflows/review-windows-build.yml +++ b/.github/workflows/review-windows-build.yml @@ -9,6 +9,10 @@ on: version: type: string required: true + whiteboard_remote: + description: The remote job's pin (stamp-remote-artifacts.mjs); required with a version. + type: string + default: "" artifact_name: type: string default: windows-packages @@ -65,6 +69,10 @@ jobs: QUALITY=stable if [[ "$VERSION" == *-preview.* ]]; then QUALITY=preview; fi node apps/review-desktop/scripts/stamp-release-channel.mjs --version "$VERSION" --quality "$QUALITY" + - name: Pin the remote tarballs + env: + WHITEBOARD_REMOTE: ${{ inputs.whiteboard_remote }} + run: node apps/review-desktop/scripts/stamp-remote-artifacts.mjs --pinned "$WHITEBOARD_REMOTE" - name: Restore native dependencies id: native-cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 diff --git a/apps/review-desktop/scripts/stamp-remote-artifacts.mjs b/apps/review-desktop/scripts/stamp-remote-artifacts.mjs new file mode 100644 index 000000000..758ff57bf --- /dev/null +++ b/apps/review-desktop/scripts/stamp-remote-artifacts.mjs @@ -0,0 +1,109 @@ +import { readFile } from "node:fs/promises"; +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +const PRODUCT = path.join(import.meta.dirname, "..", "code-oss", "product.json"); + +const TARGETS = ["linux-x64", "linux-arm64"]; + +const tarballName = (version, target) => `whiteboard-remote-${version}-${target}.tar.xz`; + +export async function remoteArtifacts(directory, version) { + const files = {}; + + for (const target of TARGETS) { + const file = tarballName(version, target); + let line; + + try { + line = await readFile(path.join(directory, `${file}.sha256`), "utf8"); + } catch { + throw new Error(`${directory} has no ${file}.sha256 for ${target}`); + } + + const [sha256, name] = line.trim().split(/\s+/); + + if (name !== file) + throw new Error(`${file}.sha256 names ${name}, not the ${target} tarball`); + files[target] = { file, sha256 }; + } + + return files; +} + +export function remotePin({ version, baseUrl, files }) { + const pin = { version }; + + for (const target of TARGETS) + pin[target] = { url: `${baseUrl}/${files[target].file}`, sha256: files[target].sha256 }; + + checkPin(pin); + + return pin; +} + +function checkPin(pin) { + const version = pin?.version ?? ""; + + if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) + throw new Error("the remote pin's version is malformed"); + + for (const target of TARGETS) { + const { url = "", sha256 = "" } = pin[target] ?? {}; + + if (!url.startsWith("https://") || !url.endsWith(`/${tarballName(version, target)}`)) + throw new Error(`the remote pin's ${target} URL is malformed`); + + if (!/^[0-9a-f]{64}$/.test(sha256)) + throw new Error(`the remote pin's ${target} checksum is malformed`); + } +} + +export function writePin(pin, productPath = PRODUCT) { + checkPin(pin); + const product = JSON.parse(readFileSync(productPath, "utf8")); + product.whiteboardRemote = pin; + writeFileSync(productPath, `${JSON.stringify(product, null, "\t")}\n`); +} + +async function main() { + const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + product: { type: "string", default: PRODUCT }, + pinned: { type: "string" }, + }, + }); + + if (values.pinned !== undefined) { + let pin; + + try { + pin = JSON.parse(values.pinned); + } catch { + throw new Error("the remote pin is not JSON"); + } + + writePin(pin, values.product); + + return; + } + + if (positionals.length !== 3) { + console.error( + "usage: stamp-remote-artifacts.mjs \n" + + " stamp-remote-artifacts.mjs --pinned [--product ]", + ); + process.exit(2); + } + + const [directory, version, baseUrl] = positionals; + const files = await remoteArtifacts(directory, version); + console.log(JSON.stringify(remotePin({ version, baseUrl, files }))); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + await main(); +} diff --git a/apps/review-desktop/scripts/stamp-remote-artifacts.test.mjs b/apps/review-desktop/scripts/stamp-remote-artifacts.test.mjs new file mode 100644 index 000000000..db0dc1479 --- /dev/null +++ b/apps/review-desktop/scripts/stamp-remote-artifacts.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; + +import { REMOTE_TARGETS, remoteTarballName } from "./build-remote-tarball.mjs"; +import { remoteArtifacts, remotePin, writePin } from "./stamp-remote-artifacts.mjs"; + +const SCRIPT = path.join(import.meta.dirname, "stamp-remote-artifacts.mjs"); + +const VERSION = "1.2.4-preview.20261013.7"; + +const BASE = `https://install.dev.fast/releases/${VERSION}/remote`; + +const roots = []; + +after(() => + Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))), +); + +async function fixture(targets = REMOTE_TARGETS) { + const root = await mkdtemp(path.join(os.tmpdir(), "wb-stamp-remote-")); + roots.push(root); + const sums = {}; + + for (const target of targets) { + const name = remoteTarballName(VERSION, target); + const content = `tarball for ${target}`; + sums[target] = createHash("sha256").update(content).digest("hex"); + await writeFile(path.join(root, name), content); + await writeFile(path.join(root, `${name}.sha256`), `${sums[target]} ${name}\n`); + } + + const productPath = path.join(root, "product.json"); + await writeFile( + productPath, + `${JSON.stringify({ nameShort: "Whiteboard" }, null, "\t")}\n`, + ); + + return { root, sums, productPath }; +} + +test("pins each target's tarball under the base URL with its .sha256", async () => { + const { root, sums } = await fixture(); + + const pin = execFileSync("node", [SCRIPT, root, VERSION, BASE], { + encoding: "utf8", + }); + + assert.deepEqual(JSON.parse(pin), { + version: VERSION, + "linux-x64": { + url: `${BASE}/whiteboard-remote-${VERSION}-linux-x64.tar.xz`, + sha256: sums["linux-x64"], + }, + "linux-arm64": { + url: `${BASE}/whiteboard-remote-${VERSION}-linux-arm64.tar.xz`, + sha256: sums["linux-arm64"], + }, + }); +}); + +test("refuses a directory missing a target", async () => { + const { root } = await fixture(["linux-x64"]); + + await assert.rejects(remoteArtifacts(root, VERSION), /linux-arm64/); +}); + +test("pins from the .sha256 files alone, as a rerun downloads them", async () => { + const { root, sums } = await fixture(); + + for (const target of REMOTE_TARGETS) + await rm(path.join(root, remoteTarballName(VERSION, target))); + + assert.equal((await remoteArtifacts(root, VERSION))["linux-x64"].sha256, sums["linux-x64"]); +}); + +test("refuses a .sha256 that names another tarball", async () => { + const { root } = await fixture(); + const name = remoteTarballName(VERSION, "linux-x64"); + await writeFile(path.join(root, `${name}.sha256`), `${"0".repeat(64)} other.tar.xz\n`); + + await assert.rejects(remoteArtifacts(root, VERSION), /linux-x64/); +}); + +test("--pinned writes the pin into product.json and refuses a malformed one", async () => { + const { root, productPath } = await fixture(); + + const pin = remotePin({ + version: VERSION, + baseUrl: BASE, + files: await remoteArtifacts(root, VERSION), + }); + + execFileSync("node", [SCRIPT, "--pinned", JSON.stringify(pin), "--product", productPath]); + const product = JSON.parse(await readFile(productPath, "utf8")); + assert.deepEqual(product.whiteboardRemote, pin); + assert.equal(product.nameShort, "Whiteboard"); + + const broken = { ...pin, "linux-arm64": { ...pin["linux-arm64"], sha256: "abc" } }; + assert.throws(() => writePin(broken, productPath), /linux-arm64/); + assert.throws(() => writePin(undefined, productPath), /version/); +}); diff --git a/packages/review/package.json b/packages/review/package.json index 0175fc30e..89aec1d91 100644 --- a/packages/review/package.json +++ b/packages/review/package.json @@ -23,8 +23,7 @@ "src", "!src/**/*.test.ts", "!src/**/*test-utils*", - "THIRD_PARTY_NOTICES.md", - "vscode-server" + "THIRD_PARTY_NOTICES.md" ], "type": "module", "exports": { diff --git a/scripts/pack-review-cli.mjs b/scripts/pack-review-cli.mjs index 87a2f6175..94e8de768 100644 --- a/scripts/pack-review-cli.mjs +++ b/scripts/pack-review-cli.mjs @@ -12,7 +12,6 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { stageReviewDocs } from "../apps/review-desktop/scripts/stage-review-runtime.mjs"; -import { stageVscodeServer } from "../apps/review-desktop/scripts/stage-vscode-server.mjs"; import { distTag } from "./review-cli-release.mjs"; export const MAX_TARBALL_BYTES = 60 * 1024 * 1024; @@ -23,7 +22,6 @@ export async function packReviewCli( { packageDirectory = "packages/review", stdio = "inherit", - vscodeServer = {}, maxBytes = MAX_TARBALL_BYTES, } = {}, ) { @@ -60,7 +58,6 @@ export async function packReviewCli( ]); const staged = path.join(scratch, "package"); await stageReviewDocs(staged); - await stageVscodeServer(staged, { ...vscodeServer, commit }); execFileSync( "npm", diff --git a/scripts/pack-review-cli.test.mjs b/scripts/pack-review-cli.test.mjs index 056d1000b..3fc6f002d 100644 --- a/scripts/pack-review-cli.test.mjs +++ b/scripts/pack-review-cli.test.mjs @@ -1,14 +1,10 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { createServer } from "node:http"; import os from "node:os"; import path from "node:path"; import { test } from "node:test"; -import { REMOTE_BUILTIN_EXTENSIONS } from "../apps/review-desktop/scripts/build-remote-runtime.mjs"; -import { remoteExtensionIds } from "../apps/review-desktop/scripts/curated-extensions.manifest.mjs"; import { packReviewCli } from "./pack-review-cli.mjs"; const commit = execFileSync("git", ["rev-parse", "HEAD"], { @@ -17,12 +13,7 @@ const commit = execFileSync("git", ["rev-parse", "HEAD"], { const version = "1.2.4-preview.20260901.42"; -async function write(file, content, mode) { - await mkdir(path.dirname(file), { recursive: true }); - await writeFile(file, content, { mode }); -} - -async function fixture(t, { runtimeCommit = commit } = {}) { +async function fixture(t) { const root = await mkdtemp(path.join(os.tmpdir(), "whiteboard-pack-test-")); t.after(() => rm(root, { recursive: true, force: true })); @@ -31,89 +22,23 @@ async function fixture(t, { runtimeCommit = commit } = {}) { ); const packageDirectory = path.join(root, "package-source"); - await write( + await mkdir(path.join(packageDirectory, "dist"), { recursive: true }); + await writeFile( path.join(packageDirectory, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`, ); - await write(path.join(packageDirectory, "dist/cli.js"), ""); - - const runtime = path.join(root, "runtime"); - await write( - path.join(runtime, "product.json"), - JSON.stringify({ commit: runtimeCommit }), - ); - await write(path.join(runtime, "out/server-main.js"), ""); - await write( - path.join(runtime, "extensions/node_modules/typescript/lib/tsserver.js"), - "", - ); - - for (const name of REMOTE_BUILTIN_EXTENSIONS) - await write(path.join(runtime, "extensions", name, "package.json"), "{}"); - - const tarballs = path.join(root, "tarballs"); - const downloads = []; - - const server = createServer((request, response) => - readFile(path.join(tarballs, path.basename(request.url))).then( - (data) => response.end(data), - () => response.writeHead(404).end(), - ), - ); - - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - t.after(() => new Promise((resolve) => server.close(resolve))); - - for (const [name, files] of [ - ["rg", { "bin/linux-x64/rg": "x64", "bin/linux-arm64/rg": "arm64" }], - ["watcher", { "package.json": "{}" }], - ]) { - for (const [file, content] of Object.entries(files)) - await write(path.join(tarballs, name, "package", file), content, 0o755); - - const tarball = path.join(tarballs, `${name}.tgz`); - execFileSync("tar", ["-czf", tarball, "-C", path.join(tarballs, name), "package"]); - downloads.push({ - url: `http://127.0.0.1:${server.address().port}/${name}.tgz`, - sha256: createHash("sha256") - .update(await readFile(tarball)) - .digest("hex"), - copy: - name === "rg" - ? { - "bin/linux-x64/rg": "out/vs/workbench/api/bin/linux-x64/rg", - "bin/linux-arm64/rg": "out/vs/workbench/api/bin/linux-arm64/rg", - } - : { ".": "node_modules/@parcel/watcher" }, - }); - } + await writeFile(path.join(packageDirectory, "dist/cli.js"), ""); - const cacheDir = path.join(root, "cache"); - await mkdir(cacheDir); - - for (const { url, sha256 } of downloads) - await writeFile( - path.join(cacheDir, `${sha256}-${path.basename(url)}`), - await readFile(path.join(tarballs, path.basename(url))), - ); - - return { - root, - packageDirectory, - downloads, - cacheDir, - vscodeServer: { runtime, cacheDir, downloads }, - }; + return { root, packageDirectory }; } -test("packs the Desktop version under the whiteboard name with both bins and the VS Code server", async (t) => { - const { root, packageDirectory, vscodeServer } = await fixture(t); +test("packs the Desktop version under the whiteboard name with both bins", async (t) => { + const { root, packageDirectory } = await fixture(t); const output = path.join(root, "output"); const tarball = await packReviewCli({ version, commit }, output, { packageDirectory, stdio: "pipe", - vscodeServer, }); assert.equal(path.basename(tarball), `dev.fast-whiteboard-${version}.tgz`); @@ -131,98 +56,21 @@ test("packs the Desktop version under the whiteboard name with both bins and the review: "./dist/cli.js", whiteboard: "./dist/cli.js", }); - - const server = path.join(pkgRoot, "vscode-server"); - - for (const file of [ - "out/server-main.js", - "extensions/node_modules/typescript/lib/tsserver.js", - ...REMOTE_BUILTIN_EXTENSIONS.map((name) => `extensions/${name}/package.json`), - "node_modules/@parcel/watcher/package.json", - ]) - await stat(path.join(server, file)); - - for (const arch of ["x64", "arm64"]) { - const rg = await stat( - path.join(server, `out/vs/workbench/api/bin/linux-${arch}/rg`), - ); - - assert.ok(rg.mode & 0o111, `rg for ${arch} is executable`); - } - - const curated = JSON.parse( - await readFile(path.join(server, "curated.json"), "utf8"), - ); - - assert.deepEqual( - curated.extensions.map((extension) => extension.id), - remoteExtensionIds, - ); - - const go = curated.extensions.find((extension) => extension.id === "golang.go"); - assert.equal(go.tier, "optional"); - assert.equal(go.group, "go"); - - for (const extension of curated.extensions) - for (const target of ["linux-x64", "linux-arm64"]) { - const { url, sha256, size } = extension.targets[target]; - - assert.match(url, /^https:\/\/open-vsx\.org\//); - assert.match(sha256, /^[0-9a-f]{64}$/); - assert.ok(size > 0); - } -}); - -test("refuses a runtime built from another commit", async (t) => { - const { root, packageDirectory, vscodeServer } = await fixture(t, { - runtimeCommit: "0".repeat(40), - }); - - await assert.rejects( - packReviewCli({ version, commit }, path.join(root, "output"), { - packageDirectory, - stdio: "pipe", - vscodeServer, - }), - /the remote runtime was built from 0{40}/, - ); -}); - -test("deletes a download that fails its checksum and packs nothing", async (t) => { - const { root, packageDirectory, vscodeServer, cacheDir, downloads } = - await fixture(t); - - const [rg] = downloads; - rg.sha256 = "0".repeat(64); - const cachedFile = path.join(cacheDir, `${rg.sha256}-${path.basename(rg.url)}`); - - await assert.rejects( - packReviewCli({ version, commit }, path.join(root, "output"), { - packageDirectory, - stdio: "pipe", - vscodeServer, - }), - /Checksum mismatch/, - ); - await assert.rejects(stat(cachedFile)); - await assert.rejects(stat(path.join(root, "output", `dev.fast-whiteboard-${version}.tgz`))); + await assert.rejects(stat(path.join(pkgRoot, "vscode-server"))); }); test("refuses a tarball over the size limit", async (t) => { - const { root, packageDirectory, vscodeServer } = await fixture(t); + const { root, packageDirectory } = await fixture(t); await assert.rejects( packReviewCli({ version, commit }, path.join(root, "output"), { packageDirectory, stdio: "pipe", - vscodeServer, - maxBytes: 1024, + maxBytes: 16, }), - /over the 1024-byte limit/, + /over the 16-byte limit/, ); await assert.rejects( - stat( - path.join(root, "output", `dev.fast-whiteboard-${version}.tgz`), - ), + stat(path.join(root, "output", `dev.fast-whiteboard-${version}.tgz`)), ); }); From 45926ed4e95d9c73b46979e6d2978298bed7a144 Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Wed, 14 Oct 2026 13:13:58 -0400 Subject: [PATCH 04/14] Probe a host before installing One read-only script reports the system, tools, free space, reachability and installed versions; Desktop judges whether Whiteboard can run there. --- .../reviewRemoteProbe.containers.test.ts | 169 +++++++++++++ .../remote/reviewRemoteProbe.test.ts | 224 ++++++++++++++++++ .../electron-main/remote/reviewRemoteProbe.ts | 128 ++++++++++ .../remote/reviewRemoteProbeScript.ts | 79 ++++++ 4 files changed, 600 insertions(+) create mode 100644 apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteProbe.containers.test.ts create mode 100644 apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteProbe.test.ts create mode 100644 apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteProbe.ts create mode 100644 apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteProbeScript.ts diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteProbe.containers.test.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteProbe.containers.test.ts new file mode 100644 index 000000000..31af1554b --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteProbe.containers.test.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from "node:assert/strict"; +import { execFile, execFileSync, spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { arch, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { after, before, test } from "node:test"; +import { promisify } from "node:util"; + +import type { SpawnSsh } from "./reviewRemoteHost.js"; +import { judgeRemote, parseRemoteProbe, probeRemote, REVIEW_REMOTE_PROBE_TIMEOUT } from "./reviewRemoteProbe.js"; +import { reviewRemoteProbeScript } from "./reviewRemoteProbeScript.js"; +import { REVIEW_SSH_CONFIG_ENV, reviewSshSession } from "./reviewSshCommand.js"; + +const run = promisify(execFile); +const harness = resolve(import.meta.dirname, "../../../../../../scripts/e2e/remote/remote.mjs"); + +function skipReason(): string | undefined { + if (process.env.WB_TEST_CONTAINERS !== "1") return "set WB_TEST_CONTAINERS=1 to run the probe against containers"; + try { + execFileSync("docker", ["info"], { stdio: "ignore" }); + } catch { + return "Docker is not available"; + } + return undefined; +} + +const skip = skipReason(); + +const runPrefix = `s3t1${randomBytes(3).toString("hex")}`; +const hosts = { + fish: ["--shell", "fish", "--banner"], + amd64: ["--platform", "linux/amd64"], + old: ["--image", "debian:11", "--sealed"], + musl: ["--image", "alpine:3.20"], + bare: [], +} as const; +type Host = keyof typeof hosts; +const runOf = (host: Host) => `${runPrefix}-${host}`; +const containerOf = (host: Host) => `wb-test-${runOf(host)}-${host}`; +const aliasOf = (host: Host) => `wb-test-${host}`; +const started = new Set(); +let controlDirectory = ""; + +const remote = (host: Host, args: string[]) => + run(process.execPath, [harness, ...args], { env: { ...process.env, WB_TEST_RUN: runOf(host) }, maxBuffer: 16 << 20 }); + +const sshEnv = (host: Host) => ({ ...process.env, VSCODE_DEV: "1", [REVIEW_SSH_CONFIG_ENV]: `/tmp/wbt.${runOf(host)}/ssh_config` }); + +const realSsh: SpawnSsh = (args, options) => spawn("ssh", args, options); + +const RELEASES = "https://install.dev.fast/"; + +async function probe(host: Host, releases = RELEASES) { + const began = Date.now(); + const result = await probeRemote({ session: reviewSshSession(aliasOf(host), controlDirectory, "test"), spawn: realSsh, env: sshEnv(host), releases }); + const elapsed = Date.now() - began; + assert.ok("probe" in result, "error" in result ? result.error : ""); + assert.ok(elapsed < REVIEW_REMOTE_PROBE_TIMEOUT); + return { probe: result.probe, elapsed }; +} + +async function written(host: Host) { + const { stdout } = await remote(host, ["ssh", host, "--", "find", "~", "-newer", "/etc/hostname"]); + return stdout + .split("\n") + .filter((line) => line.startsWith("/")) + .sort(); +} + +before( + async () => { + if (skip) return; + controlDirectory = await mkdtemp(join(tmpdir(), "wb-probe-ssh-")); + const ups = await Promise.allSettled( + (Object.keys(hosts) as Host[]).map(async (host) => { + started.add(host); + await remote(host, ["up", host, "--node", "none", ...hosts[host]]); + }), + ); + for (const up of ups) if (up.status === "rejected") throw up.reason; + }, + { timeout: 20 * 60_000 }, +); + +after( + async () => { + for (const host of started) await remote(host, ["down", "--all"]).catch((error) => console.error(error.stderr ?? error)); + if (controlDirectory) await rm(controlDirectory, { recursive: true, force: true }); + }, + { timeout: 5 * 60_000 }, +); + +test("ubuntu:22.04 under dash, with a fish login shell and a banner, is supported and left as it was", { skip }, async () => { + const before = await written("fish"); + const { probe: found } = await probe("fish"); + + assert.deepEqual(judgeRemote(found), { supported: true, target: arch() === "arm64" ? "linux-arm64" : "linux-x64" }); + assert.equal(found.home, "/home/dev"); + assert.deepEqual(found.installed, []); + assert.deepEqual(await written("fish"), before); +}); + +test("the script also runs under bash", { skip }, async () => { + const { stdout } = await new Promise<{ stdout: string }>((resolvePromise, reject) => { + const child = execFile( + "ssh", + ["-F", `/tmp/wbt.${runOf("fish")}/ssh_config`, "-T", "--", aliasOf("fish"), "bash", "-s"], + { timeout: REVIEW_REMOTE_PROBE_TIMEOUT }, + (error, out) => (error ? reject(error) : resolvePromise({ stdout: out })), + ); + child.stdin?.end(reviewRemoteProbeScript(RELEASES)); + }); + const parsed = parseRemoteProbe(stdout); + + assert.ok("probe" in parsed, "error" in parsed ? parsed.error : ""); + assert.equal(judgeRemote(parsed.probe).supported, true); +}); + +test("a home that cannot be written is refused", { skip }, async (t) => { + const container = containerOf("fish"); + await run("docker", ["exec", container, "chmod", "555", "/home/dev"]); + t.after(() => run("docker", ["exec", container, "chmod", "755", "/home/dev"])); + + const { probe: found } = await probe("fish"); + + assert.deepEqual(judgeRemote(found), { + supported: false, + reason: "The home directory /home/dev cannot be written; Whiteboard needs to write under it.", + }); +}); + +test("ubuntu:22.04 on linux/amd64 is linux-x64", { skip }, async () => { + assert.deepEqual(judgeRemote((await probe("amd64")).probe), { supported: true, target: "linux-x64" }); +}); + +test("debian:11 is refused for glibc 2.31, and a sealed host does not reach the releases", { skip }, async () => { + const { probe: found } = await probe("old"); + + assert.equal(found.glibc, "2.31"); + assert.deepEqual(judgeRemote(found), { supported: false, reason: "This host runs glibc 2.31; Whiteboard needs 2.34 or newer." }); + assert.equal(found.downloader, "curl"); + assert.equal(found.releasesReachable, false); +}); + +test("alpine is refused for musl", { skip }, async () => { + const { probe: found } = await probe("musl"); + + assert.equal(found.glibc, null); + const judged = judgeRemote(found); + assert.equal(judged.supported, false); + assert.match(judged.supported ? "" : judged.reason, /no glibc .*musl/); +}); + +test("a host without xz lists its other tools, and reaches the releases it is given", { skip }, async () => { + await run("docker", ["exec", containerOf("bare"), "sh", "-c", 'rm -f "$(command -v xz)"']); + + const { probe: found } = await probe("bare"); + + assert.deepEqual(found.tools, ["tar", "sha256sum"].concat(found.tools.includes("openssl") ? ["openssl"] : [])); + assert.equal(found.releasesReachable, true); + assert.equal((await probe("bare", "")).probe.releasesReachable, false); + assert.equal((await probe("bare", "http://127.0.0.1:9/")).probe.releasesReachable, false); +}); diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteProbe.test.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteProbe.test.ts new file mode 100644 index 000000000..def278025 --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteProbe.test.ts @@ -0,0 +1,224 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { chmod, mkdir, mkdtemp, readdir, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import type { SpawnSsh } from "./reviewRemoteHost.js"; +import { REVIEW_REMOTE_INSTALL_MARKER, REVIEW_REMOTE_WRAPPER_MARK } from "./reviewRemoteInstallScript.js"; +import { judgeRemote, parseRemoteProbe, probeRemote, type ReviewRemoteProbe } from "./reviewRemoteProbe.js"; +import { REVIEW_REMOTE_PROBE_BEGIN, REVIEW_REMOTE_PROBE_END, REVIEW_REMOTE_PROBE_PATH_CLI } from "./reviewRemoteProbeScript.js"; +import { reviewSshSession } from "./reviewSshCommand.js"; + +const SHA256 = "a".repeat(64); + +const supported: ReviewRemoteProbe = { + os: "Linux", + arch: "x86_64", + glibc: "2.35", + home: "/home/dev", + root: "/home/dev/.dev/whiteboard-remote", + homeWritable: true, + freeBytes: 20e9, + installed: [{ version: "0.1.6", sha256: SHA256 }], + pathCli: { path: "/usr/local/bin/whiteboard", version: "0.1.6" }, + downloader: "curl", + releasesReachable: true, + tools: ["tar", "xz", "sha256sum"], +}; + +const answer = (value: unknown, before = "", after = "") => { + const { pathCli, ...rest } = value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : { pathCli: undefined }; + const body = value && typeof value === "object" && !Array.isArray(value) ? rest : value; + return `${before}${REVIEW_REMOTE_PROBE_BEGIN}\n${JSON.stringify(body)}\n${REVIEW_REMOTE_PROBE_END}\n${pathCli ? `${REVIEW_REMOTE_PROBE_PATH_CLI} ${JSON.stringify(pathCli)}\n` : ""}${after}`; +}; + +test("judgeRemote names the target of a supported host", () => { + assert.deepEqual(judgeRemote(supported), { supported: true, target: "linux-x64" }); + assert.deepEqual(judgeRemote({ ...supported, arch: "aarch64", glibc: "2.34" }), { supported: true, target: "linux-arm64" }); +}); + +test("judgeRemote refuses with the value found and the value needed", () => { + const reason = (change: Partial) => { + const judged = judgeRemote({ ...supported, ...change }); + assert.equal(judged.supported, false); + return judged.supported ? "" : judged.reason; + }; + assert.equal(reason({ os: "Darwin" }), "This host runs Darwin; Whiteboard needs Linux."); + assert.equal(reason({ arch: "armv7l" }), "This host's CPU is armv7l; Whiteboard needs x86_64 or aarch64."); + assert.equal(reason({ glibc: "2.31" }), "This host runs glibc 2.31; Whiteboard needs 2.34 or newer."); + assert.equal(reason({ glibc: "1.99" }), "This host runs glibc 1.99; Whiteboard needs 2.34 or newer."); + assert.match(reason({ glibc: null }), /no glibc .*musl.*needs glibc 2\.34 or newer\.$/); + assert.equal(reason({ homeWritable: false }), "The home directory /home/dev cannot be written; Whiteboard needs to write under it."); + assert.equal(reason({ freeBytes: 999_999_999 }), "The home directory has 0.9 GB free; Whiteboard needs 1 GB."); + assert.equal(judgeRemote({ ...supported, glibc: "3.0" }).supported, true); + assert.equal(judgeRemote({ ...supported, freeBytes: 1e9 }).supported, true); +}); + +test("the answer is read past a login banner, also one that shares the sentinel's line", () => { + assert.deepEqual(parseRemoteProbe(answer(supported, "Welcome\nno newline")), { probe: supported }); +}); + +async function executable(path: string, body: string) { + await mkdir(join(path, ".."), { recursive: true }); + await writeFile(path, `#!/bin/sh\n${body}\n`); + await chmod(path, 0o755); +} + +async function fakeRemote(t: test.TestContext) { + const home = await mkdtemp(join(tmpdir(), "wb-probe-")); + t.after(() => rm(home, { recursive: true, force: true })); + const bin = join(home, "bin"); + await executable(join(bin, "curl"), "exit 7"); + await executable(join(bin, "whiteboard"), 'if [ "$1" = --version ]; then echo 0.2.0; fi'); + const versions = join(home, ".dev/whiteboard-remote/versions"); + for (const version of ["0.1.6", "0.1.7.part", "0.1.8", "0.1.9", "0.2.0"]) await mkdir(join(versions, version), { recursive: true }); + const node = (version: string) => join(versions, version, "bin/node"); + const cli = (version: string) => join(versions, version, "dist/cli.js"); + const unpack = async (version: string) => { + await executable(node(version), "echo v24.18.0"); + await mkdir(join(cli(version), ".."), { recursive: true }); + await writeFile(cli(version), ""); + }; + const marker = (version: string, sha256: string) => writeFile(join(versions, version, REVIEW_REMOTE_INSTALL_MARKER), `${JSON.stringify({ version, sha256, node: node(version), cli: cli(version) })}\n`); + await unpack("0.1.6"); + await marker("0.1.6", SHA256); + await unpack("0.1.9"); + await marker("0.1.9", ""); + await marker("0.2.0", SHA256); + return { home, env: { HOME: home, PATH: `${bin}:/usr/bin:/bin` } }; +} + +const localShell = + (env: NodeJS.ProcessEnv, command = "/bin/sh", args = ["-s"]): SpawnSsh => + (_args, options) => + spawn(command, args, { ...options, env }); + +const session = reviewSshSession("devbox", tmpdir(), "test"); + +async function tree(dir: string): Promise { + const entries = await readdir(dir, { recursive: true }); + return Promise.all(entries.sort().map(async (entry) => `${entry} ${(await stat(join(dir, entry))).mtimeMs}`)); +} + +test("the script finds the complete installed versions and the host's tools, and writes nothing", async (t) => { + const { home, env } = await fakeRemote(t); + const before = await tree(home); + + const result = await probeRemote({ session, spawn: localShell(env), env: {} }); + + assert.ok("probe" in result, "error" in result ? result.error : ""); + const { probe } = result; + assert.deepEqual(probe.installed, [{ version: "0.1.6", sha256: SHA256 }]); + assert.deepEqual(probe.pathCli, { path: join(home, "bin/whiteboard"), version: "0.2.0" }); + assert.equal(probe.home, home); + assert.equal(probe.root, join(home, ".dev/whiteboard-remote")); + assert.equal(probe.homeWritable, true); + assert.ok(probe.freeBytes > 0); + assert.equal(probe.downloader, "curl"); + assert.equal(probe.releasesReachable, false); + assert.ok(probe.tools.includes("tar"), probe.tools.join()); + assert.deepEqual(await tree(home), before); +}); + +test("Desktop's wrapper and a link into the install root are not a CLI on PATH: their markers decide", async (t) => { + const { home, env } = await fakeRemote(t); + const bin = join(home, "bin"); + const local = join(home, ".local/bin"); + await rm(join(bin, "whiteboard")); + await executable(join(local, "whiteboard"), `${REVIEW_REMOTE_WRAPPER_MARK}\necho 0.1.6`); + const probe = async () => { + const result = await probeRemote({ session, spawn: localShell(env), env: {} }); + assert.ok("probe" in result, "error" in result ? result.error : ""); + return result.probe.pathCli; + }; + + assert.equal(await probe(), null); + + const managed = join(home, ".dev/whiteboard-remote/versions/0.1.6/whiteboard"); + await executable(managed, "echo 0.1.6"); + await symlink(managed, join(bin, "whiteboard")); + assert.equal(await probe(), null); +}); + +test("a tree unpacked by hand under the install root is a CLI on PATH; one under versions/ is not", async (t) => { + const { home, env } = await fakeRemote(t); + const root = join(home, ".dev/whiteboard-remote"); + await rm(join(home, "bin/whiteboard")); + const probe = async (dir: string) => { + await executable(join(dir, "whiteboard"), 'if [ "$1" = --version ]; then echo 1.2.3; fi'); + const result = await probeRemote({ session, spawn: localShell({ ...env, PATH: `${dir}:${env.PATH}` }), env: {} }); + assert.ok("probe" in result, "error" in result ? result.error : ""); + return result.probe.pathCli; + }; + + const extracted = join(root, "whiteboard-remote"); + assert.deepEqual(await probe(extracted), { path: join(extracted, "whiteboard"), version: "1.2.3" }); + await rm(extracted, { recursive: true }); + assert.equal(await probe(join(root, "versions/1.2.3")), null); +}); + +test("a home with quotes and backslashes reaches the parser intact", async (t) => { + const parent = await mkdtemp(join(tmpdir(), "wb-probe-")); + t.after(() => rm(parent, { recursive: true, force: true })); + const home = join(parent, 'a "quoted" \\ home'); + await mkdir(home); + + const result = await probeRemote({ session, spawn: localShell({ HOME: home, PATH: "/usr/bin:/bin" }), env: {} }); + + assert.ok("probe" in result, "error" in result ? result.error : ""); + assert.equal(result.probe.home, home); +}); + +test("a home that does not exist is refused as unwritable, not as a malformed answer", async (t) => { + const parent = await mkdtemp(join(tmpdir(), "wb-probe-")); + t.after(() => rm(parent, { recursive: true, force: true })); + const home = join(parent, "missing"); + + const result = await probeRemote({ session, spawn: localShell({ HOME: home, PATH: "/usr/bin:/bin" }), env: {} }); + + assert.ok("probe" in result, "error" in result ? result.error : ""); + assert.equal(result.probe.freeBytes, 0); + assert.deepEqual(judgeRemote({ ...result.probe, os: "Linux", arch: "x86_64", glibc: "2.35" }), { + supported: false, + reason: `The home directory ${home} cannot be written; Whiteboard needs to write under it.`, + }); +}); + +test("a probe that does not answer in time is ended and reported", async () => { + const started = Date.now(); + const result = await probeRemote({ session, spawn: localShell({}, "/bin/sleep", ["30"]), env: {}, timeout: 300 }); + + assert.deepEqual(result, { error: "The probe of devbox did not answer within 0.3 seconds." }); + assert.ok(Date.now() - started < 5000); +}); + +test("a probe that runs out of time after its answer has no PATH CLI, and is not an error", async () => { + const printed = answer({ ...supported, pathCli: undefined }); + const result = await probeRemote({ session, spawn: localShell({}, "/bin/sh", ["-c", `cat >/dev/null; printf '%s' '${printed}'; exec sleep 30`]), env: {}, timeout: 500 }); + + assert.deepEqual(result, { probe: { ...supported, pathCli: null } }); +}); + +const timeoutDir = spawnSync("/bin/sh", ["-c", "command -v timeout"], { encoding: "utf8" }).stdout.trim().replace(/\/timeout$/, ""); + +test("a login shell that ignores SIGTERM is killed in time, and finds no CLI", { skip: !timeoutDir && "no timeout on PATH" }, async (t) => { + const home = await mkdtemp(join(tmpdir(), "wb-probe-")); + t.after(() => rm(home, { recursive: true, force: true })); + const shell = join(home, "slow-shell"); + await executable(shell, "trap '' TERM\nsleep 2; sleep 2; sleep 2; echo /opt/bin/whiteboard"); + await executable(join(home, "bin/curl"), "exit 7"); + const started = Date.now(); + + const result = await probeRemote({ session, spawn: localShell({ HOME: home, SHELL: shell, PATH: `${join(home, "bin")}:${timeoutDir}:/usr/bin:/bin` }), env: {} }); + + assert.ok("probe" in result, "error" in result ? result.error : ""); + assert.equal(result.probe.pathCli, null); + assert.ok(Date.now() - started < 6000, `${Date.now() - started} ms`); +}); diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteProbe.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteProbe.ts new file mode 100644 index 000000000..4f323463d --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteProbe.ts @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { lastLine, runSsh, type SpawnSsh } from "./reviewRemoteHost.js"; +import { REVIEW_REMOTE_PROBE_BEGIN, REVIEW_REMOTE_PROBE_END, REVIEW_REMOTE_PROBE_PATH_CLI, reviewRemoteProbeScript } from "./reviewRemoteProbeScript.js"; +import { sshExecArgs, type ReviewSshSession } from "./reviewSshCommand.js"; + +export interface ReviewRemoteProbe { + os: string; + arch: string; + glibc: string | null; + home: string; + root: string; + homeWritable: boolean; + freeBytes: number; + installed: ReviewRemoteInstalled[]; + pathCli: { path: string; version: string | null } | null; + downloader: "curl" | "wget" | null; + releasesReachable: boolean; + tools: ReviewRemoteTool[]; +} + +export const REVIEW_REMOTE_PROBE_TOOLS = ["tar", "xz", "sha256sum", "openssl"] as const; +export type ReviewRemoteTool = (typeof REVIEW_REMOTE_PROBE_TOOLS)[number]; + +export type ReviewRemoteTarget = "linux-x64" | "linux-arm64"; + +export interface ReviewRemoteInstalled { + version: string; + sha256: string; +} + +export type ReviewRemoteSupport = { supported: true; target: ReviewRemoteTarget } | { supported: false; reason: string }; + +export const REVIEW_REMOTE_PROBE_TIMEOUT = 10_000; + +const TARGETS: Record = { x86_64: "linux-x64", aarch64: "linux-arm64" }; +const GLIBC_FLOOR = [2, 34] as const; +const GB = 1_000_000_000; + +export function judgeRemote(probe: ReviewRemoteProbe): ReviewRemoteSupport { + if (probe.os !== "Linux") return refuse(`This host runs ${probe.os}; Whiteboard needs Linux.`); + const target = TARGETS[probe.arch]; + if (!target) return refuse(`This host's CPU is ${probe.arch}; Whiteboard needs x86_64 or aarch64.`); + if (probe.glibc === null) { + return refuse(`This host has no glibc (Alpine and other musl systems do not); Whiteboard needs glibc ${GLIBC_FLOOR.join(".")} or newer.`); + } + const [major = 0, minor = 0] = probe.glibc.split(".").map(Number); + if (major < GLIBC_FLOOR[0] || (major === GLIBC_FLOOR[0] && minor < GLIBC_FLOOR[1])) { + return refuse(`This host runs glibc ${probe.glibc}; Whiteboard needs ${GLIBC_FLOOR.join(".")} or newer.`); + } + if (!probe.homeWritable) return refuse(`The home directory ${probe.home} cannot be written; Whiteboard needs to write under it.`); + if (probe.freeBytes < GB) { + return refuse(`The home directory has ${(Math.floor(probe.freeBytes / (GB / 10)) / 10).toFixed(1)} GB free; Whiteboard needs 1 GB.`); + } + return { supported: true, target }; +} + +const refuse = (reason: string): ReviewRemoteSupport => ({ supported: false, reason }); + +export type ReviewRemoteProbeResult = { probe: ReviewRemoteProbe } | { error: string }; + +export async function probeRemote(input: { + session: ReviewSshSession; + spawn: SpawnSsh; + env: NodeJS.ProcessEnv; + releases?: string; + timeout?: number; +}): Promise { + const timeout = input.timeout ?? REVIEW_REMOTE_PROBE_TIMEOUT; + const alias = input.session.alias; + const result = await runSsh(input.spawn, input.env, sshExecArgs(input.session, input.env), timeout, reviewRemoteProbeScript(input.releases)); + if (result.error) return { error: `The probe of ${alias} could not start: ${result.error.message}` }; + const parsed = parseRemoteProbe(result.stdout); + if ("probe" in parsed) return parsed; + if (result.timedOut) return { error: `The probe of ${alias} did not answer within ${timeout / 1000} seconds.` }; + const stderr = lastLine(result.stderr); + return { error: `The probe of ${alias} failed: ${parsed.error}${result.code ? ` It exited with ${result.code}${stderr ? `: ${stderr}` : ""}.` : ""}` }; +} + +export function parseRemoteProbe(stdout: string): ReviewRemoteProbeResult { + const begin = stdout.indexOf(REVIEW_REMOTE_PROBE_BEGIN); + const end = begin < 0 ? -1 : stdout.indexOf(REVIEW_REMOTE_PROBE_END, begin + REVIEW_REMOTE_PROBE_BEGIN.length); + if (end < 0) return { error: "it printed no answer between its sentinels." }; + let value: Record; + try { + value = JSON.parse(stdout.slice(begin + REVIEW_REMOTE_PROBE_BEGIN.length, end)); + } catch { + return { error: "its answer is not JSON." }; + } + if (!isProbe(value)) return { error: "its answer is malformed." }; + if (value.root === "") return { error: "its answer is malformed: DEV_REVIEW_HOME there is not an absolute, normalised path." }; + return { probe: { ...value, pathCli: readPathCli(stdout.slice(end)) } }; +} + +function isProbe(value: Record | null): value is Omit { + return ( + typeof value === "object" && + value !== null && + typeof value.os === "string" && + typeof value.arch === "string" && + typeof value.home === "string" && + typeof value.root === "string" && + typeof value.freeBytes === "number" && + Array.isArray(value.installed) && + Array.isArray(value.tools) + ); +} + +function readPathCli(after: string): ReviewRemoteProbe["pathCli"] { + const line = after.split("\n").find((candidate) => candidate.startsWith(`${REVIEW_REMOTE_PROBE_PATH_CLI} `)); + try { + const cli = JSON.parse(line?.slice(REVIEW_REMOTE_PROBE_PATH_CLI.length + 1) ?? "null"); + return typeof cli?.path === "string" ? { path: cli.path, version: typeof cli.version === "string" ? cli.version : null } : null; + } catch { + return null; + } +} + +export function missingInstallTools(probe: ReviewRemoteProbe, alias: string): string | undefined { + const missing = (["tar", "xz"] as const).filter((tool) => !probe.tools.includes(tool)); + if (missing.length) return `${alias} needs ${missing.join(" and ")} to install Whiteboard.`; + if (!probe.downloader) return `${alias} needs curl or wget to install Whiteboard.`; + if (!probe.tools.includes("sha256sum") && !probe.tools.includes("openssl")) return `Whiteboard needs sha256sum or openssl on ${alias} to check the tarball.`; + return undefined; +} diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteProbeScript.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteProbeScript.ts new file mode 100644 index 000000000..fd0ff73bb --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteProbeScript.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { REVIEW_REMOTE_FIND_CLI } from "./reviewRemoteAttachScript.js"; +import { REVIEW_REMOTE_COMPLETE_SHA256, REVIEW_REMOTE_INSTALL_MARKER, REVIEW_REMOTE_ROOT_SCRIPT, REVIEW_REMOTE_WRAPPER_MARK, shellQuote } from "./reviewRemoteInstallScript.js"; + +export const REVIEW_REMOTE_PROBE_BEGIN = "WHITEBOARD-PROBE-BEGIN"; +export const REVIEW_REMOTE_PROBE_END = "WHITEBOARD-PROBE-END"; +export const REVIEW_REMOTE_PROBE_PATH_CLI = "WHITEBOARD-PROBE-PATH-CLI"; + +export const reviewRemoteProbeScript = (releases?: string) => `LC_ALL=C +export LC_ALL +str() { printf '"%s"' "$(printf '%s' "$1" | tr -d '\\000-\\037' | sed 's/\\\\/\\\\\\\\/g; s/"/\\\\"/g')"; } +strOrNull() { if [ -n "$1" ]; then str "$1"; else printf null; fi; } + +os=$(uname -s 2>/dev/null) +arch=$(uname -m 2>/dev/null) +glibc=$(getconf GNU_LIBC_VERSION 2>/dev/null | sed -n 's/^glibc \\([0-9][0-9.]*\\)$/\\1/p') +if [ -z "$glibc" ]; then + glibc=$(ldd --version 2>&1 | head -n 1 | grep -i -e glibc -e 'gnu libc' | sed -n 's/.* \\([0-9][0-9]*\\.[0-9][0-9]*\\)$/\\1/p') +fi +home=$HOME +writable=false +[ -d "$home" ] && [ -w "$home" ] && writable=true +free=$(df -Pk "$home" 2>/dev/null | awk 'NR == 2 { printf "%.0f", $4 * 1024 }') + +${REVIEW_REMOTE_ROOT_SCRIPT}remote=$root + +${REVIEW_REMOTE_COMPLETE_SHA256} +installed= +for dir in "$remote"/versions/*; do + [ -d "$dir" ] || continue + case "$dir" in *.part) continue ;; esac + sha256=$(completeSha256 "$dir/${REVIEW_REMOTE_INSTALL_MARKER}") + [ -n "$sha256" ] || continue + installed="$installed\${installed:+,}{\\"version\\":$(str "\${dir##*/}"),\\"sha256\\":$(str "$sha256")}" +done + +tools= +for tool in tar xz sha256sum openssl; do + command -v "$tool" >/dev/null 2>&1 && tools="$tools\${tools:+,}\\"$tool\\"" +done + +releases=${releases ? shellQuote(releases) : ""} +downloader= +reachable=false +if command -v curl >/dev/null 2>&1; then + downloader=curl + [ -n "$releases" ] && curl -fsI --max-time 3 -o /dev/null "$releases" >/dev/null 2>&1 && reachable=true +elif command -v wget >/dev/null 2>&1; then + downloader=wget + hsts= + tries= + wget --help 2>&1 | grep -q -- --no-hsts && hsts=--no-hsts + wget --help 2>&1 | grep -q -- --tries && tries="-t 1" + limit= + command -v timeout >/dev/null 2>&1 && limit="timeout 3" + [ -n "$releases" ] && $limit wget -q $hsts $tries -T 3 --spider "$releases" >/dev/null 2>&1 && reachable=true +fi + +echo +echo ${REVIEW_REMOTE_PROBE_BEGIN} +printf '{"os":%s,"arch":%s,"glibc":%s,"home":%s,"root":%s,"homeWritable":%s,"freeBytes":%s,' \\ + "$(str "$os")" "$(str "$arch")" "$(strOrNull "$glibc")" "$(str "$home")" "$(str "$remote")" \\ + "$writable" "\${free:-0}" +printf '"installed":[%s],"downloader":%s,"releasesReachable":%s,"tools":[%s]}\\n' \\ + "$installed" "$(strOrNull "$downloader")" "$reachable" "$tools" +echo ${REVIEW_REMOTE_PROBE_END} + +${REVIEW_REMOTE_FIND_CLI}# Desktop's own installs are judged by their markers, not by a version on PATH. +grep -qxF ${shellQuote(REVIEW_REMOTE_WRAPPER_MARK)} "$wb" 2>/dev/null && wb= +case "$(readlink -f "$wb" 2>/dev/null)" in "$(readlink -f "$remote" 2>/dev/null || echo "$remote")"/versions/*) wb= ;; esac +if [ -n "$wb" ]; then + wbVersion=$(PATH="\${wb%/*}:$PATH" DEV_FAST_REVIEW_CLI_NO_DELEGATE=1 DEV_FAST_REVIEW_TELEMETRY_DISABLED=1 bounded 2 "$wb" --version /dev/null | head -n 1) + printf '%s {"path":%s,"version":%s}\\n' ${REVIEW_REMOTE_PROBE_PATH_CLI} "$(str "$wb")" "$(strOrNull "$wbVersion")" +fi +`; From 1f2d9a18f862d1a4ee522c8fe719094c4988f19c Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Wed, 14 Oct 2026 13:13:58 -0400 Subject: [PATCH 05/14] Install the pinned tarball on a host The host downloads, checks and unpacks the tarball under a lock, then removes older versions that nothing runs. --- .../remote/reviewRemoteArtifacts.test.ts | 74 +++ .../remote/reviewRemoteArtifacts.ts | 65 +++ .../remote/reviewRemoteInstallScript.ts | 241 ++++++++ .../reviewRemoteInstaller.containers.test.ts | 235 ++++++++ .../remote/reviewRemoteInstaller.test.ts | 545 ++++++++++++++++++ .../remote/reviewRemoteInstaller.ts | 279 +++++++++ .../electron-main/remote/test/fakeSsh.ts | 63 +- 7 files changed, 1497 insertions(+), 5 deletions(-) create mode 100644 apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteArtifacts.test.ts create mode 100644 apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteArtifacts.ts create mode 100644 apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstallScript.ts create mode 100644 apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.containers.test.ts create mode 100644 apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.test.ts create mode 100644 apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.ts diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteArtifacts.test.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteArtifacts.test.ts new file mode 100644 index 000000000..299df89c3 --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteArtifacts.test.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { after, test } from "node:test"; + +import type { IWhiteboardRemoteProduct } from "../../../base/common/product.js"; +import { remoteArtifact, remoteReleasesOrigin, ReviewRemoteNoTarball } from "./reviewRemoteArtifacts.js"; + +const servers: Server[] = []; + +after(() => { + for (const server of servers) server.close(); +}); + +async function serve(files: Record) { + const requests: string[] = []; + const server = createServer((request, response) => { + requests.push(request.url ?? ""); + const body = files[request.url ?? ""]; + response.writeHead(body === undefined ? 404 : 200); + response.end(body); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { base: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, requests }; +} + +const PIN: IWhiteboardRemoteProduct = { + version: "1.2.3", + "linux-x64": { url: "https://install.dev.fast/releases/1.2.3/remote/whiteboard-remote-1.2.3-linux-x64.tar.xz", sha256: "a".repeat(64) }, + "linux-arm64": { url: "https://install.dev.fast/releases/1.2.3/remote/whiteboard-remote-1.2.3-linux-arm64.tar.xz", sha256: "b".repeat(64) }, +}; + +test("a release build installs what it pinned, over https only", async () => { + assert.equal(remoteReleasesOrigin({ pin: PIN }), "https://install.dev.fast"); + assert.deepEqual(await remoteArtifact("linux-arm64", "1.2.3", { pin: PIN }), { + name: "whiteboard-remote-1.2.3-linux-arm64.tar.xz", + url: PIN["linux-arm64"].url, + sha256: "b".repeat(64), + }); + const plain = { ...PIN, "linux-x64": { ...PIN["linux-x64"], url: PIN["linux-x64"].url.replace("https:", "http:") } }; + await assert.rejects(remoteArtifact("linux-x64", "1.2.3", { pin: plain }), /is not https/); +}); + +test("the releases override wins over the pin, may be plain http, and reads the checksum beside the tarball", async () => { + const name = "whiteboard-remote-0.2.4%2Bdev.abc-linux-x64.tar.xz"; + const { base, requests } = await serve({ [`/dev/${name}.sha256`]: `${"c".repeat(64)} whiteboard-remote-0.2.4+dev.abc-linux-x64.tar.xz\n` }); + + for (const releases of [`${base}/dev`, `${base}/dev/`]) { + const options = { pin: PIN, releases }; + assert.equal(remoteReleasesOrigin(options), base); + assert.deepEqual(await remoteArtifact("linux-x64", "0.2.4+dev.abc", options), { + name: "whiteboard-remote-0.2.4+dev.abc-linux-x64.tar.xz", + url: `${base}/dev/${name}`, + sha256: "c".repeat(64), + }); + } + assert.deepEqual(requests, [`/dev/${name}.sha256`, `/dev/${name}.sha256`]); + await assert.rejects(remoteArtifact("linux-arm64", "0.2.4+dev.abc", { pin: PIN, releases: base }), /answered 404/); + for (const releases of ["file:///tmp/releases", "not a url"]) { + assert.equal(remoteReleasesOrigin({ pin: PIN, releases }), undefined); + await assert.rejects(remoteArtifact("linux-x64", "1.2.3", { pin: PIN, releases }), /DEV_FAST_REVIEW_REMOTE_RELEASES is not an http or https URL/); + } +}); + +test("a build with neither a pin nor the override has no tarball", async () => { + assert.equal(remoteReleasesOrigin({ pin: undefined }), undefined); + await assert.rejects(remoteArtifact("linux-x64", "1.2.3", { pin: undefined }), ReviewRemoteNoTarball); +}); diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteArtifacts.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteArtifacts.ts new file mode 100644 index 000000000..50556d846 --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteArtifacts.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IWhiteboardRemoteProduct } from "../../../base/common/product.js"; +import type { ReviewRemoteTarget } from "./reviewRemoteProbe.js"; + +export interface ReviewRemoteArtifact { + name: string; + url: string; + sha256: string; +} + +export const REVIEW_REMOTE_RELEASES_ENV = "DEV_FAST_REVIEW_REMOTE_RELEASES"; + +export interface ReviewRemoteArtifactsOptions { + readonly pin: IWhiteboardRemoteProduct | undefined; + /** A base URL that holds `whiteboard-remote--.tar.xz` and its `.sha256`, in place of the pin. */ + readonly releases?: string; +} + +export class ReviewRemoteNoTarball extends Error { + constructor() { + super("This build has no remote tarball."); + } +} + +function releasesBase(releases: string): URL | undefined { + try { + const base = new URL(releases.endsWith("/") ? releases : `${releases}/`); + return base.protocol === "http:" || base.protocol === "https:" ? base : undefined; + } catch { + return undefined; + } +} + +/** Where hosts download the tarball from; undefined when this build has none, or the override is not a URL. */ +export function remoteReleasesOrigin(options: ReviewRemoteArtifactsOptions): string | undefined { + if (options.releases) return releasesBase(options.releases)?.origin; + return options.pin && new URL(options.pin["linux-x64"].url).origin; +} + +export async function remoteArtifact(target: ReviewRemoteTarget, version: string, options: ReviewRemoteArtifactsOptions): Promise { + if (options.releases) { + const base = releasesBase(options.releases); + if (!base) throw new Error(`${REVIEW_REMOTE_RELEASES_ENV} is not an http or https URL: ${options.releases}`); + const name = `whiteboard-remote-${version}-${target}.tar.xz`; + const url = new URL(encodeURIComponent(name), base).href; + return { name, url, sha256: await fetchSha256(`${url}.sha256`) }; + } + const { pin } = options; + if (!pin) throw new ReviewRemoteNoTarball(); + const { url, sha256 } = pin[target]; + if (!url.startsWith("https:")) throw new Error(`The pinned remote tarball ${url} is not https.`); + return { name: `whiteboard-remote-${pin.version}-${target}.tar.xz`, url, sha256 }; +} + +async function fetchSha256(url: string): Promise { + const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + if (!response.ok) throw new Error(`${url} answered ${response.status}.`); + const sha256 = (await response.text()).trim().split(/\s/)[0]; + if (!/^[0-9a-f]{64}$/.test(sha256)) throw new Error(`${url} holds no sha256.`); + return sha256; +} diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstallScript.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstallScript.ts new file mode 100644 index 000000000..34df3340a --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstallScript.ts @@ -0,0 +1,241 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { REVIEW_REMOTE_INSTALL_LOCK, REVIEW_REMOTE_LOCK_STALE_SECONDS, REVIEW_REMOTE_VERSION, REVIEW_REMOTE_WRAPPER_MARK } from "../../common/reviewProtocol.js"; + +export { REVIEW_REMOTE_LOCK_STALE_SECONDS, REVIEW_REMOTE_VERSION, REVIEW_REMOTE_WRAPPER_MARK }; + +export const REVIEW_REMOTE_INSTALL_SAY = "WHITEBOARD-INSTALL"; +export const REVIEW_REMOTE_INSTALL_MARKER = ".whiteboard-install.json"; + +export function shellQuote(value: string, lines = false): string { + if ((lines ? /[\x00-\x09\x0b-\x1f\x7f-\x9f]/ : /[\x00-\x1f\x7f-\x9f]/).test(value)) { + throw new Error(`${JSON.stringify(value)} holds a control character.`); + } + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +export interface ReviewRemoteInstallContext { + readonly home: string; + readonly root: string; + readonly token: string; +} + +export const REVIEW_REMOTE_ROOT_SCRIPT = `base=$(printf '%s' "\${DEV_REVIEW_HOME-}" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') +root= +case "$base" in +'') root=$HOME/.dev/whiteboard-remote ;; +*//*|*/./*|*/../*|*/.|*/..|*/./|*/../) ;; +/*) root=\${base%/}/whiteboard-remote ;; +esac +[ "$(printf '%s' "\${DEV_REVIEW_HOME-}" | tr -d '\\001-\\037\\177')" = "\${DEV_REVIEW_HOME-}" ] || root= +`; + +export const reviewRemoteVersionDir = (root: string, version: string) => `${root}/versions/${version}`; +const reviewRemoteWrapperPath = (home: string) => `${home}/.local/bin/whiteboard`; + +const versionPart = (context: ReviewRemoteInstallContext, version: string) => `${reviewRemoteVersionDir(context.root, version)}.${context.token}.part`; + +function prelude(context: ReviewRemoteInstallContext): string { + if (!/^[0-9a-f]{8,64}$/.test(context.token)) throw new Error("The install token is not hex."); + return `LC_ALL=C +export LC_ALL +umask 022 +trap '' PIPE +exec 3>&1 +root=${shellQuote(context.root)} +lock="$root/${REVIEW_REMOTE_INSTALL_LOCK}" +token=${context.token} +say() { printf '\\n%s %s\\n' ${REVIEW_REMOTE_INSTALL_SAY} "$*" >&3; } +fail() { say FAIL "$*"; exit 3; } +# A rename, so a reader never sees "started" half written. +stamp() { date +%s > "$lock/started.$token" && mv -f "$lock/started.$token" "$lock/started"; } +own() { + [ "$(cat "$lock/token" 2>/dev/null)" = "$token" ] || fail this install no longer holds the install lock + stamp +} +guard() { + if command -v setsid >/dev/null 2>&1; then setsid "$@" & else "$@" & fi + pid=$! + while kill -0 "$pid" 2>/dev/null; do + printf '.\\n' >&3 2>/dev/null || { kill -TERM -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null; exit 3; } + [ "$(cat "$lock/token" 2>/dev/null)" = "$token" ] && stamp + sleep 1 + done + wait "$pid" +} +`; +} + +export function lockScript(context: ReviewRemoteInstallContext, owner: string, staleSeconds = REVIEW_REMOTE_LOCK_STALE_SECONDS): string { + const staleMinutes = Math.ceil(staleSeconds / 60); + return `${prelude(context)}mkdir -p "$root" || fail cannot create "$root" +take() { + mkdir "$lock" 2>/dev/null || return 1 + printf '%s\\n' "$token" > "$lock/token" + printf '%s\\n' ${shellQuote(owner)} > "$lock/owner" + stamp + say LOCKED + exit 0 +} +take +# The token first: a takeover completed between the two reads leaves "started" fresh. +held=$(cat "$lock/token" 2>/dev/null) +started=$(cat "$lock/started" 2>/dev/null) +case "$started" in +''|*[!0-9]*) + # Never written (a holder that died right after mkdir): by the directory's age. Unreadable: fresh. + if [ ! -e "$lock/started" ] && [ -n "$(find "$lock" -prune -mmin +${staleMinutes} 2>/dev/null)" ]; then started=0; else started=$(date +%s); fi ;; +esac +if [ $(( $(date +%s) - started )) -ge ${staleSeconds} ]; then + stale="$lock.$token.stale" + if mv "$lock" "$stale" 2>/dev/null; then + # Another install may have taken it over first: give that one back. + if [ "$(cat "$stale/token" 2>/dev/null)" = "$held" ] || [ -e "$lock" ]; then rm -rf "$stale"; else mv "$stale" "$lock"; fi + fi + take +fi +say BUSY "$(cat "$lock/owner" 2>/dev/null)" +`; +} + +export function releaseScript(context: ReviewRemoteInstallContext): string { + return `${prelude(context)}if [ "$(cat "$lock/token" 2>/dev/null)" = "$token" ] && mv "$lock" "$lock.$token.done" 2>/dev/null; then + rm -rf "$lock.$token.done" +fi +say RELEASED +`; +} + +export function prepareScript(context: ReviewRemoteInstallContext, input: { version: string; sha256: string }): string { + return `${prelude(context)}own +rm -rf "$root"/versions/*.part "$lock".*.stale "$lock".*.done +mkdir -p "$root/versions" || fail cannot create "$root/versions" +${markerCheck(context, input)} +p=${shellQuote(versionPart(context, input.version))} +if [ -n "$complete" ]; then + say COMPLETE + say MARKER "$(cat "$m")" +elif [ -e "$v" ]; then + mv "$v" "$p" && rm -rf "$p" || fail cannot remove "$v" +fi +for dir in "$root"/versions/*; do + [ -n "$(completeSha256 "$dir/${REVIEW_REMOTE_INSTALL_MARKER}")" ] && say HAVE "\${dir##*/}" && continue + # A version whose removal was cut short (its marker went first). + case "\${dir##*/}" in [0-9]*.[0-9]*.[0-9]*) rm -rf "$dir" ;; esac +done +[ -n "$complete" ] || mkdir "$p" || fail cannot create "$p" +say PREPARED +`; +} + +export const REVIEW_REMOTE_COMPLETE_SHA256 = `completeSha256() { + cs=$(sed -n 's/.*"sha256":"\\([^"][^"]*\\)".*/\\1/p' "$1" 2>/dev/null) + cn=$(sed -n 's/.*"node":"\\([^"]*\\)".*/\\1/p' "$1" 2>/dev/null) + cc=$(sed -n 's/.*"cli":"\\([^"]*\\)".*/\\1/p' "$1" 2>/dev/null) + [ -n "$cs" ] && [ -x "$cn" ] && [ -f "$cc" ] && printf '%s' "$cs" +}`; + +function markerCheck(context: ReviewRemoteInstallContext, input: { version: string; sha256: string }): string { + return `v=${shellQuote(reviewRemoteVersionDir(context.root, input.version))} +m="$v/${REVIEW_REMOTE_INSTALL_MARKER}" +${REVIEW_REMOTE_COMPLETE_SHA256} +complete= +[ -f "$m" ] && [ "$(completeSha256 "$m")" = ${shellQuote(input.sha256)} ] && complete=1`; +} + +export function completeScript(context: ReviewRemoteInstallContext, input: { version: string; sha256: string }): string { + return `${prelude(context)}${markerCheck(context, input)} +[ -n "$complete" ] || exit 0 +say COMPLETE +say MARKER "$(cat "$m")" +`; +} + +const packageTarball = (context: ReviewRemoteInstallContext, version: string) => `${versionPart(context, version)}/whiteboard-remote.tar.xz`; + +const DOWNLOAD = { + curl: `guard curl -fsSL --connect-timeout 20 --max-time 900 -o "$f" "$url"`, + wget: `hsts= +wget --help 2>&1 | grep -q -- --no-hsts && hsts=--no-hsts +limit= +command -v timeout >/dev/null 2>&1 && limit="timeout 900" +guard $limit wget -q $hsts -t 2 -T 30 -O "$f" "$url"`, +}; + +export function unpackScript( + context: ReviewRemoteInstallContext, + input: { version: string; sha256: string; url: string; downloader: "curl" | "wget" }, +): string { + if (!/^[0-9a-f]{64}$/.test(input.sha256)) throw new Error("The tarball checksum is not a sha256."); + return `${prelude(context)}own +p=${shellQuote(versionPart(context, input.version))} +f=${shellQuote(packageTarball(context, input.version))} +url=${shellQuote(input.url)} +${DOWNLOAD[input.downloader]} || { s=$?; rm -rf "$p"; fail cannot download "$url": ${input.downloader} exited with "$s"; } +sum=$(sha256sum "$f" 2>/dev/null || openssl dgst -sha256 -r "$f" 2>/dev/null) +sum=\${sum%% *} +[ "$sum" = ${input.sha256} ] || { rm -rf "$p"; say MISMATCH "$sum"; exit 3; } +guard tar -xJf "$f" -C "$p" --strip-components=1 || { rm -rf "$p"; fail cannot unpack the tarball; } +rm -f "$f" +[ -x "$p/bin/node" ] && [ -f "$p/dist/cli.js" ] && [ -x "$p/whiteboard" ] || { rm -rf "$p"; fail the tarball holds no whiteboard; } +out=$(DEV_FAST_REVIEW_CLI_NO_DELEGATE=1 DEV_FAST_REVIEW_TELEMETRY_DISABLED=1 "$p/bin/node" "$p/dist/cli.js" version --json /dev/null | tail -n 1) +say VERSION "$out" +say UNPACKED +`; +} + +export function finishScript( + context: ReviewRemoteInstallContext, + input: { version: string; marker: string; wrapper?: string }, +): string { + const wrapper = input.wrapper + ? `w=${shellQuote(reviewRemoteWrapperPath(context.home))} +if { [ ! -e "$w" ] && [ ! -L "$w" ]; } || { [ -f "$w" ] && [ ! -L "$w" ] && grep -qxF ${shellQuote(REVIEW_REMOTE_WRAPPER_MARK)} "$w"; }; then + if mkdir -p "\${w%/*}" && printf '%s' ${shellQuote(input.wrapper, true)} > "$w.$token.part" && chmod 755 "$w.$token.part" && mv -f "$w.$token.part" "$w"; then + say WRAPPER written + else + rm -f "$w.$token.part" + say WRAPPER failed + fi +else + say WRAPPER foreign +fi +` + : ""; + return `${prelude(context)}own +p=${shellQuote(versionPart(context, input.version))} +v=${shellQuote(reviewRemoteVersionDir(context.root, input.version))} +printf '%s\\n' ${shellQuote(input.marker)} > "$p/${REVIEW_REMOTE_INSTALL_MARKER}" || fail cannot write the marker +[ -e "$v" ] && { rm -rf "$p"; fail "$v" appeared during the install; } +mv "$p" "$v" || fail cannot move the version into place +say COMPLETE +${wrapper}rm -rf "$root"/versions/*.part +say FINISHED +`; +} + +export function cleanupScript(context: ReviewRemoteInstallContext, input: { candidates: readonly string[]; room: number }): string { + const names = input.candidates.map((name) => shellQuote(name)).join(" "); + return `${prelude(context)}own +# Read once so a grep below never finds itself; launchers run the CLI by absolute path. +if [ -d /proc/self ]; then + procs=$(for f in /proc/[0-9]*/cmdline; do tr '\\000' ' ' < "$f" 2>/dev/null; echo; done) +else + procs=$(ps -eo args= 2>/dev/null) +fi +running() { printf '%s\\n' "$procs" | grep -qF -- "$root/versions/$1/"; } +kept=0 +for v in ${names}; do running "$v" && kept=$((kept + 1)); done +for v in ${names}; do + if running "$v"; then say IN-USE "$v"; continue; fi + if [ "$kept" -lt ${input.room} ]; then kept=$((kept + 1)); continue; fi + d="$root/versions/$v" + [ -f "$d/${REVIEW_REMOTE_INSTALL_MARKER}" ] || continue + rm -f "$d/${REVIEW_REMOTE_INSTALL_MARKER}" && rm -rf "$d" && say REMOVED "$v" +done +say CLEANED +`; +} diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.containers.test.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.containers.test.ts new file mode 100644 index 000000000..a41912dbb --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.containers.test.ts @@ -0,0 +1,235 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from "node:assert/strict"; +import { execFile, execFileSync, spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { after, before, test } from "node:test"; +import { promisify } from "node:util"; + +import { remoteArtifact, type ReviewRemoteArtifact } from "./reviewRemoteArtifacts.js"; +import type { SpawnSsh } from "./reviewRemoteHost.js"; +import { installRemote, type ReviewRemoteInstallInput, type ReviewRemoteInstallProgress } from "./reviewRemoteInstaller.js"; +import { judgeRemote, probeRemote, type ReviewRemoteProbe, type ReviewRemoteTarget } from "./reviewRemoteProbe.js"; +import { REVIEW_SSH_CONFIG_ENV, reviewSshSession, sshCheckArgs, sshCloseArgs, sshMasterArgs, type ReviewSshSession } from "./reviewSshCommand.js"; + +const run = promisify(execFile); +const checkout = resolve(import.meta.dirname, "../../../../../../../.."); +const harness = join(checkout, "apps/review-desktop/scripts/e2e/remote/remote.mjs"); +const VERSION: string = JSON.parse(await readFile(join(checkout, "packages/review/package.json"), "utf8")).version; + +function skipReason(): string | undefined { + if (process.env.WB_TEST_CONTAINERS !== "1") return "set WB_TEST_CONTAINERS=1 to install into containers"; + try { + execFileSync("docker", ["info"], { stdio: "ignore" }); + } catch { + return "Docker is not available"; + } + return undefined; +} + +const skip = skipReason(); +const runId = `s3t3${randomBytes(3).toString("hex")}`; +const hosts = { fresh: ["--node", "none"] } as const; +type Host = keyof typeof hosts; +const env = { ...process.env, WB_TEST_RUN: runId }; +const sshEnv = { ...process.env, VSCODE_DEV: "1", [REVIEW_SSH_CONFIG_ENV]: `/tmp/wbt.${runId}/ssh_config` }; +const realSsh: SpawnSsh = (args, options) => spawn("ssh", args, options); + +let started = false; +let controlDirectory = ""; +let releases: { url: string; stage(target: ReviewRemoteTarget): Promise; close(): Promise } | undefined; +const masters: ReviewSshSession[] = []; +const found = new Map(); +const artifacts = new Map(); + +const remote = (...args: string[]) => run(process.execPath, [harness, ...args], { env, maxBuffer: 16 << 20 }); +const inContainer = async (host: Host, command: string) => (await run("docker", ["exec", "-u", "dev", `wb-test-${runId}-${host}`, "sh", "-c", command])).stdout.trim(); +const reset = (host: Host) => inContainer(host, "rm -rf ~/.dev/whiteboard-remote ~/.local/bin/whiteboard"); + +async function connect(host: Host): Promise { + const session = reviewSshSession(`wb-test-${host}`, controlDirectory, "test"); + spawn("ssh", sshMasterArgs(session, sshEnv), { env: sshEnv, detached: true, stdio: "ignore" }).unref(); + masters.push(session); + for (let i = 0; i < 100; i++) { + if (await run("ssh", sshCheckArgs(session, sshEnv), { env: sshEnv }).then(() => true, () => false)) return session; + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error(`the master for ${host} did not start`); +} + +before( + async () => { + if (skip) return; + controlDirectory = await mkdtemp("/tmp/wbi-"); + started = true; + for (const [host, flags] of Object.entries(hosts)) await remote("up", host, ...flags); + for (const host of Object.keys(hosts) as Host[]) { + const session = await connect(host); + const probed = await probeRemote({ session, spawn: realSsh, env: sshEnv }); + assert.ok("probe" in probed, "error" in probed ? probed.error : ""); + const judged = judgeRemote(probed.probe); + assert.ok(judged.supported); + found.set(host, { session, probe: probed.probe, target: judged.target }); + } + // A computed specifier: the harness is JavaScript outside this project. + const { serveReleases } = await import(pathToFileURL(join(checkout, "apps/review-desktop/scripts/e2e/remote/releases.mjs")).href); + releases = await serveReleases(); + for (const { target } of found.values()) { + if (artifacts.has(target)) continue; + await releases!.stage(target); + artifacts.set(target, await remoteArtifact(target, VERSION, { pin: undefined, releases: releases!.url })); + } + }, + { timeout: 30 * 60_000 }, +); + +after( + async () => { + await releases?.close(); + for (const session of masters) await run("ssh", sshCloseArgs(session, sshEnv), { env: sshEnv }).catch(() => undefined); + if (started) await remote("down", "--all").catch((error) => console.error(error.stderr ?? error)); + if (controlDirectory) await rm(controlDirectory, { recursive: true, force: true }); + }, + { timeout: 5 * 60_000 }, +); + +function input(host: Host, change: Partial = {}) { + const { session, probe, target } = found.get(host)!; + const progress: ReviewRemoteInstallProgress[] = []; + const value: ReviewRemoteInstallInput = { + session, + probe, + target, + version: VERSION, + onProgress: (step) => progress.push(step), + signal: new AbortController().signal, + artifact: artifacts.get(target)!, + spawn: realSsh, + env: sshEnv, + ...change, + }; + return { value, progress }; +} + +const version = async (host: Host) => JSON.parse(await inContainer(host, "~/.local/bin/whiteboard version --json")).version; +const leftovers = async (host: Host) => (await inContainer(host, "cd ~/.dev/whiteboard-remote && find . -mindepth 1 -maxdepth 2 | sort")).split("\n"); +const installed = `/home/dev/.dev/whiteboard-remote/versions/${VERSION}`; + +test("a host with no Node downloads the tarball, with diffr inside it; whiteboard version prints it", { skip, timeout: 10 * 60_000 }, async () => { + const { value, progress } = input("fresh"); + const result = await installRemote(value); + + assert.deepEqual(result, { nodePath: `${installed}/bin/node`, cliPath: `${installed}/dist/cli.js` }); + assert.equal(await version("fresh"), VERSION); + assert.deepEqual(progress, [{ step: "package" }, { step: "done" }]); + assert.equal(await inContainer("fresh", "ls ~/.dev/whiteboard-remote/versions"), VERSION); + assert.match(await inContainer("fresh", `${installed}/node_modules/@dev.fast/diffr-linux-*/diffr --version`), /^diffr /); + + const again = input("fresh"); + assert.deepEqual(await installRemote(again.value), result); + assert.deepEqual(again.progress.map((p) => p.step), ["done"]); + assert.equal(await inContainer("fresh", "ls -d ~/.dev/whiteboard-remote/install.lock 2>/dev/null | wc -l"), "0"); +}); + +test("a tarball the host cannot download fails the install with the URL and leaves nothing", { skip, timeout: 10 * 60_000 }, async () => { + await reset("fresh"); + const { value } = input("fresh"); + const missing = { ...value.artifact, url: `${releases!.url}missing.tar.xz` }; + + await assert.rejects(installRemote({ ...value, artifact: missing }), /cannot download http:\/\/.*missing\.tar\.xz: curl exited with 22/); + + assert.deepEqual(await leftovers("fresh"), ["./versions"]); +}); + +test("an abort while the tarball unpacks leaves no version; the next install succeeds", { skip, timeout: 10 * 60_000 }, async () => { + await reset("fresh"); + const abort = new AbortController(); + const unpacking = () => inContainer("fresh", "pgrep -f '[t]ar -xJf' || true"); + let sawTar = false; + const { value } = input("fresh", { + signal: abort.signal, + onProgress: async (step) => { + if (step.step !== "package") return; + for (let i = 0; i < 300 && !abort.signal.aborted; i++) { + if (await unpacking()) { + sawTar = true; + abort.abort(); + } + await new Promise((r) => setTimeout(r, 50)); + } + }, + }); + + await assert.rejects(installRemote(value), { name: "AbortError" }); + assert.ok(sawTar); + + let tar = ""; + for (let i = 0; i < 50; i++) { + tar = await unpacking(); + if (!tar) break; + await new Promise((r) => setTimeout(r, 200)); + } + assert.equal(tar, ""); + const left = await leftovers("fresh"); + assert.ok(!left.includes(`./versions/${VERSION}`) && !left.includes("./install.lock"), left.join(" ")); + + await installRemote(input("fresh").value); + assert.equal(await version("fresh"), VERSION); +}); + +test("two installs at once: one waits, the tarball is downloaded once, both return the same path", { skip, timeout: 10 * 60_000 }, async () => { + await reset("fresh"); + const a = input("fresh"); + const b = input("fresh", { onProgress: (step) => a.progress.push(step), timeouts: { lockPoll: 500 } }); + + const [first, second] = await Promise.all([installRemote(a.value), installRemote(b.value)]); + + assert.deepEqual(first, second); + assert.equal(a.progress.filter((p) => p.step === "waiting-for-lock").length, 1); + assert.equal(a.progress.filter((p) => p.step === "package").length, 1); +}); + +test("a downloaded tarball with the wrong checksum fails the install and leaves nothing", { skip, timeout: 10 * 60_000 }, async () => { + await reset("fresh"); + const { value } = input("fresh"); + const wrong = { ...value.artifact, url: "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz" }; + + await assert.rejects(installRemote({ ...value, artifact: wrong }), /does not match its pinned checksum/); + + assert.deepEqual(await leftovers("fresh"), ["./versions"]); + assert.equal(await inContainer("fresh", "ls ~/.local/bin 2>/dev/null | wc -l"), "0"); +}); + +test("an existing ~/.local/bin/whiteboard that Desktop did not write is left alone", { skip, timeout: 10 * 60_000 }, async () => { + await reset("fresh"); + await inContainer("fresh", "mkdir -p ~/.local/bin && printf '#!/bin/sh\\necho mine\\n' > ~/.local/bin/whiteboard && chmod 755 ~/.local/bin/whiteboard"); + + await installRemote(input("fresh").value); + + assert.equal(await inContainer("fresh", "~/.local/bin/whiteboard"), "mine"); + assert.equal(JSON.parse(await inContainer("fresh", `${installed}/whiteboard version --json`)).version, VERSION); +}); + +test("with three versions installed and one running, an install leaves the running one and the newest", { skip, timeout: 10 * 60_000 }, async (t) => { + await reset("fresh"); + await installRemote(input("fresh").value); + await inContainer( + "fresh", + `cd ~/.dev/whiteboard-remote/versions && for v in ${VERSION}-old.1 ${VERSION}-old.2; do cp -a ${VERSION} $v && sed -i "s#/versions/${VERSION}/#/versions/$v/#g" $v/.whiteboard-install.json; done + old="$HOME/.dev/whiteboard-remote/versions/${VERSION}-old.1" + setsid nohup "$old/bin/node" -e 'setInterval(() => {}, 1000)' "$old/dist/cli.js" >/dev/null 2>&1 < /dev/null &`, + ); + // The bracket keeps pkill from matching its own shell. + t.after(() => inContainer("fresh", `pkill -f '[${VERSION[0]}]${VERSION.slice(1)}-old.1/' || true`)); + await inContainer("fresh", `rm -rf ${installed}`); + + await installRemote(input("fresh").value); + + assert.equal(await inContainer("fresh", "ls ~/.dev/whiteboard-remote/versions | tr '\\n' ' '"), `${VERSION} ${VERSION}-old.1`); +}); diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.test.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.test.ts new file mode 100644 index 000000000..e2b6159d8 --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.test.ts @@ -0,0 +1,545 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; + +import type { SpawnSsh } from "./reviewRemoteHost.js"; +import { probeRemote } from "./reviewRemoteProbe.js"; +import { uninstallRemote } from "./reviewRemoteUninstall.js"; +import { compareVersions, installRemote, type ReviewRemoteInstallInput, type ReviewRemoteInstallProgress } from "./reviewRemoteInstaller.js"; +import { cleanupScript, lockScript, REVIEW_REMOTE_INSTALL_MARKER, REVIEW_REMOTE_WRAPPER_MARK, shellQuote } from "./reviewRemoteInstallScript.js"; +import type { ReviewRemoteProbe } from "./reviewRemoteProbe.js"; +import { reviewSshSession } from "./reviewSshCommand.js"; + +const VERSION = "9.9.9"; +const PATH = `${dirname(process.execPath)}:/usr/bin:/bin:/usr/sbin:/sbin`; + +const localRemote = + (home: string, extra: NodeJS.ProcessEnv = {}): SpawnSsh => + (args, options) => { + const command = args.slice(args.indexOf("--") + 2).join(" "); + const env = { HOME: home, PATH, ...extra }; + return spawn("/bin/sh", ["-c", `${command}; exit $?`], { ...options, env }); + }; + +function recording(inner: SpawnSsh) { + const argv: string[][] = []; + const spawn: SpawnSsh = (args, options) => { + argv.push([...args]); + return inner(args, options); + }; + return { spawn, argv }; +} + +async function tarball(dir: string, cli: string, launcher = true): Promise<{ file: string; sha256: string }> { + const source = join(dir, `source-${createHash("sha256").update(`${cli}${launcher}`).digest("hex").slice(0, 8)}`); + const tree = join(source, "whiteboard-remote"); + await mkdir(join(tree, "bin"), { recursive: true }); + await mkdir(join(tree, "dist"), { recursive: true }); + await writeFile(join(tree, "bin", "node"), `#!/bin/sh\nexec ${shellQuote(process.execPath)} "$@"\n`, { mode: 0o755 }); + await writeFile(join(tree, "dist", "cli.js"), cli); + if (launcher) await writeFile(join(tree, "whiteboard"), `#!/bin/sh\nd=$(dirname "$0")\nexec "$d/bin/node" "$d/dist/cli.js" "$@"\n`, { mode: 0o755 }); + const file = join(source, "whiteboard-remote.tar.xz"); + execFileSync("tar", ["-cJf", file, "-C", source, "whiteboard-remote"], { env: { ...process.env, COPYFILE_DISABLE: "1" } }); + return { file, sha256: createHash("sha256").update(await readFile(file)).digest("hex") }; +} + +const cliFor = (version: string) => `const [a, b] = process.argv.slice(2); +if (a === "version") console.log(JSON.stringify({ event: "version", version: "${version}" })); +else console.log("ran " + process.argv.slice(2).join(" ")); +`; +const CLI = cliFor(VERSION); + +async function fixture(t: test.TestContext, cli = CLI) { + const root = await mkdtemp(join(tmpdir(), "wb-install-")); + t.after(() => rm(root, { recursive: true, force: true })); + const home = join(root, "a home"); + await mkdir(home); + const pack = await tarball(root, cli); + const served = join(root, "served.tar.xz"); + await writeFile(served, await readFile(pack.file)); + const env = await fakeCurl(root, served); + const probe: ReviewRemoteProbe = { + os: "Linux", + arch: "aarch64", + glibc: "2.35", + home, + root: join(home, ".dev", "whiteboard-remote"), + homeWritable: true, + freeBytes: 20e9, + installed: [], + pathCli: null, + downloader: "curl", + releasesReachable: true, + tools: ["tar", "xz", "sha256sum", "openssl"], + }; + const progress: ReviewRemoteInstallProgress[] = []; + const artifact = { name: `whiteboard-remote-${VERSION}-linux-arm64.tar.xz`, url: RELEASE_URL, sha256: pack.sha256 }; + const input = (change: Partial = {}): ReviewRemoteInstallInput => ({ + session: reviewSshSession("devbox", tmpdir(), "test"), + probe, + target: "linux-arm64", + version: VERSION, + onProgress: (step) => progress.push(step), + signal: new AbortController().signal, + artifact, + spawn: localRemote(home, env), + env: {}, + owner: "laptop-test", + timeouts: { lockPoll: 50 }, + ...change, + }); + const remoteRoot = join(home, ".dev", "whiteboard-remote"); + const launcher = join(remoteRoot, "versions", VERSION, "whiteboard"); + /** Serves `bytes` from the fake curl and returns their sha256. */ + const serve = async (bytes: Buffer | string) => { + await writeFile(served, bytes); + return createHash("sha256").update(bytes).digest("hex"); + }; + return { root, home, env, pack, artifact, probe, progress, input, serve, remoteRoot, launcher, versions: () => readdir(join(remoteRoot, "versions")).catch((): string[] => []) }; +} + +const run = (file: string, ...args: string[]) => execFileSync(file, args, { encoding: "utf8" }).trim(); + +/** Every fake in `bin` (default: the fixture's) is on the returned PATH, ahead of the fixture's. */ +async function onPath(root: string, name: string, script: string, bin = join(root, "fake-bin")): Promise { + await mkdir(bin, { recursive: true }); + await writeFile(join(bin, name), `#!/bin/sh\n${script}`, { mode: 0o755 }); + return { PATH: `${bin}:${join(root, "fake-bin")}:${PATH}` }; +} + +const slowTar = (root: string, body: string) => onPath(root, "tar", `case "$1" in -xJf) ${body} ;; esac\nexec /usr/bin/tar "$@"\n`, join(root, "slow-bin")); + +/** A curl that answers every URL with `source`. */ +const fakeCurl = (root: string, source: string) => + onPath(root, "curl", `while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; url=$1; shift; done\nprintf '%s\\n' "$url" >> ${shellQuote(join(root, "fetched"))}\nexec cp ${shellQuote(source)} "$out"\n`); + +const RELEASE_URL = `https://install.dev.fast/releases/${VERSION}/remote/whiteboard-remote-${VERSION}-linux-arm64.tar.xz`; + +test("a DEV_REVIEW_HOME the remote cannot normalise is refused by the probe and the uninstall, before anything is written", async (t) => { + const f = await fixture(t); + for (const home of ["relative/home", `${f.root}/a/../b`, `${f.root}//b`, `${f.root}/./b`, `${f.root}/a\nb`, `${f.root}/a\tb`]) { + const spawn = localRemote(f.home, { ...f.env, DEV_REVIEW_HOME: home }); + const probed = await probeRemote({ session: reviewSshSession("devbox", tmpdir(), "test"), spawn, env: {} }); + assert.match("error" in probed ? probed.error : "", /DEV_REVIEW_HOME there is not an absolute, normalised path/, home); + await assert.rejects(uninstallRemote({ session: reviewSshSession("devbox", tmpdir(), "test"), spawn, env: {} }), /DEV_REVIEW_HOME there is not an absolute, normalised path/); + } + assert.deepEqual(await readdir(f.home), []); +}); + +test("DEV_REVIEW_HOME moves the probe's versions, the install, its launcher and the uninstall together", async (t) => { + const uninstalled = `else if (a === "remote" && b === "uninstall") console.log(JSON.stringify({ event: "remote.uninstall", ok: true, removed: [], keptReviews: true }));\n`; + const f = await fixture(t, CLI.replace("else console.log", `${uninstalled}else console.log`)); + const moved = join(f.root, "moved home"); + const spawn = localRemote(f.home, { ...f.env, DEV_REVIEW_HOME: ` ${moved}/ ` }); + const probed = async () => { + const result = await probeRemote({ session: reviewSshSession("devbox", tmpdir(), "test"), spawn, env: {} }); + assert.ok("probe" in result, "error" in result ? result.error : ""); + return result.probe; + }; + const root = join(moved, "whiteboard-remote"); + + const before = await probed(); + assert.equal(before.root, root); + const result = await installRemote(f.input({ probe: { ...f.probe, root: before.root }, spawn })); + + assert.ok(result.cliPath.startsWith(`${root}/versions/${VERSION}/`), result.cliPath); + assert.equal(run(`${root}/versions/${VERSION}/whiteboard`, "version", "--json"), `{"event":"version","version":"${VERSION}"}`); + assert.deepEqual((await probed()).installed, [{ version: VERSION, sha256: f.pack.sha256 }]); + await uninstallRemote({ session: reviewSshSession("devbox", tmpdir(), "test"), spawn, env: {} }); + assert.deepEqual(await readdir(f.home), [".local"]); +}); + +test("the host downloads and installs the tarball, its launcher and ~/.local/bin/whiteboard; no -R is ever spawned; a second call finds it complete", async (t) => { + const f = await fixture(t); + for (const version of ["1.0.0", "2.0.0"]) await earlierVersion(f.remoteRoot, version); + const ssh = recording(localRemote(f.home, f.env)); + + const result = await installRemote(f.input({ spawn: ssh.spawn })); + + const dir = join(f.remoteRoot, "versions", VERSION); + assert.deepEqual(result, { cliPath: join(dir, "dist/cli.js"), nodePath: join(dir, "bin/node") }); + assert.equal(await readFile(join(f.root, "fetched"), "utf8"), `${RELEASE_URL}\n`); + assert.ok(ssh.argv.every((args) => !args.includes("-R")), JSON.stringify(ssh.argv)); + assert.deepEqual(f.progress, [{ step: "package" }, { step: "done" }]); + assert.equal(run(f.launcher, "version", "--json"), `{"event":"version","version":"${VERSION}"}`); + const wrapper = join(f.home, ".local/bin/whiteboard"); + assert.ok((await readFile(wrapper, "utf8")).includes(REVIEW_REMOTE_WRAPPER_MARK)); + assert.equal(run(wrapper, "a b"), "ran a b"); + assert.deepEqual((await readdir(dir)).sort(), [REVIEW_REMOTE_INSTALL_MARKER, "bin", "dist", "whiteboard"]); + assert.deepEqual((await readdir(f.remoteRoot)).sort(), ["versions"]); + assert.deepEqual((await f.versions()).sort(), ["2.0.0", VERSION]); + + f.progress.length = 0; + assert.deepEqual(await installRemote(f.input()), result); + assert.deepEqual(f.progress, [{ step: "done" }]); +}); + +test("a download that fails on the host fails the install with curl's exit status and leaves no work directory", async (t) => { + const f = await fixture(t); + const spawn = localRemote(f.home, await onPath(f.root, "curl", "exit 22\n")); + + await assert.rejects(installRemote(f.input({ spawn })), new RegExp(`while downloading and unpacking .*: cannot download ${RELEASE_URL.replaceAll(".", "\\.")}: curl exited with 22\\.$`)); + + assert.deepEqual(await f.versions(), []); + assert.deepEqual((await readdir(f.remoteRoot)).sort(), ["versions"]); +}); + +test("a wget download that outlasts its time bound fails the install", async (t) => { + const f = await fixture(t); + await onPath(f.root, "wget", `[ "$1" = --help ] && exit 0\nexec sleep 30\n`); + const path = await onPath(f.root, "timeout", `shift\n"$@" &\nsleep 1\nkill $! 2>/dev/null\nexit 124\n`); + const started = Date.now(); + + await assert.rejects(installRemote(f.input({ spawn: localRemote(f.home, path), probe: { ...f.probe, downloader: "wget" } })), /cannot download .*: wget exited with 124/); + + assert.ok(Date.now() - started < 25_000, `${Date.now() - started} ms`); + assert.deepEqual(await f.versions(), []); +}); + +test("a downloaded tarball that does not match fails the install and its work directory is removed", async (t) => { + const f = await fixture(t); + await f.serve("not the tarball"); + + await assert.rejects(installRemote(f.input()), /does not match its pinned checksum; it was removed/); + + assert.deepEqual(f.progress, [{ step: "package" }]); + assert.deepEqual(await f.versions(), []); + assert.deepEqual((await readdir(f.remoteRoot)).sort(), ["versions"]); +}); + +test("a host where no checksum tool answers fails the install and says so", async (t) => { + const f = await fixture(t); + await onPath(f.root, "sha256sum", "exit 1\n"); + await onPath(f.root, "openssl", "exit 1\n"); + + await assert.rejects(installRemote(f.input()), /does not match its pinned checksum \(no checksum tool answered\); it was removed/); + assert.deepEqual(await f.versions(), []); +}); + +test("a tarball whose whiteboard reports another version fails the install", async (t) => { + const f = await fixture(t, cliFor("1.2.3")); + + await assert.rejects(installRemote(f.input()), /The tarball installed on devbox reports version 1\.2\.3, not 9\.9\.9/); + assert.ok(!(await f.versions()).includes(VERSION)); +}); + +test("a tarball without its launcher fails the install", async (t) => { + const f = await fixture(t); + const bare = await tarball(f.root, CLI, false); + const sha256 = await f.serve(await readFile(bare.file)); + + await assert.rejects(installRemote(f.input({ artifact: { ...f.artifact, sha256 } })), /the tarball holds no whiteboard/); + assert.deepEqual(await f.versions(), []); +}); + +test("two installs at once: one waits, the tarball is downloaded once, both get the same path", async (t) => { + const f = await fixture(t); + + const [a, b] = await Promise.all([installRemote(f.input()), installRemote(f.input())]); + + assert.deepEqual(a, b); + assert.equal(f.progress.filter((p) => p.step === "waiting-for-lock").length, 1); + assert.equal(f.progress.filter((p) => p.step === "package").length, 1); + assert.deepEqual(await f.versions(), [VERSION]); +}); + +test("another tarball under the same version is installed again", async (t) => { + const f = await fixture(t); + await installRemote(f.input()); + const other = await tarball(f.root, `${CLI}// another build\n`); + const sha256 = await f.serve(await readFile(other.file)); + f.progress.length = 0; + + const result = await installRemote(f.input({ artifact: { ...f.artifact, sha256 } })); + + assert.ok(f.progress.some((p) => p.step === "package")); + assert.match(await readFile(result.cliPath, "utf8"), /another build/); + assert.equal(JSON.parse(await readFile(join(dirname(f.launcher), REVIEW_REMOTE_INSTALL_MARKER), "utf8")).sha256, other.sha256); + assert.deepEqual(await f.versions(), [VERSION]); +}); + +test("an existing ~/.local/bin/whiteboard that Desktop did not write is left alone", async (t) => { + const f = await fixture(t); + const wrapper = join(f.home, ".local/bin/whiteboard"); + await mkdir(dirname(wrapper), { recursive: true }); + await writeFile(wrapper, "#!/bin/sh\necho mine\n", { mode: 0o755 }); + + await installRemote(f.input()); + + assert.equal(await readFile(wrapper, "utf8"), "#!/bin/sh\necho mine\n"); + assert.equal(run(f.launcher, "version", "--json"), `{"event":"version","version":"${VERSION}"}`); +}); + +async function earlierVersion(remoteRoot: string, version: string): Promise { + const dir = join(remoteRoot, "versions", version); + await mkdir(join(dir, "dist"), { recursive: true }); + await writeFile(join(dir, "dist/cli.js"), ""); + await writeFile(join(dir, REVIEW_REMOTE_INSTALL_MARKER), JSON.stringify({ version, sha256: "0".repeat(64), node: process.execPath, cli: join(dir, "dist/cli.js") })); + return dir; +} + +test("after an install, versions other than the newest two are removed, and a version left without its marker", async (t) => { + const f = await fixture(t); + for (const version of ["1.0.0", "2.0.0", "2.0.0-preview.1", "10.0.0-preview.3"]) await earlierVersion(f.remoteRoot, version); + await mkdir(join(f.remoteRoot, "versions", "notes")); + await mkdir(join(f.remoteRoot, "versions", "3.0.0", "dist"), { recursive: true }); + + await installRemote(f.input()); + + assert.deepEqual((await f.versions()).sort(), ["10.0.0-preview.3", VERSION, "notes"].sort()); + assert.deepEqual((await readdir(f.remoteRoot)).sort(), ["versions"]); +}); + +test("a version without a sha256 marker is removed, and Desktop's wrapper moves to the new version", async (t) => { + const f = await fixture(t); + const old = join(f.remoteRoot, "versions", "10.0.0"); + const cli = join(old, "dist/cli.js"); + await mkdir(dirname(cli), { recursive: true }); + await writeFile(cli, cliFor("10.0.0")); + await writeFile(join(old, "whiteboard"), `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(cli)} "$@"\n`, { mode: 0o755 }); + await writeFile(join(old, REVIEW_REMOTE_INSTALL_MARKER), JSON.stringify({ version: "10.0.0", integrity: `sha512-${"A".repeat(86)}==`, node: process.execPath, cli, installedAt: 0 })); + const wrapper = join(f.home, ".local/bin/whiteboard"); + await mkdir(dirname(wrapper), { recursive: true }); + await writeFile(wrapper, `#!/bin/sh\n${REVIEW_REMOTE_WRAPPER_MARK}\nexec ${shellQuote(join(old, "whiteboard"))} "$@"\n`, { mode: 0o755 }); + + await installRemote(f.input()); + + assert.deepEqual(await f.versions(), [VERSION]); + assert.equal(run(wrapper, "version", "--json"), `{"event":"version","version":"${VERSION}"}`); +}); + +test("with three versions installed and one running, cleanup leaves the running one and the newest", async (t) => { + const f = await fixture(t); + const running = await earlierVersion(f.remoteRoot, "1.0.0"); + await earlierVersion(f.remoteRoot, "2.0.0"); + const server = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", join(running, "dist/cli.js"), "server", "start"], { stdio: "ignore" }); + t.after(() => server.kill("SIGKILL")); + + await installRemote(f.input()); + + assert.deepEqual((await f.versions()).sort(), ["1.0.0", VERSION]); + assert.equal(server.exitCode, null); + + server.kill("SIGKILL"); + await new Promise((resolve) => server.once("exit", resolve)); + await earlierVersion(f.remoteRoot, "2.0.0"); + await rm(join(f.remoteRoot, "versions", VERSION), { recursive: true }); + await installRemote(f.input()); + assert.deepEqual((await f.versions()).sort(), ["2.0.0", VERSION]); +}); + +test("an install of an older version keeps itself and the newest", async (t) => { + const f = await fixture(t); + for (const version of ["10.0.0", "11.0.0", "12.0.0"]) await earlierVersion(f.remoteRoot, version); + + await installRemote(f.input()); + + assert.deepEqual((await f.versions()).sort(), ["12.0.0", VERSION]); +}); + +test("an abort while the tarball unpacks ends it, leaves no version and no lock, and the next install succeeds", async (t) => { + const f = await fixture(t); + const seconds = `47.${Math.floor(Math.random() * 1000)}1`; + const path = await slowTar(f.root, `touch "$HOME/unpack-started"; exec sleep ${seconds}`); + const abort = new AbortController(); + const started = join(f.home, "unpack-started"); + const watch = setInterval(() => void readFile(started).then(() => abort.abort(), () => {}), 50); + t.after(() => clearInterval(watch)); + + await assert.rejects(installRemote(f.input({ spawn: localRemote(f.home, path), signal: abort.signal })), { name: "AbortError" }); + + let alive = ""; + for (let i = 0; i < 50; i++) { + alive = spawnText("pgrep", ["-f", `sleep ${seconds}`]); + if (!alive) break; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.equal(alive, ""); + assert.ok(!(await f.versions()).includes(VERSION)); + assert.ok(!(await readdir(f.remoteRoot)).includes("install.lock")); + + await installRemote(f.input()); + assert.equal(run(f.launcher, "version", "--json"), `{"event":"version","version":"${VERSION}"}`); + assert.deepEqual(await f.versions(), [VERSION]); +}); + +function spawnText(command: string, args: string[]): string { + try { + return execFileSync(command, args, { encoding: "utf8" }).trim(); + } catch { + return ""; + } +} + +test("a stale lock is taken over; a fresh one is waited for, within the bound", async (t) => { + const f = await fixture(t); + const lock = join(f.remoteRoot, "install.lock"); + await mkdir(lock, { recursive: true }); + await writeFile(join(lock, "token"), "0123456789abcdef\n"); + await writeFile(join(lock, "owner"), "gone-laptop\n"); + await writeFile(join(lock, "started"), `${Math.floor(Date.now() / 1000) - 16 * 60}\n`); + + const installed = await installRemote(f.input()); + assert.ok(!(await readdir(f.remoteRoot)).includes("install.lock")); + + await mkdir(lock); + await writeFile(join(lock, "token"), "0123456789abcdef\n"); + await writeFile(join(lock, "started"), `${Math.floor(Date.now() / 1000)}\n`); + f.progress.length = 0; + assert.deepEqual(await installRemote(f.input({ timeouts: { lockPoll: 50, lockWait: 400 } })), installed); + assert.deepEqual(f.progress, [{ step: "done" }]); + await rm(lock, { recursive: true }); + + await rm(join(f.remoteRoot, "versions"), { recursive: true }); + await mkdir(lock); + await writeFile(join(lock, "token"), "0123456789abcdef\n"); + await writeFile(join(lock, "owner"), "busy-laptop\n"); + await writeFile(join(lock, "started"), `${Math.floor(Date.now() / 1000)}\n`); + f.progress.length = 0; + + await assert.rejects(installRemote(f.input({ timeouts: { lockPoll: 50, lockWait: 400 } })), /Another install on devbox \(busy-laptop\) held the lock/); + assert.deepEqual(f.progress, [{ step: "waiting-for-lock" }]); + assert.equal(await readFile(join(lock, "token"), "utf8"), "0123456789abcdef\n"); +}); + +function sh(home: string, script: string): Promise { + return new Promise((resolve, reject) => { + const child = localRemote(home)(["--", "devbox", "sh", "-s"], { env: {}, detached: true, stdio: ["pipe", "pipe", "pipe"] }); + let out = ""; + child.stdout!.setEncoding("utf8").on("data", (chunk: string) => (out += chunk)); + child.once("error", reject); + child.once("close", () => resolve(out)); + child.stdin!.end(script); + }); +} + +test("shellQuote survives quotes and refuses control characters", async () => { + for (const value of ["plain", "it's", "a 'b' \"c\" $d `e` \\f", "'", ""]) { + assert.equal(run("/bin/sh", "-c", `printf '%s' ${shellQuote(value)}`), value); + } + assert.throws(() => shellQuote("a\nb"), /control character/); + assert.throws(() => shellQuote("a\u0007b"), /control character/); +}); + +test("versions order as semver", () => { + const sorted = ["0.1.10", "0.1.7", "0.1.7-preview.20261003.10", "0.1.6", "0.1.7-preview.20261003.2", "0.2.0-preview.1"].sort(compareVersions); + assert.deepEqual(sorted, ["0.1.6", "0.1.7-preview.20261003.2", "0.1.7-preview.20261003.10", "0.1.7", "0.1.10", "0.2.0-preview.1"]); +}); + +test("a lock taken over between the stealer's two reads is left to its new holder", async (t) => { + const f = await fixture(t); + const lock = join(f.remoteRoot, "install.lock"); + await mkdir(lock, { recursive: true }); + await writeFile(join(lock, "token"), "0123456789abcdef\n"); + await writeFile(join(lock, "started"), "0\n"); + const bin = join(f.root, "racing-bin"); + await mkdir(bin); + await writeFile( + join(bin, "cat"), + `#!/bin/sh +/bin/cat "$@" +case "$1" in */install.lock/*) + [ -e "$RACED" ] && exit 0 + : > "$RACED" + rm -rf "$LOCK" && mkdir "$LOCK" && echo fedcba9876543210 > "$LOCK/token" && echo winner > "$LOCK/owner" && date +%s > "$LOCK/started" ;; +esac +`, + { mode: 0o755 }, + ); + + const out = await new Promise((resolve, reject) => { + const child = spawn("/bin/sh", ["-s"], { + env: { HOME: f.home, LOCK: lock, RACED: join(f.root, "raced"), PATH: `${bin}:/usr/bin:/bin:/usr/sbin:/sbin` }, + stdio: ["pipe", "pipe", "inherit"], + }); + let text = ""; + child.stdout.setEncoding("utf8").on("data", (chunk: string) => (text += chunk)); + child.once("error", reject); + child.once("close", () => resolve(text)); + child.stdin.end(lockScript({ home: f.home, root: f.remoteRoot, token: "00112233aabbccdd" }, "stealer")); + }); + + assert.match(out, /WHITEBOARD-INSTALL BUSY winner/); + assert.equal(await readFile(join(lock, "token"), "utf8"), "fedcba9876543210\n"); +}); + +test("a long unpack keeps the lock fresh, so a second install waits", async (t) => { + const f = await fixture(t); + const timeouts = { lockPoll: 100, lockStale: 3000 }; + const first = installRemote(f.input({ timeouts, spawn: localRemote(f.home, await slowTar(f.root, "sleep 5")) })); + await new Promise((resolve) => setTimeout(resolve, 500)); + const second = installRemote(f.input({ timeouts })); + + const [a, b] = await Promise.all([first, second]); + + assert.deepEqual(a, b); + assert.deepEqual( + f.progress.filter((p) => p.step !== "done"), + [{ step: "package" }, { step: "waiting-for-lock" }], + ); +}); + +test("a marker whose sha256 only contains the pin is not complete", async (t) => { + const f = await fixture(t); + await installRemote(f.input()); + const marker = join(dirname(f.launcher), REVIEW_REMOTE_INSTALL_MARKER); + const text = await readFile(marker, "utf8"); + await writeFile(marker, text.replace(f.pack.sha256, `${f.pack.sha256}x`)); + f.progress.length = 0; + + await installRemote(f.input()); + + assert.ok(f.progress.some((p) => p.step === "package")); + assert.equal(JSON.parse(await readFile(marker, "utf8")).sha256, f.pack.sha256); +}); + +test("a root with a quote is refused before anything runs", async (t) => { + const f = await fixture(t); + + await assert.rejects(installRemote(f.input({ probe: { ...f.probe, root: '/opt/wh"ite/whiteboard-remote' } })), /Whiteboard cannot install on devbox: .* holds a quote or backslash/); + assert.deepEqual(await readdir(f.home), []); +}); + +test("a read of the lock's start time during refreshes never finds it empty", async (t) => { + const f = await fixture(t); + const context = { home: f.home, root: f.remoteRoot, token: "00112233aabbccdd" }; + assert.match(await sh(f.home, lockScript(context, "me")), /LOCKED/); + const started = join(f.remoteRoot, "install.lock", "started"); + const refreshes = sh(f.home, cleanupScript(context, { candidates: [], room: 0 }).replace("own\n", "i=0\nwhile [ $i -lt 400 ]; do own; i=$((i + 1)); done\n")); + let done = false; + void refreshes.then(() => (done = true)); + + let reads = 0; + const bad: string[] = []; + while (!done) { + const text = await readFile(started, "utf8").catch((error: NodeJS.ErrnoException) => error.code ?? "error"); + reads++; + if (!/^\d+\n$/.test(text)) bad.push(JSON.stringify(text)); + } + + assert.match(await refreshes, /CLEANED/); + assert.ok(reads > 50, `${reads} reads`); + assert.deepEqual(bad, []); +}); + +test("a lock whose start time cannot be read counts as fresh", async (t) => { + const f = await fixture(t); + const lock = join(f.remoteRoot, "install.lock"); + await mkdir(lock, { recursive: true }); + await writeFile(join(lock, "token"), "0123456789abcdef\n"); + await writeFile(join(lock, "owner"), "busy-laptop\n"); + await writeFile(join(lock, "started"), ""); + execFileSync("touch", ["-t", "202001010000", lock]); + + assert.match(await sh(f.home, lockScript({ home: f.home, root: f.remoteRoot, token: "00112233aabbccdd" }, "me")), /BUSY busy-laptop/); + assert.equal(await readFile(join(lock, "token"), "utf8"), "0123456789abcdef\n"); +}); diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.ts new file mode 100644 index 000000000..eb27645ca --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.ts @@ -0,0 +1,279 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { randomBytes } from "node:crypto"; +import { hostname } from "node:os"; + +import type { ReviewRemoteArtifact } from "./reviewRemoteArtifacts.js"; +import { lastLine, runSsh, type RunResult, type SpawnSsh, type SshChildProcess } from "./reviewRemoteHost.js"; +import { + cleanupScript, + completeScript, + finishScript, + lockScript, + prepareScript, + releaseScript, + REVIEW_REMOTE_INSTALL_SAY, + REVIEW_REMOTE_LOCK_STALE_SECONDS, + REVIEW_REMOTE_VERSION, + REVIEW_REMOTE_WRAPPER_MARK, + reviewRemoteVersionDir, + shellQuote, + unpackScript, + type ReviewRemoteInstallContext, +} from "./reviewRemoteInstallScript.js"; +import { missingInstallTools, type ReviewRemoteProbe, type ReviewRemoteTarget } from "./reviewRemoteProbe.js"; +import { sshExecArgs, type ReviewSshSession } from "./reviewSshCommand.js"; + +export interface ReviewRemoteInstallProgress { + step: "waiting-for-lock" | "package" | "done"; +} + +const REVIEW_REMOTE_INSTALL_TIMEOUTS = { + step: 30_000, + fetch: 25 * 60_000, + lockWait: REVIEW_REMOTE_LOCK_STALE_SECONDS * 1000, + lockStale: REVIEW_REMOTE_LOCK_STALE_SECONDS * 1000, + lockPoll: 2_000, + release: 15_000, +}; + +export interface ReviewRemoteInstallInput { + readonly session: ReviewSshSession; + readonly probe: ReviewRemoteProbe; + readonly target: ReviewRemoteTarget; + readonly version: string; + onProgress(progress: ReviewRemoteInstallProgress): void; + readonly signal: AbortSignal; + readonly artifact: ReviewRemoteArtifact; + readonly spawn: SpawnSsh; + readonly env: NodeJS.ProcessEnv; + readonly owner?: string; + readonly timeouts?: Partial; +} + +export interface ReviewRemoteInstallResult { + readonly cliPath: string; + readonly nodePath: string; +} + +export async function installRemote(input: ReviewRemoteInstallInput): Promise { + const { session, probe, signal, artifact } = input; + const timeouts = { ...REVIEW_REMOTE_INSTALL_TIMEOUTS, ...input.timeouts }; + const alias = session.alias; + signal.throwIfAborted(); + // The marker is JSON read back with sed: no quote or backslash in any path it holds. + for (const path of [probe.home, probe.root]) { + if (/['"\\]/.test(path)) throw new Error(`Whiteboard cannot install on ${alias}: ${JSON.stringify(path)} holds a quote or backslash.`); + } + if (!REVIEW_REMOTE_VERSION.test(input.version)) throw new Error(`${JSON.stringify(input.version)} is not a version.`); + const { sha256 } = artifact; + if (!/^[0-9a-f]{64}$/.test(sha256)) throw new Error(`${artifact.name} has no sha256.`); + if (!artifact.name.endsWith(`-${input.target}.tar.xz`)) throw new Error(`${artifact.name} is not the tarball for ${input.target}.`); + + const context: ReviewRemoteInstallContext = { home: probe.home, root: probe.root, token: randomBytes(8).toString("hex") }; + const owner = (input.owner ?? hostname()).replace(/[^\w.-]/g, "-").slice(0, 64) || "unknown"; + + const children = new Set(); + const tracked: SpawnSsh = (args, options) => { + const child = input.spawn(args, options); + children.add(child); + child.once("close", () => children.delete(child)); + return child; + }; + const onAbort = () => { + for (const child of children) { + child.kill("SIGKILL"); + child.stdout?.destroy(); + child.stderr?.destroy(); + } + }; + signal.addEventListener("abort", onAbort, { once: true }); + + const run = async (what: string, script: string, timeout = timeouts.step): Promise => { + signal.throwIfAborted(); + const result = await runSsh(tracked, input.env, sshExecArgs(session, input.env), timeout, script); + signal.throwIfAborted(); + return answer(alias, what, result, timeout); + }; + + let locking = false; + try { + let result = await alreadyComplete(); + if (!result) { + const missing = missingInstallTools(probe, alias); + if (missing || !probe.downloader) throw new Error(missing); + locking = true; + await takeLock(); + result = await install(probe.downloader); + } + input.onProgress({ step: "done" }); + return result; + } finally { + signal.removeEventListener("abort", onAbort); + if (locking) await runSsh(input.spawn, input.env, sshExecArgs(session, input.env), timeouts.release, releaseScript(context)); + } + + async function alreadyComplete(): Promise { + const checked = await run("checking the installed version", completeScript(context, { version: input.version, sha256 })).catch((error: unknown) => { + if (signal.aborted) throw error; + return undefined; + }); + return checked?.has("COMPLETE") ? readMarker(checked.get("MARKER")) : undefined; + } + + async function takeLock(): Promise { + const deadline = Date.now() + timeouts.lockWait; + let told = false; + for (;;) { + const says = await run("taking the install lock", lockScript(context, owner, Math.ceil(timeouts.lockStale / 1000))); + if (says.has("LOCKED")) return; + const holder = says.get("BUSY"); + if (holder === undefined) throw says.failure(); + if (Date.now() >= deadline) { + throw new Error(`Another install on ${alias} (${holder || "unknown"}) held the lock for ${timeouts.lockWait / 60_000} minutes.`); + } + if (!told) input.onProgress({ step: "waiting-for-lock" }); + told = true; + await delay(timeouts.lockPoll, signal); + } + } + + async function install(downloader: "curl" | "wget"): Promise { + const prepared = await run("preparing", prepareScript(context, { version: input.version, sha256 })); + if (!prepared.has("PREPARED")) throw prepared.failure(); + if (prepared.has("COMPLETE")) { + const marker = readMarker(prepared.get("MARKER")); + if (!marker) throw new Error(`The install marker on ${alias} for ${input.version} cannot be read.`); + return marker; + } + + input.onProgress({ step: "package" }); + const unpacked = await run( + `downloading and unpacking ${artifact.name}`, + unpackScript(context, { version: input.version, sha256, url: artifact.url, downloader }), + timeouts.fetch, + ); + const mismatch = unpacked.get("MISMATCH"); + if (mismatch !== undefined) { + throw new Error(`The tarball on ${alias} does not match its pinned checksum${mismatch ? "" : " (no checksum tool answered)"}; it was removed.`); + } + if (!unpacked.has("UNPACKED")) throw unpacked.failure(); + const reported = parseVersion(unpacked.get("VERSION")); + if (reported !== input.version) { + throw new Error(`The tarball installed on ${alias} reports version ${reported ?? "nothing"}, not ${input.version}.`); + } + + const dir = reviewRemoteVersionDir(probe.root, input.version); + const result = { nodePath: `${dir}/bin/node`, cliPath: `${dir}/dist/cli.js` }; + const have = prepared.all("HAVE").filter((name) => REVIEW_REMOTE_VERSION.test(name)); + const newest = [input.version, ...have].sort(compareVersions).at(-1); + const finished = await run( + "finishing", + finishScript(context, { + version: input.version, + marker: JSON.stringify({ version: input.version, sha256, node: result.nodePath, cli: result.cliPath, installedAt: Math.floor(Date.now() / 1000) }), + wrapper: newest === input.version ? `#!/bin/sh\n${REVIEW_REMOTE_WRAPPER_MARK}\nexec ${shellQuote(`${dir}/whiteboard`)} "$@"\n` : undefined, + }), + ); + if (!finished.has("COMPLETE")) throw finished.failure(); + + const keep = new Set([input.version, newest]); + const candidates = have.filter((name) => !keep.has(name)).sort(compareVersions).reverse(); + if (candidates.length) { + await run("removing old versions", cleanupScript(context, { candidates, room: 2 - keep.size })).catch((error: unknown) => { + if (signal.aborted) throw error; + }); + } + return result; + } +} + +interface Answer { + has(word: string): boolean; + get(word: string): string | undefined; + all(word: string): string[]; + failure(): Error; +} + +function answer(alias: string, what: string, result: RunResult, timeout: number): Answer { + const says = result.stdout.split("\n").flatMap((line) => { + if (!line.startsWith(`${REVIEW_REMOTE_INSTALL_SAY} `)) return []; + const rest = line.slice(REVIEW_REMOTE_INSTALL_SAY.length + 1); + const space = rest.indexOf(" "); + return [space < 0 ? [rest, ""] : [rest.slice(0, space), rest.slice(space + 1)]]; + }); + const all = (word: string) => says.filter(([said]) => said === word).map(([, value]) => value); + return { + has: (word) => all(word).length > 0, + get: (word) => all(word).at(-1), + all, + failure() { + const reason = all("FAIL").at(-1); + if (reason) return new Error(`Installing on ${alias} failed while ${what}: ${reason}.`); + if (result.timedOut) return new Error(`Installing on ${alias} failed: ${what} took longer than ${timeout / 1000} seconds.`); + if (result.error) return new Error(`Installing on ${alias} failed while ${what}: ${result.error.message}`); + const stderr = lastLine(result.stderr); + return new Error(`Installing on ${alias} failed while ${what}: exit ${result.code}${stderr ? `: ${stderr}` : ""}.`); + }, + }; +} + +function readMarker(text: string | undefined): ReviewRemoteInstallResult | undefined { + try { + const marker = JSON.parse(text ?? "") as { node?: unknown; cli?: unknown }; + if (typeof marker.node === "string" && typeof marker.cli === "string") return { nodePath: marker.node, cliPath: marker.cli }; + } catch { + } + return undefined; +} + +function parseVersion(text: string | undefined): string | undefined { + try { + const value = JSON.parse(text ?? "") as { version?: unknown }; + return typeof value.version === "string" ? value.version : undefined; + } catch { + return undefined; + } +} + +export function compareVersions(a: string, b: string): number { + const [coreA, preA] = split(a); + const [coreB, preB] = split(b); + for (let i = 0; i < 3; i++) if (coreA[i] !== coreB[i]) return coreA[i] - coreB[i]; + if (!preA.length || !preB.length) return preB.length - preA.length; + for (let i = 0; i < Math.max(preA.length, preB.length); i++) { + const x = preA[i]; + const y = preB[i]; + if (x === undefined || y === undefined) return x === undefined ? -1 : 1; + if (x === y) continue; + const nx = /^\d+$/.test(x); + const ny = /^\d+$/.test(y); + if (nx && ny) return Number(x) - Number(y); + if (nx !== ny) return nx ? -1 : 1; + return x < y ? -1 : 1; + } + return 0; +} + +function split(version: string): [number[], string[]] { + const dash = version.indexOf("-"); + const core = (dash < 0 ? version : version.slice(0, dash)).split(".").map(Number); + return [core, dash < 0 ? [] : version.slice(dash + 1).split(".")]; +} + +function delay(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", stop); + resolve(); + }, ms); + const stop = () => { + clearTimeout(timer); + reject(signal.reason); + }; + signal.addEventListener("abort", stop, { once: true }); + }); +} diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/test/fakeSsh.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/test/fakeSsh.ts index f604ed065..33bfd6e18 100644 --- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/test/fakeSsh.ts +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/test/fakeSsh.ts @@ -9,6 +9,8 @@ import type { AddressInfo } from "node:net"; import { PassThrough } from "node:stream"; import type { TestContext } from "node:test"; import type { ReviewRemoteClock, SpawnSsh, SshChildProcess } from "../reviewRemoteHost.js"; +import { REVIEW_REMOTE_INSTALL_SAY } from "../reviewRemoteInstallScript.js"; +import { REVIEW_REMOTE_PROBE_BEGIN, REVIEW_REMOTE_PROBE_END, REVIEW_REMOTE_PROBE_PATH_CLI } from "../reviewRemoteProbeScript.js"; class FakeChild extends EventEmitter { static nextPid = 1000; @@ -54,26 +56,53 @@ type MasterOutcome = "up" | "hang" | "missing" | { code: number; stderr: string export interface FakeRemote { master?: MasterOutcome | ((alias: string) => MasterOutcome); attach?: Attach | ((call: number) => Attach); + probe?: Partial>; remotePort?: number; masterStderr?: string; exitDelayMs?: number; checkAnswered?: (call: number) => Promise | undefined; + detect?: Attach; + connect?: Attach; + uninstall?: { ok: boolean; after?: Promise }; } type Attach = { code: number; stdout?: string; stderr?: string }; export interface FakeCall { readonly alias: string; - readonly kind: "master" | "check" | "exec" | "forward" | "cancel" | "exit"; + kind: "master" | "check" | "exec" | "probe" | "detect" | "connect" | "uninstall" | "forward" | "cancel" | "exit"; readonly args: readonly string[]; + input?: string; readonly at: number; readonly masterAlive: boolean; closed?: boolean; - input(): string; } -export const attachOutput = (port: number, token = "remote-token", languageServer?: { port: number; connectionToken: string; commit: string }) => - `WHITEBOARD-REMOTE-BEGIN\n${JSON.stringify({ event: "remote.attach", version: "0.1.6", commit: "abc", serverId: "s1", url: `http://127.0.0.1:${port}`, token, startedServer: true, ...(languageServer && { languageServer }) })}\nWHITEBOARD-REMOTE-END\n`; +export const FAKE_SERVER_ID = "0199a3f2-7c1e-7d4a-9b2f-3e5d6c7b8a90"; + +export const detectOutput = (agents: readonly Record[]) => `${JSON.stringify({ event: "connect.detect", agents })}\n`; + +const connectedOutput = (input: string) => + `${JSON.stringify({ event: "connect.run", agents: [...input.matchAll(/'(claude|codex|opencode|pi)'/g)].map(([, id]) => ({ id, name: id, connected: true, output: "" })) })}\n`; + +export const attachOutput = (port: number, token = "remote-token", extra: Record = {}) => + `WHITEBOARD-REMOTE-BEGIN\n${JSON.stringify({ event: "remote.attach", version: "0.1.6", commit: "abc", serverId: FAKE_SERVER_ID, url: `http://127.0.0.1:${port}`, token, startedServer: true, ...extra })}\nWHITEBOARD-REMOTE-END\n`; + +export const probeOutput = (probe: Partial> = {}) => + `${REVIEW_REMOTE_PROBE_BEGIN}\n${JSON.stringify({ + os: "Linux", + arch: "aarch64", + glibc: "2.35", + home: "/home/dev", + root: "/home/dev/.dev/whiteboard-remote", + homeWritable: true, + freeBytes: 50e9, + installed: [], + downloader: "curl", + releasesReachable: true, + tools: ["tar", "xz", "sha256sum", "openssl"], + ...probe, + })}\n${REVIEW_REMOTE_PROBE_END}\n${probe.pathCli ? `${REVIEW_REMOTE_PROBE_PATH_CLI} ${JSON.stringify(probe.pathCli)}\n` : ""}`; export function fakeClock() { let time = 0; @@ -117,7 +146,7 @@ export function fakeSsh(remotes: Record, clock?: { now(): nu const operation = args.includes("-O") ? args[args.indexOf("-O") + 1] : undefined; const kind: FakeCall["kind"] = args.includes("-M") ? "master" : args.at(-1) === "-s" ? "exec" : (operation as FakeCall["kind"]); const master = masters.get(alias); - const entry: FakeCall = { alias, kind, args, at: clock?.now() ?? Date.now(), masterAlive: master?.alive ?? false, input: () => child.input }; + const entry: FakeCall = { alias, kind, args, at: clock?.now() ?? Date.now(), masterAlive: master?.alive ?? false }; calls.push(entry); child.once("close", () => (entry.closed = true)); setImmediate(() => { @@ -137,6 +166,30 @@ export function fakeSsh(remotes: Record, clock?: { now(): nu void Promise.resolve(remote.checkAnswered?.(call)).then(() => child.finish(code, output)); } else if (kind === "exec") { const answer = () => { + entry.input = child.input; + if (child.input.includes(REVIEW_REMOTE_PROBE_BEGIN)) { + entry.kind = "probe"; + return child.finish(0, { stdout: probeOutput(remote.probe) }); + } + if (child.input.includes("'connect' '--detect'")) { + entry.kind = "detect"; + const answer = remote.detect ?? { code: 0, stdout: detectOutput([]) }; + return child.finish(answer.code, answer); + } + if (child.input.includes("'connect' '--yes'")) { + entry.kind = "connect"; + const answer = remote.connect ?? { code: 0, stdout: connectedOutput(child.input) }; + return child.finish(answer.code, answer); + } + if (child.input.includes(" LISTED\\n")) { + entry.kind = "uninstall"; + return child.finish(0, { stdout: `${REVIEW_REMOTE_INSTALL_SAY} ROOT /home/dev/.dev/whiteboard-remote\n${REVIEW_REMOTE_INSTALL_SAY} HAVE 0.1.6\n${REVIEW_REMOTE_INSTALL_SAY} LISTED\n` }); + } + if (child.input.includes("remote uninstall")) { + entry.kind = "uninstall"; + const done = () => child.finish(0, { stdout: `${JSON.stringify({ event: "remote.uninstall", ok: remote.uninstall?.ok ?? true, reason: "refused" })}\n` }); + return void (remote.uninstall?.after ?? Promise.resolve()).then(done); + } const call = calls.filter((c) => c.alias === alias && c.kind === "exec").length; const attach = typeof remote.attach === "function" From 14ba8e363c0d1fa2c8e64b9db516e54e60d15d99 Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Wed, 14 Oct 2026 13:13:59 -0400 Subject: [PATCH 06/14] Install from Desktop when a host lacks its version Desktop asks once per host, shows each step, and attaches the installed CLI with --replace. --- .../remote/reviewRemoteAttachScript.ts | 39 +- .../remote/reviewRemoteHost.test.ts | 563 +++++++++++++++++- .../electron-main/remote/reviewRemoteHost.ts | 304 +++++++++- .../remote/reviewRemoteHosts.test.ts | 353 ++++++++++- .../electron-main/remote/reviewRemoteHosts.ts | 107 +++- .../remote/reviewRemoteInstallConsent.test.ts | 41 ++ .../remote/reviewRemoteInstallConsent.ts | 65 ++ .../electron-main/remote/reviewSshCommand.ts | 4 +- .../reviewDesktopChannel.test.ts | 19 + .../electron-main/reviewDesktopChannel.ts | 22 +- .../review/electron-main/reviewDesktopHost.ts | 91 ++- .../reviewDesktopConnectionService.test.ts | 2 +- .../reviewDesktopConnectionService.ts | 24 + packages/review/src/cli-runner.ts | 6 + packages/review/src/remote-attach.ts | 48 ++ .../src/server/background-server.test.ts | 104 ++++ .../review/src/server/background-server.ts | 9 + .../src/server/review-gateway-hosts.test.ts | 107 +++- .../review/src/server/review-gateway-hosts.ts | 73 ++- 19 files changed, 1899 insertions(+), 82 deletions(-) create mode 100644 apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstallConsent.test.ts create mode 100644 apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstallConsent.ts diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteAttachScript.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteAttachScript.ts index 417d7dedc..45e4fc029 100644 --- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteAttachScript.ts +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteAttachScript.ts @@ -4,21 +4,40 @@ *--------------------------------------------------------------------------------------------*/ import { REVIEW_REMOTE_ATTACH_BEGIN, REVIEW_REMOTE_ATTACH_END } from "../../common/reviewProtocol.js"; +import { shellQuote } from "./reviewRemoteInstallScript.js"; -export function reviewRemoteAttachScript(groups: readonly string[] = []): string { - return `wb=$(command -v whiteboard 2>/dev/null) +export const REVIEW_REMOTE_FIND_CLI = `bounded() { + if timeout -k 1 1 true >/dev/null 2>&1; then timeout -k 1 "$@"; else shift; "$@"; fi +} +wb=$(command -v whiteboard 2>/dev/null) case "$wb" in /*) ;; *) wb= ;; esac if [ -z "$wb" ] && [ -x "$HOME/.local/bin/whiteboard" ]; then wb="$HOME/.local/bin/whiteboard"; fi if [ -z "$wb" ] && [ -n "$SHELL" ]; then - wb=$("$SHELL" -lic 'command -v whiteboard' /dev/null | tr -d '\\r' | grep '^/.*/whiteboard$' | tail -n 1) + wb=$(bounded 3 "$SHELL" -lic 'command -v whiteboard' /dev/null | tr -d '\\r' | grep '^/.*/whiteboard$' | tail -n 1) fi -if [ -z "$wb" ] || [ ! -x "$wb" ]; then exit 127; fi +[ -n "$wb" ] && [ -x "$wb" ] || wb= +`; + +export function pathCliScript(words: string): string { + return `${REVIEW_REMOTE_FIND_CLI}if [ -z "$wb" ]; then exit 127; fi PATH="\${wb%/*}:$PATH" export PATH -exec "$wb" remote attach --json${groups.length ? ` --groups ${groups.join(",")}` : ""} +exec "$wb" ${words} `; } +function attachWords(groups: readonly string[], replace: boolean): string { + return `remote attach --json${replace ? " --replace" : ""}${groups.length ? ` --groups ${groups.join(",")}` : ""}`; +} + +export function reviewRemoteAttachScript(groups: readonly string[] = [], replace = false): string { + return pathCliScript(attachWords(groups, replace)); +} + +export function installedAttachScript(nodePath: string, cliPath: string, groups: readonly string[] = []): string { + return `exec ${shellQuote(nodePath)} ${shellQuote(cliPath)} ${attachWords(groups, true)}\n`; +} + export interface ReviewRemoteLanguageServer { readonly port: number; readonly connectionToken: string; @@ -40,6 +59,8 @@ export interface ReviewRemoteAttach { readonly languageServerDetail?: string; readonly languageServerPending?: true; readonly languageGroups: readonly ReviewRemoteLanguageGroup[]; + readonly replaced?: string; + readonly incompatibleRunning?: { readonly version: string; readonly startedBy: "user" | "cli" | "desktop" }; } export function parseRemoteAttach(stdout: string): { attach: ReviewRemoteAttach } | { error: string } | undefined { @@ -66,6 +87,7 @@ export function parseRemoteAttach(stdout: string): { attach: ReviewRemoteAttach if (port === undefined || typeof record.token !== "string" || !record.token) { return { error: "remote attach answered without a URL and a token." }; } + const running = record.incompatibleRunning as Record | undefined; return { attach: { version: typeof record.version === "string" ? record.version : null, @@ -74,6 +96,11 @@ export function parseRemoteAttach(stdout: string): { attach: ReviewRemoteAttach port, ...languageServerOf(record), languageGroups: languageGroupsOf(record.languageGroups), + ...(record.replaced === true && { replaced: versionText(record.previousVersion) }), + ...(running && + typeof running === "object" && { + incompatibleRunning: { version: versionText(running.version), startedBy: running.startedBy === "cli" || running.startedBy === "desktop" ? running.startedBy : "user" }, + }), }, }; } @@ -101,6 +128,8 @@ function languageGroupsOf(value: unknown): ReviewRemoteLanguageGroup[] { }); } +const versionText = (value: unknown) => (typeof value === "string" ? value : "unknown"); + function urlPort(url: unknown): number | undefined { if (typeof url !== "string") return undefined; try { diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.test.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.test.ts index 1a857e765..88c5e98c7 100644 --- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.test.ts +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.test.ts @@ -5,7 +5,7 @@ import assert from "node:assert/strict"; import { existsSync } from "node:fs"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; @@ -13,8 +13,11 @@ import { join } from "node:path"; import test from "node:test"; import type { ReviewGatewayHost } from "../../common/reviewProtocol.js"; -import { attachOutput, COMMIT, fakeClock, fakeSsh, flush, healthServer, until, type FakeRemote } from "./test/fakeSsh.js"; -import { classifySshFailure, languageCommitMismatch, ReviewRemoteHost } from "./reviewRemoteHost.js"; +import { attachOutput, COMMIT, detectOutput, FAKE_SERVER_ID, fakeClock, fakeSsh, flush, healthServer, until, type FakeRemote } from "./test/fakeSsh.js"; +import { classifySshFailure, languageCommitMismatch, ReviewRemoteHost, type ReviewRemoteHostOptions, type ReviewRemoteInstallFlow, type ReviewRemoteInstallMode, type ReviewRemoteInstallRunInput } from "./reviewRemoteHost.js"; +import { ReviewRemoteNoTarball } from "./reviewRemoteArtifacts.js"; +import { openRemoteInstallConsent } from "./reviewRemoteInstallConsent.js"; +import type { ReviewRemoteInstallProgress, ReviewRemoteInstallResult } from "./reviewRemoteInstaller.js"; import { reviewSshSession } from "./reviewSshCommand.js"; async function versionServer(t: test.TestContext, commit: string): Promise { @@ -32,7 +35,9 @@ function hostFor( ports: number | number[] | (() => Promise), alias = "wb-test-a", controlDirectory = "/tmp/wb-ssh-test", - desktopCommit?: string, + install?: ReviewRemoteInstallFlow, + version = "0.1.6", + extra: Partial = {}, ) { const free = typeof ports === "function" ? [] : [ports].flat(); let next = 0; @@ -43,19 +48,73 @@ function hostFor( session: reviewSshSession(alias, controlDirectory, "test"), spawn: ssh.spawn, environment: async () => ({ PATH: "/usr/bin" }), - desktopVersion: async () => "0.1.6", - desktopCommit, + desktopVersion: async () => version, groups: async () => ["go"], freePort: typeof ports === "function" ? ports : async () => free[next++ % free.length], report: (state) => reports.push(state), log: () => {}, clock, timeouts: { poll: 1 }, + install, + ...extra, }); t.after(() => host.dispose()); return { host, ssh, clock, reports, last: () => reports.at(-1) }; } +const SHA256 = "a".repeat(64); +const at = (version: string, sha256 = SHA256) => ({ version, sha256 }); + +const INSTALLED: ReviewRemoteInstallResult = { + nodePath: "/home/dev/.dev/whiteboard-remote/versions/0.1.6/bin/node", + cliPath: "/home/dev/.dev/whiteboard-remote/versions/0.1.6/dist/cli.js", +}; + +async function installFlow( + t: test.TestContext, + mode: ReviewRemoteInstallMode, + options: { + answers?: (boolean | undefined)[]; + steps?: ReviewRemoteInstallProgress[]; + fails?: (call: number) => Error | undefined; + consentFile?: string; + origin?: string | null; + artifact?: ReviewRemoteInstallFlow["artifact"]; + } = {}, +) { + const dir = await mkdtemp(join(tmpdir(), "wb-flow-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const consentFile = options.consentFile ?? join(dir, "consent.json"); + const prompts: { alias: string; text: string }[] = []; + const runs: ReviewRemoteInstallRunInput[] = []; + const answers = [...(options.answers ?? [])]; + const flow: ReviewRemoteInstallFlow = { + mode: () => mode, + consent: openRemoteInstallConsent(consentFile), + confirm: async (request) => { + prompts.push(request); + return answers.shift(); + }, + run: async (input) => { + runs.push(input); + for (const step of options.steps ?? [{ step: "done" }]) input.onProgress(step); + const error = options.fails?.(runs.length); + if (error) throw error; + return INSTALLED; + }, + origin: () => (options.origin === null ? undefined : (options.origin ?? "https://install.dev.fast")), + artifact: + options.artifact ?? + (async (target, version) => { + if (options.origin === null) throw new ReviewRemoteNoTarball(); + return { name: `whiteboard-remote-${version}-${target}.tar.xz`, url: `https://install.dev.fast/${version}/${target}`, sha256: SHA256 }; + }), + }; + return { flow, prompts, runs, consentFile }; +} + +const STEPS: ReviewRemoteInstallProgress[] = [{ step: "package" }, { step: "done" }]; + test("a successful attach reports an endpoint at the forwarded port", async (t) => { const port = await healthServer(t); const { host, ssh, last } = hostFor(t, { remotePort: 41234 }, port); @@ -73,7 +132,7 @@ test("a successful attach reports an endpoint at the forwarded port", async (t) test("the VS Code server gets a second forward on the same master, and only its endpoint reaches a window", async (t) => { const ports = [await healthServer(t), await versionServer(t, COMMIT)]; const languageServer = { port: 45678, connectionToken: "vscode-token", commit: COMMIT }; - const { host, ssh, last } = hostFor(t, { attach: { code: 0, stdout: attachOutput(41234, "remote-token", languageServer) } }, ports); + const { host, ssh, last } = hostFor(t, { attach: { code: 0, stdout: attachOutput(41234, "remote-token", { languageServer }) } }, ports); host.start(); await until(() => last()?.endpoint !== undefined); @@ -82,7 +141,7 @@ test("the VS Code server gets a second forward on the same master, and only its assert.equal(ssh.of("wb-test-a", "master").length, 1); const forwards = ssh.of("wb-test-a", "forward").map((call) => call.args.find((arg) => arg.startsWith("127.0.0.1:"))); assert.deepEqual(forwards, [`127.0.0.1:${ports[0]}:127.0.0.1:41234`, `127.0.0.1:${ports[1]}:127.0.0.1:45678`]); - assert.deepEqual(await host.languageEndpoint("s1"), { host: "127.0.0.1", port: ports[1], connectionToken: "vscode-token" }); + assert.deepEqual(await host.languageEndpoint(FAKE_SERVER_ID), { host: "127.0.0.1", port: ports[1], connectionToken: "vscode-token" }); assert.equal(await host.languageEndpoint("another machine"), undefined); assert.doesNotMatch(JSON.stringify(last()), /vscode-token/); }); @@ -94,13 +153,13 @@ test("the Desktop's enabled groups go to remote attach", async (t) => { host.start(); await until(() => last()?.endpoint !== undefined); - assert.match(ssh.of("wb-test-a", "exec")[0].input(), /exec "\$wb" remote attach --json --groups go\n$/); + assert.match(ssh.of("wb-test-a", "exec")[0].input!, /exec "\$wb" remote attach --json --groups go\n$/); }); test("a VS Code server of another commit leaves the review online without language features", async (t) => { const ports = [await healthServer(t), await versionServer(t, COMMIT)]; const languageServer = { port: 45678, connectionToken: "vscode-token", commit: COMMIT }; - const { host, ssh, last } = hostFor(t, { attach: { code: 0, stdout: attachOutput(41234, "remote-token", languageServer) } }, ports, "wb-test-a", "/tmp/wb-ssh-test", "b".repeat(40)); + const { host, ssh, last } = hostFor(t, { attach: { code: 0, stdout: attachOutput(41234, "remote-token", { languageServer }) } }, ports, "wb-test-a", "/tmp/wb-ssh-test", undefined, undefined, { desktopCommit: "b".repeat(40) }); host.start(); await until(() => last()?.endpoint !== undefined); @@ -112,7 +171,7 @@ test("a VS Code server of another commit leaves the review online without langua languageFeaturesDetail: "language features need the same Whiteboard version on wb-test-a: it runs aaaaaaa, this Desktop bbbbbbb", }); assert.equal(ssh.of("wb-test-a", "forward").length, 1); - assert.equal(await host.languageEndpoint("s1"), undefined); + assert.equal(await host.languageEndpoint(FAKE_SERVER_ID), undefined); }); test("a dev Desktop, with no commit, accepts any VS Code server; a release Desktop only its own", () => { @@ -124,7 +183,7 @@ test("a dev Desktop, with no commit, accepts any VS Code server; a release Deskt test("a VS Code server that does not answer through its forward is unavailable, and its forward is cancelled", async (t) => { const ports = [await healthServer(t), await versionServer(t, "c".repeat(40))]; const languageServer = { port: 45678, connectionToken: "vscode-token", commit: COMMIT }; - const { host, ssh, last } = hostFor(t, { attach: { code: 0, stdout: attachOutput(41234, "remote-token", languageServer) } }, ports); + const { host, ssh, last } = hostFor(t, { attach: { code: 0, stdout: attachOutput(41234, "remote-token", { languageServer }) } }, ports); host.start(); await until(() => last()?.endpoint !== undefined); @@ -134,14 +193,14 @@ test("a VS Code server that does not answer through its forward is unavailable, const cancels = ssh.of("wb-test-a", "cancel"); assert.equal(cancels.length, 1); assert.ok(cancels[0].args.includes(`127.0.0.1:${ports[1]}:127.0.0.1:45678`)); - assert.equal(await host.languageEndpoint("s1"), undefined); + assert.equal(await host.languageEndpoint(FAKE_SERVER_ID), undefined); }); test("a reattach drops both old forwards", async (t) => { const ports = [await healthServer(t), await versionServer(t, COMMIT), await healthServer(t), await versionServer(t, COMMIT)]; const { host, ssh, last } = hostFor( t, - { attach: (call) => ({ code: 0, stdout: attachOutput(41234 + call, `token-${call}`, { port: 45678 + call, connectionToken: `vscode-${call}`, commit: COMMIT }) }) }, + { attach: (call) => ({ code: 0, stdout: attachOutput(41234 + call, `token-${call}`, { languageServer: { port: 45678 + call, connectionToken: `vscode-${call}`, commit: COMMIT } }) }) }, ports, ); @@ -155,13 +214,13 @@ test("a reattach drops both old forwards", async (t) => { ssh.of("wb-test-a", "cancel").map((call) => call.args.find((arg) => arg.startsWith("127.0.0.1:"))), [`127.0.0.1:${ports[0]}:127.0.0.1:41235`, `127.0.0.1:${ports[1]}:127.0.0.1:45679`], ); - assert.deepEqual(await host.languageEndpoint("s1"), { host: "127.0.0.1", port: ports[3], connectionToken: "vscode-2" }); + assert.deepEqual(await host.languageEndpoint(FAKE_SERVER_ID), { host: "127.0.0.1", port: ports[3], connectionToken: "vscode-2" }); }); const PENDING_DETAIL = "Installing the language extensions on this host; they will be available on the next connection."; const pendingOutput = (port: number) => - `WHITEBOARD-REMOTE-BEGIN\n${JSON.stringify({ event: "remote.attach", version: "0.1.6", commit: "abc", serverId: "s1", url: `http://127.0.0.1:${port}`, token: "remote-token", startedServer: false, languageServer: null, languageServerDetail: PENDING_DETAIL, languageServerPending: true })}\nWHITEBOARD-REMOTE-END\n`; + `WHITEBOARD-REMOTE-BEGIN\n${JSON.stringify({ event: "remote.attach", version: "0.1.6", commit: "abc", serverId: FAKE_SERVER_ID, url: `http://127.0.0.1:${port}`, token: "remote-token", startedServer: false, languageServer: null, languageServerDetail: PENDING_DETAIL, languageServerPending: true })}\nWHITEBOARD-REMOTE-END\n`; test("a VS Code server that stopped answering is not handed out, and the host attaches again", async (t) => { const stopped: Server = createServer((request, response) => response.end(request.url === "/version" ? COMMIT : "")); @@ -170,7 +229,7 @@ test("a VS Code server that stopped answering is not handed out, and the host at const ports = [await healthServer(t), (stopped.address() as AddressInfo).port, await versionServer(t, COMMIT)]; const { host, ssh, last } = hostFor( t, - { attach: (call) => ({ code: 0, stdout: attachOutput(41234, "remote-token", { port: 45677 + call, connectionToken: `vscode-${call}`, commit: COMMIT }) }) }, + { attach: (call) => ({ code: 0, stdout: attachOutput(41234, "remote-token", { languageServer: { port: 45677 + call, connectionToken: `vscode-${call}`, commit: COMMIT } }) }) }, ports, ); @@ -178,9 +237,9 @@ test("a VS Code server that stopped answering is not handed out, and the host at await until(() => last()?.languageFeatures === true); await new Promise((resolve) => stopped.close(resolve)); - assert.equal(await host.languageEndpoint("s1"), undefined); + assert.equal(await host.languageEndpoint(FAKE_SERVER_ID), undefined); await until(() => ssh.of("wb-test-a", "exec").length === 2 && ssh.of("wb-test-a", "cancel").length === 1); - assert.deepEqual(await host.languageEndpoint("s1"), { host: "127.0.0.1", port: ports[2], connectionToken: "vscode-2" }); + assert.deepEqual(await host.languageEndpoint(FAKE_SERVER_ID), { host: "127.0.0.1", port: ports[2], connectionToken: "vscode-2" }); assert.equal(last()?.endpoint?.url, `http://127.0.0.1:${ports[0]}`); assert.deepEqual( ssh.of("wb-test-a", "cancel").map((call) => call.args.find((arg) => arg.startsWith("127.0.0.1:"))), @@ -206,7 +265,7 @@ test("a reattach the gateway asks for during a pending attach runs after it, wit const ports = [await healthServer(t), await versionServer(t, COMMIT), await healthServer(t), await versionServer(t, COMMIT)]; const { host, ssh, clock, last } = hostFor( t, - { attach: (call) => ({ code: 0, stdout: call === 1 ? pendingOutput(41234) : attachOutput(41234, "remote-token", { port: 45678, connectionToken: `vscode-${call}`, commit: COMMIT }) }) }, + { attach: (call) => ({ code: 0, stdout: call === 1 ? pendingOutput(41234) : attachOutput(41234, "remote-token", { languageServer: { port: 45678, connectionToken: `vscode-${call}`, commit: COMMIT } }) }) }, ports, ); @@ -217,7 +276,7 @@ test("a reattach the gateway asks for during a pending attach runs after it, wit await until(() => ssh.of("wb-test-a", "exec").length === 3 && last()?.endpoint?.url === `http://127.0.0.1:${ports[2]}`); assert.equal(last()?.languageFeatures, true); - assert.deepEqual(await host.languageEndpoint("s1"), { host: "127.0.0.1", port: ports[3], connectionToken: "vscode-3" }); + assert.deepEqual(await host.languageEndpoint(FAKE_SERVER_ID), { host: "127.0.0.1", port: ports[3], connectionToken: "vscode-3" }); }); test("a Retry after ten pending attaches attaches again on the same schedule", async (t) => { @@ -244,7 +303,7 @@ test("a remote still installing its extensions is attached again after a minute, const ports = [await healthServer(t), await versionServer(t, COMMIT)]; const { host, ssh, clock, last } = hostFor( t, - { attach: (call) => ({ code: 0, stdout: call === 1 ? pendingOutput(41234) : attachOutput(41234, "remote-token", { port: 45678, connectionToken: "vscode-token", commit: COMMIT }) }) }, + { attach: (call) => ({ code: 0, stdout: call === 1 ? pendingOutput(41234) : attachOutput(41234, "remote-token", { languageServer: { port: 45678, connectionToken: "vscode-token", commit: COMMIT } }) }) }, ports, ); @@ -306,7 +365,6 @@ test("exit 127 from the script is not-installed, naming the version to install", assert.equal(last()?.problem?.state, "not-installed"); assert.match(last()!.problem!.detail, /Install Whiteboard 0\.1\.6 there/); assert.doesNotMatch(last()!.problem!.detail, /npm install/); - assert.match(last()!.problem!.detail, /Node 24/); assert.equal(clock.pending, 0); }); @@ -615,7 +673,7 @@ test("a dispose while the language forward's port is chosen starts no language f const asked = Promise.withResolvers(); let calls = 0; const languageServer = { port: 45678, connectionToken: "vscode-token", commit: COMMIT }; - const { host, ssh, reports } = hostFor(t, { attach: { code: 0, stdout: attachOutput(41234, "remote-token", languageServer) } }, async () => { + const { host, ssh, reports } = hostFor(t, { attach: { code: 0, stdout: attachOutput(41234, "remote-token", { languageServer }) } }, async () => { if (++calls === 1) return port; asked.resolve(); return chosen.promise; @@ -631,3 +689,460 @@ test("a dispose while the language forward's port is chosen starts no language f assert.equal(probes, 0); assert.ok(reports.every((report) => !report.endpoint)); }); + +test("installs off: no probe, stage 1's attach through PATH, and not-installed with the version to install", async (t) => { + const { flow, prompts, runs } = await installFlow(t, "never"); + const { host, ssh, clock, last } = hostFor(t, { attach: { code: 127 } }, 1, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.problem !== undefined); + + assert.deepEqual(last(), { + alias: "wb-test-a", + problem: { state: "not-installed", detail: "Whiteboard is not installed on wb-test-a. Install Whiteboard 0.1.6 there." }, + }); + assert.equal(ssh.of("wb-test-a", "probe").length, 0); + assert.match(ssh.of("wb-test-a", "exec")[0].input!, /command -v whiteboard/); + assert.deepEqual([prompts.length, runs.length, clock.pending], [0, 0, 0]); +}); + +test("the version present: no prompt and no install shown; the installed CLI attaches by its path, with --replace", async (t) => { + const port = await healthServer(t); + const { flow, prompts, runs } = await installFlow(t, "ask"); + const { host, ssh, reports, last } = hostFor(t, { probe: { installed: [at("0.1.5"), at("0.1.6")] } }, port, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.endpoint !== undefined); + + assert.equal(prompts.length, 0); + assert.equal(runs.length, 1); + assert.ok(reports.every((report) => !report.installing)); + assert.equal(ssh.of("wb-test-a", "probe").length, 1); + assert.equal( + ssh.of("wb-test-a", "exec")[0].input, + `exec '${INSTALLED.nodePath}' '${INSTALLED.cliPath}' remote attach --json --replace --groups go\n`, + ); +}); + +test("the version present with another sha256 is not installed: the user is asked and the install is shown", async (t) => { + const port = await healthServer(t); + const { flow, prompts, runs } = await installFlow(t, "ask", { answers: [true], steps: STEPS }); + const { host, reports, last } = hostFor(t, { probe: { installed: [at("0.1.5"), at("0.1.6", "b".repeat(64))] } }, port, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.endpoint !== undefined); + + assert.equal(prompts.length, 1); + assert.equal(runs.length, 1); + assert.deepEqual(reports.find((report) => report.installing)?.installing, { step: "preparing" }); + assert.equal(await flow.consent.get("wb-test-a"), "allow"); +}); + +test("the version absent with installs always: each step is reported, then the host attaches", async (t) => { + const port = await healthServer(t); + const { flow, prompts, runs } = await installFlow(t, "always", { steps: STEPS }); + const { host, reports, last } = hostFor(t, {}, port, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.endpoint !== undefined); + + assert.equal(prompts.length, 0); + assert.equal(runs.length, 1); + assert.equal(runs[0].version, "0.1.6"); + assert.equal(runs[0].target, "linux-arm64"); + assert.deepEqual(runs[0].artifact, { name: "whiteboard-remote-0.1.6-linux-arm64.tar.xz", url: "https://install.dev.fast/0.1.6/linux-arm64", sha256: SHA256 }); + assert.deepEqual( + reports.filter((report) => report.installing).map((report) => report.installing), + [ + { step: "preparing" }, + { step: "package" }, + { step: "done" }, + ], + ); + assert.ok(reports.every((report) => !(report.installing && report.endpoint))); +}); + +test("the version absent, asked and declined: not-installed and declined, and no second prompt on reconnect", async (t) => { + const { flow, prompts, runs, consentFile } = await installFlow(t, "ask", { answers: [false] }); + const { host, ssh, clock, last } = hostFor(t, { attach: { code: 127 } }, 1, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.problem !== undefined); + + assert.equal(prompts.length, 1); + assert.deepEqual(last(), { + alias: "wb-test-a", + problem: { state: "not-installed", detail: "Whiteboard is not installed on wb-test-a. Install Whiteboard 0.1.6 there." }, + declined: true, + target: "linux-arm64", + }); + assert.equal(clock.pending, 0); + assert.deepEqual(JSON.parse(await readFile(consentFile, "utf8")).aliases, { "wb-test-a": "deny" }); + + host.retry(); + await until(() => ssh.of("wb-test-a", "exec").length === 2 && last()?.problem !== undefined); + assert.equal(prompts.length, 1); + assert.equal(runs.length, 0); + assert.equal(last()?.declined, true); +}); + +test("a declined host with another version's CLI on PATH attaches it, and still offers the install", async (t) => { + const port = await healthServer(t); + const { flow, runs } = await installFlow(t, "ask", { answers: [false] }); + const { host, ssh, last } = hostFor(t, {}, port, "wb-test-a", "/tmp/wb-ssh-test", flow, "0.1.7"); + + host.start(); + await until(() => last()?.endpoint !== undefined); + + assert.equal(last()?.declined, true); + assert.equal(runs.length, 0); + assert.match(ssh.of("wb-test-a", "exec")[0].input!, /command -v whiteboard/); + assert.doesNotMatch(ssh.of("wb-test-a", "exec")[0].input!, /--replace/); + assert.equal(await flow.consent.get("wb-test-a"), "deny"); +}); + +test("this version's CLI on PATH counts as installed: no prompt, no install, and it attaches through PATH with --replace", async (t) => { + const port = await healthServer(t); + const { flow, prompts, runs } = await installFlow(t, "ask"); + const { host, ssh, last } = hostFor(t, { probe: { pathCli: { path: "/usr/local/bin/whiteboard", version: "0.1.6" } } }, port, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.endpoint !== undefined); + + assert.deepEqual([prompts.length, runs.length], [0, 0]); + assert.equal(last()?.declined, undefined); + assert.match(ssh.of("wb-test-a", "exec")[0].input!, /command -v whiteboard[\s\S]*remote attach --json --replace/); +}); + +test("another version's CLI on PATH does not count: the user is asked", async (t) => { + const port = await healthServer(t); + const { flow, prompts } = await installFlow(t, "ask", { answers: [false] }); + const { host, last } = hostFor(t, { probe: { pathCli: { path: "/usr/local/bin/whiteboard", version: "0.1.5" } } }, port, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.endpoint !== undefined); + + assert.equal(prompts.length, 1); + assert.equal(await flow.consent.get("wb-test-a"), "deny"); +}); + +test("an open install question is reported until it is answered", async (t) => { + const { flow } = await installFlow(t, "ask"); + const answer = Promise.withResolvers(); + flow.confirm = () => answer.promise; + const { host, reports, last } = hostFor(t, { attach: { code: 127 } }, 1, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.asking !== undefined); + assert.deepEqual(last(), { alias: "wb-test-a", asking: "0.1.6" }); + + answer.resolve(false); + await until(() => last()?.problem !== undefined); + assert.deepEqual(reports.at(-2), { alias: "wb-test-a" }); +}); + +test("a quiesce closes an open install question, and one it lands before", async (t) => { + const { flow } = await installFlow(t, "ask"); + const answer = Promise.withResolvers(); + flow.confirm = () => answer.promise; + const { host, last } = hostFor(t, { attach: { code: 127 } }, 1, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.asking !== undefined); + host.quiesce(); + answer.resolve(undefined); + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.deepEqual(last(), { alias: "wb-test-a" }); + + const reading = await installFlow(t, "ask"); + const read = Promise.withResolvers(); + const release = Promise.withResolvers(); + const get = reading.flow.consent.get.bind(reading.flow.consent); + reading.flow.consent.get = async (alias) => { + read.resolve(); + await release.promise; + return get(alias); + }; + const other = hostFor(t, { attach: { code: 127 } }, 1, "wb-test-a", "/tmp/wb-ssh-test", reading.flow); + + other.host.start(); + await read.promise; + other.host.quiesce(); + release.resolve(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.equal(reading.prompts.length, 0); + assert.equal(other.last()?.asking, undefined); +}); + +test("a prompt nobody answered is not remembered, offers Install, and the next connect asks again", async (t) => { + const { flow, prompts, consentFile } = await installFlow(t, "ask", { answers: [undefined, undefined] }); + const { host, ssh, last } = hostFor(t, { attach: { code: 127 } }, 1, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.problem !== undefined); + assert.equal(last()?.problem?.state, "not-installed"); + assert.equal(last()?.declined, true); + await assert.rejects(readFile(consentFile), { code: "ENOENT" }); + host.retry(); + await until(() => ssh.of("wb-test-a", "exec").length === 2 && last()?.problem !== undefined); + + assert.equal(prompts.length, 2); +}); + +test("an unsupported host is reported with the reason, and nothing is installed or attached", async (t) => { + const { flow, prompts, runs } = await installFlow(t, "always"); + const { host, ssh, clock, last } = hostFor(t, { probe: { glibc: "2.31" } }, 1, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.problem !== undefined); + + assert.deepEqual(last()?.problem, { state: "unsupported", detail: "This host runs glibc 2.31; Whiteboard needs 2.34 or newer." }); + assert.deepEqual([prompts.length, runs.length, ssh.of("wb-test-a", "exec").length, clock.pending], [0, 0, 0, 0]); +}); + +test("a host that lacks an install tool is unsupported before the question, and nothing is installed", async (t) => { + const { flow, prompts, runs } = await installFlow(t, "ask"); + const { host, last } = hostFor(t, { probe: { tools: ["tar", "sha256sum"] } }, 1, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.problem !== undefined); + + assert.deepEqual(last()?.problem, { state: "unsupported", detail: "wb-test-a needs xz to install Whiteboard." }); + assert.deepEqual([prompts.length, runs.length], [0, 0]); +}); + +test("a host that cannot reach the releases, or a build with no tarball, is not-installed without a question", async (t) => { + for (const [origin, probe, detail] of [ + [undefined, { releasesReachable: false }, "wb-test-a cannot reach https://install.dev.fast."], + [null, {}, "This dev build has no remote tarball; run `node apps/review-desktop/scripts/remote-dev-install.mjs wb-test-a` from the checkout or set DEV_FAST_REVIEW_REMOTE_RELEASES."], + ] as const) { + const { flow, prompts, runs } = await installFlow(t, "ask", { origin }); + const { host, last } = hostFor(t, { probe }, 1, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.problem !== undefined); + + assert.deepEqual(last()?.problem, { state: "not-installed", detail }); + assert.deepEqual([prompts.length, runs.length], [0, 0]); + await host.dispose(); + } +}); + +test("with no checksum to compare, Desktop's version installed on the host attaches by its path", async (t) => { + const port = await healthServer(t); + const { flow, prompts, runs } = await installFlow(t, "ask", { + artifact: async () => { + throw new Error("http://laptop/whiteboard-remote-0.1.6-linux-arm64.tar.xz.sha256 answered 404."); + }, + }); + const { host, ssh, last } = hostFor(t, { probe: { installed: [at("0.1.6", "b".repeat(64))] } }, port, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.endpoint !== undefined); + + assert.deepEqual([prompts.length, runs.length], [0, 0]); + assert.equal(ssh.of("wb-test-a", "exec")[0].input, `exec '${INSTALLED.nodePath}' '${INSTALLED.cliPath}' remote attach --json --replace --groups go\n`); +}); + +test("a host with this version installed attaches without the install tools", async (t) => { + const port = await healthServer(t); + const { flow, prompts } = await installFlow(t, "ask"); + const { host, last } = hostFor(t, { probe: { tools: [], installed: [at("0.1.6")] } }, port, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.endpoint !== undefined); + + assert.equal(prompts.length, 0); +}); + +test("a server of another version a user started is left, and the host is incompatible until a retry", async (t) => { + const port = await healthServer(t); + const { flow } = await installFlow(t, "always"); + const { host, ssh, clock, last } = hostFor( + t, + { + attach: (call) => ({ + code: 0, + stdout: attachOutput(41234, "old-token", call === 1 ? { version: "0.1.5", startedServer: false, incompatibleRunning: { version: "0.1.5", pid: 4242, startedBy: "user" } } : {}), + }), + }, + port, + "wb-test-a", + "/tmp/wb-ssh-test", + flow, + ); + + host.start(); + await until(() => last()?.problem !== undefined); + + assert.deepEqual(last(), { + alias: "wb-test-a", + problem: { + state: "incompatible", + detail: "A Whiteboard server 0.1.5 started by a user is running on wb-test-a; stop it to use this Desktop's version.", + }, + target: "linux-arm64", + }); + assert.equal(clock.pending, 0); + assert.equal(ssh.of("wb-test-a", "exec").length, 1); + host.retry(); + await until(() => last()?.endpoint !== undefined); + assert.equal(ssh.of("wb-test-a", "exec").length, 2); +}); + +test("a failed install names the step and the reason, is not retried alone, and Retry installs again", async (t) => { + const port = await healthServer(t); + const reason = "Installing on wb-test-a failed while installing the package: exit 1: \x1b[31mtar: Unexpected EOF\x1b[0m\r."; + const { flow, runs } = await installFlow(t, "always", { + steps: [{ step: "package" }], + fails: (call) => (call === 1 ? new Error(reason) : undefined), + }); + const { host, clock, last } = hostFor(t, {}, port, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.problem !== undefined); + + assert.deepEqual(last()?.problem, { + state: "not-installed", + detail: "Installing Whiteboard 0.1.6 on wb-test-a failed while installing the package: exit 1: tar: Unexpected EOF .", + }); + assert.equal(clock.pending, 0); + host.retry(); + await until(() => last()?.endpoint !== undefined); + assert.equal(runs.length, 2); +}); + +test("an install whose connection dropped is unreachable, and is tried again after the backoff", async (t) => { + const { flow } = await installFlow(t, "always", { + steps: [{ step: "package" }], + fails: () => new Error("Installing on wb-test-a failed while downloading Whiteboard: exit 255: Connection to 127.0.0.1 closed by remote host."), + }); + const { host, clock, last } = hostFor(t, {}, 1, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.problem !== undefined); + + assert.deepEqual(last()?.problem, { + state: "unreachable", + detail: "The connection to wb-test-a dropped while installing Whiteboard 0.1.6 (installing the package).", + }); + assert.equal(clock.pending, 1); +}); + +test("a retry during an install aborts it", async (t) => { + const port = await healthServer(t); + const signals: AbortSignal[] = []; + const { flow } = await installFlow(t, "always"); + const run = flow.run; + flow.run = async (input) => { + signals.push(input.signal); + if (signals.length === 1) await new Promise((resolve) => input.signal.addEventListener("abort", resolve)); + input.signal.throwIfAborted(); + return run(input); + }; + const { host, last } = hostFor(t, {}, port, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => signals.length === 1); + host.retry(); + await until(() => last()?.endpoint !== undefined); + + assert.equal(signals[0].aborted, true); + assert.equal(signals.length, 2); +}); + +test("a failed upgrade attaches the older version, which reports why, and Retry installs again", async (t) => { + const port = await healthServer(t); + const { flow, runs } = await installFlow(t, "always", { + steps: [{ step: "package" }], + fails: (call) => (call === 1 ? new Error("The package installed on wb-test-a reports version 0.1.5, not 0.1.6.") : undefined), + }); + const { host, ssh, clock, last } = hostFor(t, { probe: { installed: [at("0.1.5")] } }, port, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.endpoint !== undefined); + + assert.deepEqual(last(), { + alias: "wb-test-a", + endpoint: { url: `http://127.0.0.1:${port}`, token: "remote-token" }, + ...NO_SERVER, + installFailure: "Installing Whiteboard 0.1.6 on wb-test-a failed while installing the package: The package installed on wb-test-a reports version 0.1.5, not 0.1.6.", + target: "linux-arm64", + }); + assert.match(ssh.of("wb-test-a", "exec")[0].input!, /command -v whiteboard/); + assert.equal(clock.pending, 0); + host.retry(); + await until(() => last()?.endpoint !== undefined && !last()?.installFailure); + assert.equal(runs.length, 2); + assert.match(ssh.of("wb-test-a", "exec")[1].input!, /--replace/); +}); + +test("agents are detected once after the first attach, with the installed CLI, and kept", async (t) => { + const port = await healthServer(t); + const { flow } = await installFlow(t, "always"); + const attached = new Set(); + const firstAttach = (key: string) => !attached.has(key) && !!attached.add(key); + const remote: FakeRemote = { detect: { code: 0, stdout: `noise\n${detectOutput([{ id: "pi", name: "Pi", connected: false }])}` } }; + const { host, ssh, last } = hostFor(t, remote, port, "wb-test-a", "/tmp/wb-ssh-test", flow, "0.1.6", { firstAttach }); + + host.start(); + await until(() => ssh.of("wb-test-a", "detect").length === 1); + + assert.ok(last()?.endpoint, "online before agents are read"); + const [detect] = ssh.of("wb-test-a", "detect"); + assert.match(detect.input!, new RegExp(`exec '${INSTALLED.nodePath}' '${INSTALLED.cliPath}' 'connect' '--detect' '--json'\n$`)); + assert.deepEqual(await host.detectAgents(), [{ id: "pi", connected: false }]); + assert.equal(ssh.of("wb-test-a", "detect").length, 1, "the kept answer"); + assert.equal(ssh.of("wb-test-a", "connect").length, 0, "nothing changed on the host"); + assert.deepEqual([...attached], [FAKE_SERVER_ID]); + + host.retry(); + await until(() => ssh.of("wb-test-a", "exec").length === 2); + await until(() => last()?.endpoint !== undefined); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(ssh.of("wb-test-a", "detect").length, 1); +}); + +test("a hand-installed host detects with the CLI on its PATH", async (t) => { + const port = await healthServer(t); + const { host, ssh, last } = hostFor(t, {}, port, "wb-test-a", "/tmp/wb-ssh-test", undefined, "0.1.6", { firstAttach: () => true }); + + host.start(); + await until(() => last()?.endpoint !== undefined && ssh.of("wb-test-a", "detect").length === 1); + + assert.match(ssh.of("wb-test-a", "detect")[0].input!, /exec "\$wb" 'connect' '--detect' '--json'\n$/); + assert.deepEqual(await host.detectAgents(), []); +}); + +test("connecting runs the agents' commands with the installed CLI, and refuses agents it did not find or cannot connect alone", async (t) => { + const port = await healthServer(t); + const { flow } = await installFlow(t, "always"); + const remote: FakeRemote = { + detect: { + code: 0, + stdout: detectOutput([ + { id: "pi", connected: false }, + { id: "codex", connected: false, manual: true }, + ]), + }, + }; + const { host, ssh, last } = hostFor(t, remote, port, "wb-test-a", "/tmp/wb-ssh-test", flow); + + host.start(); + await until(() => last()?.endpoint !== undefined); + + for (const ids of [["cursor"], ["codex"], ["claude"], ["__proto__"], ["pi", "x".repeat(10)]]) { + await assert.rejects(host.connectAgents(ids), /Whiteboard cannot connect .* on wb-test-a/); + } + assert.equal(ssh.of("wb-test-a", "connect").length, 0); + + assert.deepEqual(await host.connectAgents(["pi", "pi"]), [{ id: "pi", connected: true, output: "" }]); + const [connect] = ssh.of("wb-test-a", "connect"); + assert.match(connect.input!, new RegExp(`exec '${INSTALLED.nodePath}' '${INSTALLED.cliPath}' 'connect' '--yes' '--json' 'pi'\n$`)); + assert.deepEqual(await host.detectAgents(), [ + { id: "codex", connected: false, manual: true }, + { id: "pi", connected: true }, + ]); +}); diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.ts index 8a453c4ef..66cca15b0 100644 --- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.ts +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.ts @@ -6,8 +6,14 @@ import { rm } from "node:fs/promises"; import { get } from "node:http"; import type { Readable, Writable } from "node:stream"; -import type { ReviewGatewayHost } from "../../common/reviewProtocol.js"; -import { parseRemoteAttach, reviewRemoteAttachScript, type ReviewRemoteAttach } from "./reviewRemoteAttachScript.js"; +import type { ReviewGatewayHost, ReviewRemoteAgent, ReviewRemoteAgentResult } from "../../common/reviewProtocol.js"; +import { ReviewRemoteNoTarball, type ReviewRemoteArtifact } from "./reviewRemoteArtifacts.js"; +import { isRemoteAgentId, parseRemoteAgents, parseRemoteConnect, plainText as plain, remoteConnectScript, type ReviewRemoteCli } from "./reviewRemoteAgents.js"; +import { installedAttachScript, parseRemoteAttach, reviewRemoteAttachScript, type ReviewRemoteAttach } from "./reviewRemoteAttachScript.js"; +import type { ReviewRemoteInstallConsent } from "./reviewRemoteInstallConsent.js"; +import type { ReviewRemoteInstallInput, ReviewRemoteInstallResult } from "./reviewRemoteInstaller.js"; +import { reviewRemoteVersionDir } from "./reviewRemoteInstallScript.js"; +import { judgeRemote, missingInstallTools, probeRemote, type ReviewRemoteProbe, type ReviewRemoteTarget } from "./reviewRemoteProbe.js"; import { sshCancelForwardArgs, sshCheckArgs, @@ -55,6 +61,8 @@ export const REVIEW_REMOTE_TIMEOUTS = { attach: 60_000, operation: 10_000, close: 2_000, + agents: 10_000, + agentConnect: 130_000, stable: 30_000, }; @@ -96,6 +104,8 @@ function meaningfulLines(text: string): string[] { ); } +export const lastLine = (text: string) => meaningfulLines(text).at(-1)?.slice(0, 300) ?? ""; + function firstLines(text: string, count = 6): string { return meaningfulLines(text).slice(0, count).join("\n").slice(0, 1000); } @@ -195,6 +205,50 @@ export function runSsh(spawn: SpawnSsh, env: NodeJS.ProcessEnv, args: string[], }); } +export type ReviewRemoteInstallMode = "ask" | "always" | "never"; + +export type ReviewRemoteInstallRunInput = Pick; + +export interface ReviewRemoteInstallFlow { + mode(): ReviewRemoteInstallMode; + readonly consent: ReviewRemoteInstallConsent; + confirm(request: { alias: string; text: string; signal?: AbortSignal }): Promise; + cancel?(alias: string): void; + run(input: ReviewRemoteInstallRunInput): Promise; + origin(): string | undefined; + artifact(target: ReviewRemoteTarget, version: string): Promise; +} + +type InstallStep = NonNullable["step"]; + +type AttachScript = (groups: readonly string[]) => string; + +interface PreparedAttach { + readonly script: AttachScript; + readonly cli?: ReviewRemoteCli; +} + +const STEP_WORDS: Record = { + preparing: "preparing", + "waiting-for-lock": "waiting for another install", + package: "installing the package", + done: "finishing", +}; + +const noTarballText = (alias: string) => + `This dev build has no remote tarball; run \`node apps/review-desktop/scripts/remote-dev-install.mjs ${alias}\` from the checkout or set DEV_FAST_REVIEW_REMOTE_RELEASES.`; + +export function installPromptText(alias: string, version: string, probe: ReviewRemoteProbe): string { + const where = probe.root === `${probe.home}/.dev/whiteboard-remote` ? "~/.dev/whiteboard-remote" : "whiteboard-remote under DEV_REVIEW_HOME"; + return `Whiteboard ${version} is not installed on ${alias}. Install it in ${where}? It takes about 250 MB. Whiteboard also adds ~/.local/bin/whiteboard if that path is free.`; +} + +export function installFailureText(alias: string, version: string, step: InstallStep, message: string): string { + const said = /^Installing on .+? failed( while .+?)?: ([\s\S]*)$/.exec(message); + const what = said ? (said[1] ?? "") : ` while ${STEP_WORDS[step]}`; + return plain(`Installing Whiteboard ${version} on ${alias} failed${what}: ${said ? said[2] : message}`); +} + export interface ReviewRemoteHostOptions { readonly session: ReviewSshSession; readonly spawn: SpawnSsh; @@ -207,6 +261,8 @@ export interface ReviewRemoteHostOptions { log(message: string): void; readonly clock?: ReviewRemoteClock; readonly timeouts?: Partial; + readonly install?: ReviewRemoteInstallFlow; + firstAttach?(key: string): boolean; } export class ReviewRemoteHost { @@ -221,7 +277,6 @@ export class ReviewRemoteHost { private language: { local: number; remote: number; connectionToken: string; commit: string } | undefined; private pendingAttaches = 0; private cancelPending: (() => void) | undefined; - private serverId: string | null = null; private reattaching = false; private queuedReattach = false; private masterStderr = ""; @@ -232,6 +287,16 @@ export class ReviewRemoteHost { private prompts = 0; private promptCancelled = false; private disposed = false; + private attachScript: AttachScript = reviewRemoteAttachScript; + private cli: ReviewRemoteCli; + private agents: ReviewRemoteAgent[] | undefined; + private detecting: Promise | undefined; + private declined = false; + private installFailure: string | undefined; + private target: ReviewRemoteTarget | undefined; + private installing: AbortController | undefined; + private removing = false; + serverId: string | undefined; constructor(private readonly options: ReviewRemoteHostOptions) { this.alias = options.session.alias; @@ -249,15 +314,17 @@ export class ReviewRemoteHost { } retry(): void { - if (this.disposed) return; + if (this.disposed || this.removing) return; this.failures = 0; this.pendingAttaches = 0; + this.declined = false; + this.installFailure = undefined; this.set({ alias: this.alias }); void this.connect(); } async resume(): Promise { - if (this.disposed) return; + if (this.disposed || this.removing) return; if (this.cancelTimer) return void this.connect(); const master = this.master; const port = this.forwarded?.local; @@ -273,7 +340,7 @@ export class ReviewRemoteHost { } reattach(): Promise { - if (this.disposed || this.cancelTimer || !this.master || this.connectedAt === undefined) return Promise.resolve(); + if (this.disposed || this.removing || this.cancelTimer || !this.master || this.connectedAt === undefined) return Promise.resolve(); if (this.reattaching) { this.queuedReattach = true; return Promise.resolve(); @@ -291,7 +358,7 @@ export class ReviewRemoteHost { private async attachAgain(reason: string, reuse: boolean): Promise { const env = this.env; - if (this.disposed || this.reattaching || !this.master || this.connectedAt === undefined || !env || !this.forwarded) return; + if (this.disposed || this.removing || this.reattaching || !this.master || this.connectedAt === undefined || !env || !this.forwarded) return; const generation = this.generation; const stale = () => generation !== this.generation || this.disposed; this.reattaching = true; @@ -300,7 +367,7 @@ export class ReviewRemoteHost { const check = await this.run(sshCheckArgs(this.options.session, env), this.timeouts.operation); if (stale()) return; if (check.code !== 0) throw unreachable(`The SSH connection to ${this.alias} did not answer. ${firstLines(check.stderr)}`.trim()); - await this.establish(env, stale, reuse); + await this.establish(env, stale, { script: this.attachScript, cli: this.cli }, reuse); } catch (error) { if (stale()) return; this.fail(error instanceof HostFailure ? error.problem : { state: "unreachable", detail: (error as Error).message }); @@ -313,6 +380,27 @@ export class ReviewRemoteHost { } } + quiesce(): void { + this.removing = true; + this.generation++; + this.cancelTimer?.(); + this.cancelTimer = undefined; + this.cancelPending?.(); + this.cancelPending = undefined; + this.installing?.abort(); + this.options.install?.cancel?.(this.alias); + if (this.reported.asking) this.set({ alias: this.alias }); + } + + get quiesced(): boolean { + return this.removing; + } + + unquiesce(): void { + this.removing = false; + this.retry(); + } + promptOpened(): void { this.prompts++; } @@ -325,10 +413,12 @@ export class ReviewRemoteHost { async dispose(): Promise { this.disposed = true; this.generation++; + this.options.install?.cancel?.(this.alias); this.cancelTimer?.(); this.cancelTimer = undefined; this.cancelPending?.(); this.cancelPending = undefined; + this.installing?.abort(); const master = this.master; this.master = undefined; await this.close(master); @@ -373,6 +463,7 @@ export class ReviewRemoteHost { } private async connect(): Promise { + if (this.removing) return; const generation = ++this.generation; const stale = () => generation !== this.generation || this.disposed; this.cancelTimer?.(); @@ -383,6 +474,9 @@ export class ReviewRemoteHost { this.dropMaster(); this.connectedAt = undefined; this.promptCancelled = false; + this.declined = false; + this.installFailure = undefined; + this.agents = undefined; try { const env = await this.options.environment(); if (stale()) return; @@ -393,17 +487,21 @@ export class ReviewRemoteHost { if (stale()) return; const master = this.startMaster(env); await this.waitForMaster(master, env, stale); - await this.establish(env, stale); + const prepared = await this.prepareAttach(env, stale); + if (prepared === undefined) return; + await this.establish(env, stale, prepared); + if (stale()) return; + if (this.options.firstAttach?.(this.serverId ?? `alias:${this.alias}`)) void this.detectAgents(); } catch (error) { if (stale()) return; this.fail(error instanceof HostFailure ? error.problem : { state: "unreachable", detail: (error as Error).message }); } } - private async establish(env: NodeJS.ProcessEnv, stale: () => boolean, reuse = false): Promise { + private async establish(env: NodeJS.ProcessEnv, stale: () => boolean, prepared: PreparedAttach, reuse = false): Promise { const old = this.forwarded; const oldLanguage = this.language; - const attach = await this.attach(env); + const attach = await this.attach(env, prepared.script); if (stale()) return; const kept = reuse && old !== undefined && attach.port === old.remote && (await probeHealth(old.local, this.timeouts.operation).then(() => true, () => false)); if (stale()) return; @@ -416,10 +514,12 @@ export class ReviewRemoteHost { if (forward) await this.run(sshCancelForwardArgs(this.options.session, forward.local, forward.remote, env), this.timeouts.operation); if (stale()) return; } + this.attachScript = prepared.script; + this.cli = prepared.cli; this.connectedAt = this.clock.now(); - this.serverId = attach.serverId; + this.serverId = attach.serverId ?? undefined; this.masterStderr = ""; - this.set({ alias: this.alias, endpoint: { url, token: attach.token }, ...language, ...groupsOf(attach) }); + this.set({ alias: this.alias, endpoint: { url, token: attach.token }, ...language, ...groupsOf(attach), ...this.facts() }); this.whilePending(attach); } @@ -453,6 +553,7 @@ export class ReviewRemoteHost { private masterGone(master: SshChildProcess, code: number | null, error?: NodeJS.ErrnoException): void { if (master !== this.master) return; this.master = undefined; + this.options.install?.cancel?.(this.alias); if (error?.code === "ENOENT") return this.fail({ state: "unreachable", detail: OPENSSH_NEEDED }); const exited = error?.message || `ssh exited with code ${code ?? "none"}.`; if (this.connectedAt !== undefined) { @@ -482,12 +583,174 @@ export class ReviewRemoteHost { } } - private async attach(env: NodeJS.ProcessEnv): Promise { + private async prepareAttach(env: NodeJS.ProcessEnv, stale: () => boolean): Promise { + const onPath = { script: reviewRemoteAttachScript }; + const flow = this.options.install; + const mode = flow?.mode() ?? "never"; + if (!flow || mode === "never") return onPath; + const origin = flow.origin(); + const probed = await probeRemote({ session: this.options.session, spawn: this.options.spawn, env, releases: origin && `${origin}/` }); + if (stale()) return; + if ("error" in probed) throw unreachable(probed.error); + const { probe } = probed; + const support = judgeRemote(probe); + if (!support.supported) throw new HostFailure({ state: "unsupported", detail: support.reason }); + this.target = support.target; + const version = await this.options.desktopVersion(); + const artifact = await flow.artifact(support.target, version).catch((error: Error) => error); + if (stale()) return; + // With no checksum to compare, Desktop's own complete version is taken as it is. + const sha256 = artifact instanceof Error ? undefined : artifact.sha256; + const present = probe.installed.some((entry) => entry.version === version && (sha256 === undefined || entry.sha256 === sha256)); + if (present && artifact instanceof Error) { + const dir = reviewRemoteVersionDir(probe.root, version); + const cli = { nodePath: `${dir}/bin/node`, cliPath: `${dir}/dist/cli.js` }; + return { script: (groups) => installedAttachScript(cli.nodePath, cli.cliPath, groups), cli }; + } + if (!present && probe.pathCli?.version === version) return { script: (groups) => reviewRemoteAttachScript(groups, true) }; + const missing = present ? undefined : missingInstallTools(probe, this.alias); + if (missing) throw new HostFailure({ state: "unsupported", detail: missing }); + if (artifact instanceof ReviewRemoteNoTarball) return this.cannotInstall(probe, version, noTarballText(this.alias)); + if (artifact instanceof Error) return this.cannotInstall(probe, version, installFailureText(this.alias, version, "preparing", artifact.message)); + if (!present && !probe.releasesReachable) return this.cannotInstall(probe, version, `${this.alias} cannot reach ${origin}.`); + if (!present && mode === "ask" && !(await this.agreed(flow, probe, version, stale))) { + this.declined = true; + return stale() ? undefined : onPath; + } + if (stale()) return; + const installed = await this.install(flow, env, probe, support.target, version, artifact, present, stale); + if (!installed) return undefined; + if ("path" in installed) { + const { nodePath, cliPath } = installed.path; + return { script: (groups) => installedAttachScript(nodePath, cliPath, groups), cli: { nodePath, cliPath } }; + } + return this.cannotInstall(probe, version, installed.failed); + } + + private cannotInstall(probe: ReviewRemoteProbe, version: string, detail: string): PreparedAttach { + if (!probe.installed.some((other) => other.version !== version)) throw new HostFailure({ state: "not-installed", detail }); + this.installFailure = detail; + return { script: reviewRemoteAttachScript }; + } + + private async agreed(flow: ReviewRemoteInstallFlow, probe: ReviewRemoteProbe, version: string, stale: () => boolean): Promise { + const log = (error: Error) => this.options.log(`${this.alias}: install consent: ${error.message}`); + const stored = await flow.consent.get(this.alias).catch(log); + if (stored) return stored === "allow"; + if (stale()) return false; + this.set({ alias: this.alias, asking: version }); + const answer = await flow.confirm({ alias: this.alias, text: installPromptText(this.alias, version, probe) }); + if (!stale()) this.set({ alias: this.alias }); + if (answer !== undefined && !stale()) await flow.consent.set(this.alias, answer ? "allow" : "deny").catch(log); + return answer === true; + } + + private async install( + flow: ReviewRemoteInstallFlow, + env: NodeJS.ProcessEnv, + probe: ReviewRemoteProbe, + target: ReviewRemoteTarget, + version: string, + artifact: ReviewRemoteArtifact, + present: boolean, + stale: () => boolean, + ): Promise<{ path: ReviewRemoteInstallResult } | { failed: string } | undefined> { + const abort = new AbortController(); + this.installing = abort; + let step: InstallStep = "preparing"; + if (!present) this.set({ alias: this.alias, installing: { step } }); + try { + const path = await flow.run({ + session: this.options.session, + probe, + target, + version, + artifact, + spawn: this.options.spawn, + env, + signal: abort.signal, + onProgress: (progress) => { + if (stale()) return; + step = progress.step; + if (step === "done" && !this.reported.installing) return; + this.set({ alias: this.alias, installing: { step } }); + }, + }); + return { path }; + } catch (error) { + if (stale()) return undefined; + const message = (error as Error).message; + // ssh exits 255 when the connection drops. + if (/\bexit 255\b/.test(message) || !this.master || gone(this.master)) { + throw unreachable(`The connection to ${this.alias} dropped while installing Whiteboard ${version} (${STEP_WORDS[step]}).`); + } + return { failed: installFailureText(this.alias, version, step, message) }; + } finally { + if (this.installing === abort) this.installing = undefined; + } + } + + detectAgents(): Promise { + if (this.agents) return Promise.resolve(this.agents); + return (this.detecting ??= this.readAgents().finally(() => (this.detecting = undefined))); + } + + private async readAgents(): Promise { + const env = this.env; + if (this.disposed || !this.master || this.connectedAt === undefined || !env) return undefined; + const generation = this.generation; + const result = await this.run(sshExecArgs(this.options.session, env), this.timeouts.agents, remoteConnectScript(this.cli, ["--detect", "--json"])); + if (generation !== this.generation || this.disposed) return undefined; + const agents = parseRemoteAgents(result.stdout); + if (!agents) { + this.options.log(`${this.alias}: could not read its agents: ${result.timedOut ? `no answer within ${this.timeouts.agents / 1000} seconds` : plain(firstLines(result.stderr)) || `exit ${result.code ?? "none"}`}.`); + return undefined; + } + return (this.agents = agents); + } + + async connectAgents(ids: readonly unknown[]): Promise { + const known = await this.detectAgents(); + if (!known) throw new Error(`${this.alias} is not connected, or its agents could not be read.`); + const wanted = [...new Set(ids)]; + for (const id of wanted) { + if (!isRemoteAgentId(id) || !known.some((agent) => agent.id === id && !agent.manual)) { + throw new Error(`Whiteboard cannot connect ${isRemoteAgentId(id) ? id : "that agent"} on ${this.alias}.`); + } + } + const env = this.env; + if (!wanted.length) return []; + if (!this.master || !env) throw new Error(`The connection to ${this.alias} dropped; connect again to connect its agents.`); + const generation = this.generation; + const result = await this.run(sshExecArgs(this.options.session, env), this.timeouts.agentConnect * wanted.length, remoteConnectScript(this.cli, ["--yes", "--json", ...(wanted as string[])])); + const results = parseRemoteConnect(result.stdout); + if (!results) { + throw new Error( + `Connecting agents on ${this.alias} failed: ${result.timedOut ? "it did not finish in time" : plain(firstLines(result.stderr)) || `exit ${result.code ?? "none"}`}`, + ); + } + if (generation === this.generation && this.agents) { + this.agents = this.agents.map((agent) => ({ ...agent, connected: results.find((done) => done.id === agent.id)?.connected ?? agent.connected })); + } + return results; + } + + private async attach(env: NodeJS.ProcessEnv, script: AttachScript): Promise { const groups = (await this.options.groups?.()) ?? []; - const script = reviewRemoteAttachScript(groups); - const result = await this.run(sshExecArgs(this.options.session, env), this.timeouts.attach, script); + const result = await this.run(sshExecArgs(this.options.session, env), this.timeouts.attach, script(groups)); const parsed = parseRemoteAttach(result.stdout); if (parsed && "attach" in parsed) { + const { incompatibleRunning, replaced } = parsed.attach; + if (incompatibleRunning) { + throw new HostFailure({ + state: "incompatible", + detail: + incompatibleRunning.startedBy === "user" + ? `A Whiteboard server ${incompatibleRunning.version} started by a user is running on ${this.alias}; stop it to use this Desktop's version.` + : `A newer Whiteboard ${incompatibleRunning.version} is running on ${this.alias}, started by ${incompatibleRunning.startedBy === "cli" ? "the CLI" : "another Desktop"}; update this Desktop to use it.`, + }); + } + if (replaced) this.options.log(`${this.alias}: replaced its Whiteboard server ${replaced} with ${parsed.attach.version}.`); return parsed.attach; } if (parsed) throw unreachable(`whiteboard remote attach failed on ${this.alias}: ${parsed.error}`); @@ -496,7 +759,7 @@ export class ReviewRemoteHost { const version = await this.options.desktopVersion(); throw new HostFailure({ state: "not-installed", - detail: `Whiteboard is not installed on ${this.alias}. Install Whiteboard ${version} there; Node 24 is needed.`, + detail: this.installFailure ?? `Whiteboard is not installed on ${this.alias}. Install Whiteboard ${version} there.`, }); } throw unreachable(firstLines(result.stderr) || `whiteboard remote attach on ${this.alias} exited with code ${result.code ?? "none"}.`); @@ -540,6 +803,10 @@ export class ReviewRemoteHost { return { languageFeatures: true }; } + private facts(): Pick { + return { ...(this.declined && { declined: true as const }), ...(this.installFailure && { installFailure: this.installFailure }), ...(this.target && { target: this.target }) }; + } + private fail(problem: Problem): void { const attempt = this.nextAttempt(); this.generation++; @@ -550,7 +817,7 @@ export class ReviewRemoteHost { this.cancelPending = undefined; this.connectedAt = undefined; this.dropMaster(); - this.set({ alias: this.alias, problem }); + this.set({ alias: this.alias, problem, ...(this.declined && { declined: true as const }), ...(this.target && { target: this.target }) }); if (problem.state !== "unreachable" || this.disposed) return; const delay = reconnectDelay(attempt); this.cancelTimer = this.clock.schedule(delay, () => { @@ -564,6 +831,7 @@ export class ReviewRemoteHost { this.master = undefined; this.forwarded = undefined; this.language = undefined; + this.installing?.abort(); void this.close(master); } diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.test.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.test.ts index 844eb0f8f..195dcc3e3 100644 --- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.test.ts +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.test.ts @@ -4,18 +4,26 @@ *--------------------------------------------------------------------------------------------*/ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import type { ReviewGatewayHost } from "../../common/reviewProtocol.js"; -import { attachOutput, COMMIT, fakeClock, fakeSsh, flush, healthServer, until, type FakeRemote } from "./test/fakeSsh.js"; +import { attachOutput, COMMIT, detectOutput, FAKE_SERVER_ID, fakeClock, fakeSsh, flush, healthServer, until, type FakeRemote } from "./test/fakeSsh.js"; +import type { ReviewRemoteInstallFlow } from "./reviewRemoteHost.js"; import { ReviewRemoteHosts } from "./reviewRemoteHosts.js"; +import { openRemoteInstallConsent } from "./reviewRemoteInstallConsent.js"; import type { SshPromptRequest } from "./reviewSshAskpass.js"; import { reviewSshInstancePrefix } from "./reviewSshCommand.js"; -async function managerFor(t: test.TestContext, remotes: Record, answer?: string, before?: (directory: string) => Promise) { +async function managerFor( + t: test.TestContext, + remotes: Record, + answer?: string, + before?: (directory: string) => Promise, + install?: ReviewRemoteInstallFlow, +) { const port = await healthServer(t); const directory = await mkdtemp(join(tmpdir(), "wb-hosts-")); await before?.(directory); @@ -39,6 +47,7 @@ async function managerFor(t: test.TestContext, remotes: Record {}, clock, timeouts: { poll: 1 }, + install, }); t.after(async () => { await manager.dispose(); @@ -188,15 +197,341 @@ test("an entry the sweep cannot remove does not stop the sweep or the hosts", as }); test("a window gets the VS Code server of a machine only while the gateway has it online", async (t) => { - const attach = { code: 0, stdout: attachOutput(41234, "remote-token", { port: 45678, connectionToken: "vscode-token", commit: COMMIT }) }; + const attach = { code: 0, stdout: attachOutput(41234, "remote-token", { languageServer: { port: 45678, connectionToken: "vscode-token", commit: COMMIT } }) }; const { manager, sentUntil, port } = await managerFor(t, { "wb-test-a": { attach } }); manager.update(true, ["wb-test-a"]); await sentUntil((hosts) => byAlias(hosts, "wb-test-a")?.languageFeatures === true); - const online = [{ alias: "wb-test-a", serverId: "s1", state: "online" as const }]; - assert.deepEqual(await manager.languageEndpoint("s1", online), { host: "127.0.0.1", port, connectionToken: "vscode-token" }); - assert.equal(await manager.languageEndpoint("s1", [{ ...online[0], state: "duplicate" }]), undefined); - assert.equal(await manager.languageEndpoint("s1", [{ ...online[0], serverId: "s2" }]), undefined); - assert.equal(await manager.languageEndpoint("s1", []), undefined); + const online = [{ alias: "wb-test-a", serverId: FAKE_SERVER_ID, state: "online" as const }]; + assert.deepEqual(await manager.languageEndpoint(FAKE_SERVER_ID, online), { host: "127.0.0.1", port, connectionToken: "vscode-token" }); + assert.equal(await manager.languageEndpoint(FAKE_SERVER_ID, [{ ...online[0], state: "duplicate" }]), undefined); + assert.equal(await manager.languageEndpoint(FAKE_SERVER_ID, [{ ...online[0], serverId: "s2" }]), undefined); + assert.equal(await manager.languageEndpoint(FAKE_SERVER_ID, []), undefined); +}); + +const INSTALLED_SHA256 = "a".repeat(64); +const INSTALLED = { nodePath: "/n/bin/node", cliPath: "/v/cli.js" }; + +test("Install on a declined host stores the agreement, installs and attaches", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "wb-consent-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const consentFile = join(dir, "c.json"); + const runs: string[] = []; + const flow: ReviewRemoteInstallFlow = { + mode: () => "ask", + consent: openRemoteInstallConsent(consentFile), + confirm: async () => false, + run: async (input) => { + runs.push(input.version); + return INSTALLED; + }, + origin: () => "https://install.dev.fast", + artifact: async (target, version) => ({ name: `whiteboard-remote-${version}-${target}.tar.xz`, url: "https://install.dev.fast/x", sha256: INSTALLED_SHA256 }), + }; + const { manager, sentUntil } = await managerFor( + t, + { "wb-test-a": { attach: (call) => (call === 1 ? { code: 127 } : { code: 0, stdout: attachOutput(41234) }) } }, + undefined, + undefined, + flow, + ); + + manager.update(true, ["wb-test-a"]); + await sentUntil((hosts) => hosts[0].declined === true); + await manager.install("wb-test-a"); + await sentUntil((hosts) => hosts[0].endpoint !== undefined); + + assert.deepEqual(runs, ["0.1.6"]); + assert.equal(await flow.consent.get("wb-test-a"), "allow"); +}); + +test("two hosts asking at once are asked one after the other", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "wb-consent-")); + t.after(() => rm(dir, { recursive: true, force: true })); + let open = 0; + const asked: string[] = []; + const flow: ReviewRemoteInstallFlow = { + mode: () => "ask", + consent: openRemoteInstallConsent(join(dir, "c.json")), + confirm: async ({ alias }) => { + assert.equal(open++, 0, `${alias} was asked while another prompt was open`); + asked.push(alias); + await new Promise((resolve) => setTimeout(resolve, 30)); + open--; + return true; + }, + run: async () => INSTALLED, + origin: () => "https://install.dev.fast", + artifact: async (target, version) => ({ name: `whiteboard-remote-${version}-${target}.tar.xz`, url: "https://install.dev.fast/x", sha256: INSTALLED_SHA256 }), + }; + const other = { code: 0, stdout: attachOutput(41234, "remote-token", { serverId: "wb-test-b-server" }) }; + const { manager, sentUntil } = await managerFor(t, { "wb-test-a": {}, "wb-test-b": { attach: other } }, undefined, undefined, flow); + + manager.update(true, ["wb-test-a", "wb-test-b"]); + await sentUntil((hosts) => hosts.every((host) => host.endpoint)); + + assert.deepEqual(asked.sort(), ["wb-test-a", "wb-test-b"]); + assert.equal(await flow.consent.get("wb-test-a"), "allow"); + assert.equal(await flow.consent.get("wb-test-b"), "allow"); +}); + +async function promptingFlow(t: test.TestContext) { + const dir = await mkdtemp(join(tmpdir(), "wb-consent-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const consentFile = join(dir, "c.json"); + const asked: { alias: string; signal?: AbortSignal; answer(value: boolean | undefined): void }[] = []; + const runs: string[] = []; + const flow: ReviewRemoteInstallFlow = { + mode: () => "ask", + consent: openRemoteInstallConsent(consentFile), + confirm: (request) => + new Promise((resolve) => { + asked.push({ alias: request.alias, signal: request.signal, answer: resolve }); + request.signal?.addEventListener("abort", () => resolve(undefined)); + }), + run: async (input) => { + runs.push(input.version); + return INSTALLED; + }, + origin: () => "https://install.dev.fast", + artifact: async (target, version) => ({ name: `whiteboard-remote-${version}-${target}.tar.xz`, url: "https://install.dev.fast/x", sha256: INSTALLED_SHA256 }), + }; + return { flow, asked, runs, consentFile }; +} + +test("a retry while the install prompt is open joins it: one prompt, one answer applied", async (t) => { + const { flow, asked, runs } = await promptingFlow(t); + const { manager, sentUntil } = await managerFor(t, { "wb-test-a": {} }, undefined, undefined, flow); + + manager.update(true, ["wb-test-a"]); + await until(() => asked.length === 1); + manager.retry("wb-test-a"); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(asked.length, 1); + assert.equal(asked[0].signal?.aborted, false); + asked[0].answer(true); + await sentUntil((hosts) => hosts[0].endpoint !== undefined); + + assert.equal(asked.length, 1); + assert.deepEqual(runs, ["0.1.6"]); + assert.equal(await flow.consent.get("wb-test-a"), "allow"); +}); + +test("removing a host while its prompt is open closes the prompt and writes nothing", async (t) => { + const { flow, asked, consentFile } = await promptingFlow(t); + const { manager } = await managerFor(t, { "wb-test-a": {} }, undefined, undefined, flow); + + manager.update(true, ["wb-test-a"]); + await until(() => asked.length === 1); + manager.update(true, []); + + await until(() => asked[0].signal?.aborted === true); + await new Promise((resolve) => setTimeout(resolve, 50)); + await assert.rejects(readFile(consentFile), { code: "ENOENT" }); +}); + +test("a dropped connection closes the host's prompt, and the next connection asks again", async (t) => { + const { flow, asked, consentFile } = await promptingFlow(t); + const { manager, ssh, clock } = await managerFor(t, { "wb-test-a": {} }, undefined, undefined, flow); + + manager.update(true, ["wb-test-a"]); + await until(() => asked.length === 1); + ssh.master("wb-test-a")!.finish(255, { stderr: "Connection reset by peer\n" }); + await until(() => asked[0].signal?.aborted === true); + await assert.rejects(readFile(consentFile), { code: "ENOENT" }); + + await until(() => { + clock.next(); + return asked.length === 2; + }); + assert.equal(asked[1].signal?.aborted, false); +}); + +test("quitting while a prompt is open closes it without an unhandled rejection", async (t) => { + const rejections: unknown[] = []; + const record = (reason: unknown) => rejections.push(reason); + process.on("unhandledRejection", record); + t.after(() => void process.off("unhandledRejection", record)); + const { flow, asked } = await promptingFlow(t); + const { manager } = await managerFor(t, { "wb-test-a": {} }, undefined, undefined, flow); + + manager.update(true, ["wb-test-a"]); + await until(() => asked.length === 1); + await manager.dispose(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.equal(asked[0].signal?.aborted, true); + assert.deepEqual(rejections, []); +}); + +test("a question cancelled while still queued is not joined: the next connection is asked", async (t) => { + const { flow, asked } = await promptingFlow(t); + const { manager, ssh, clock } = await managerFor(t, { "wb-test-a": {}, "wb-test-b": {} }, undefined, undefined, flow); + + manager.update(true, ["wb-test-a", "wb-test-b"]); + await until(() => asked.length === 1); + const other = asked[0].alias === "wb-test-a" ? "wb-test-b" : "wb-test-a"; + await until(() => ssh.of(other, "probe").length === 1); + await new Promise((resolve) => setTimeout(resolve, 20)); + ssh.master(other)!.finish(255, { stderr: "Connection reset by peer\n" }); + await until(() => { + clock.next(); + return ssh.of(other, "probe").length === 2; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + asked[0].answer(undefined); + + await until(() => asked.length === 2); + assert.equal(asked[1].alias, other); + assert.equal(asked[1].signal?.aborted, false); + asked[1].answer(undefined); +}); + +test("removing Whiteboard runs while nothing reconnects, then forgets the answer and closes the host; adding it again asks", async (t) => { + const { flow, asked, runs } = await promptingFlow(t); + let finish!: () => void; + const after = new Promise((resolve) => (finish = resolve)); + t.after(() => finish()); + const { manager, ssh, clock, sentUntil } = await managerFor(t, { "wb-test-a": { uninstall: { ok: true, after } } }, undefined, undefined, flow); + + manager.update(true, ["wb-test-a"]); + await until(() => asked.length === 1); + asked[0].answer(true); + await sentUntil((hosts) => hosts[0]?.endpoint !== undefined); + assert.equal(await flow.consent.get("wb-test-a"), "allow"); + + const before = ssh.calls.length; + const removed = manager.uninstall("wb-test-a"); + assert.equal(manager.uninstall("wb-test-a"), removed); + await until(() => ssh.of("wb-test-a", "uninstall").length === 2); + manager.reattach("wb-test-a"); + manager.resume(); + manager.retry("wb-test-a"); + while (clock.next()); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.deepEqual(ssh.calls.slice(before).map((call) => call.kind), ["uninstall", "uninstall"]); + assert.ok(ssh.master("wb-test-a")!.alive); + finish(); + await removed; + + assert.equal(await flow.consent.get("wb-test-a"), undefined); + await until(() => !ssh.master("wb-test-a")!.alive); + await sentUntil((hosts) => hosts.length === 0); + manager.update(true, ["wb-test-a"]); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(ssh.of("wb-test-a", "master").length, 1); + + manager.update(true, []); + manager.update(true, ["wb-test-a"]); + await until(() => asked.length === 2); + assert.deepEqual(runs, ["0.1.6"]); +}); + +test("a host removed without removing Whiteboard keeps the answer; a failed uninstall keeps it, and the host waits for the setting", async (t) => { + const { flow, asked } = await promptingFlow(t); + const { manager, ssh, sentUntil } = await managerFor(t, { "wb-test-a": { uninstall: { ok: false } } }, undefined, undefined, flow); + + manager.update(true, ["wb-test-a"]); + await until(() => asked.length === 1); + asked[0].answer(true); + await sentUntil((hosts) => hosts[0]?.endpoint !== undefined); + + await assert.rejects(manager.uninstall("wb-test-a"), /Could not remove Whiteboard from wb-test-a: refused/); + assert.equal(await flow.consent.get("wb-test-a"), "allow"); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(ssh.of("wb-test-a", "master").length, 1); + manager.update(true, ["wb-test-a"]); + await until(() => ssh.of("wb-test-a", "master").length === 2); + await sentUntil((hosts) => hosts[0]?.endpoint !== undefined); + + manager.update(true, []); + await until(() => !ssh.master("wb-test-a")!.alive); + assert.equal(await flow.consent.get("wb-test-a"), "allow"); + assert.equal(asked.length, 1); +}); + +test("a setting change that keeps the alias during an uninstall changes nothing until the uninstall ends", async (t) => { + const { flow, asked } = await promptingFlow(t); + let finish!: () => void; + const after = new Promise((resolve) => (finish = resolve)); + t.after(() => finish()); + const { manager, ssh, sentUntil } = await managerFor(t, { "wb-test-a": { uninstall: { ok: false, after } } }, undefined, undefined, flow); + + manager.update(true, ["wb-test-a"]); + await until(() => asked.length === 1); + asked[0].answer(true); + await sentUntil((hosts) => hosts[0]?.endpoint !== undefined); + const removed = manager.uninstall("wb-test-a"); + await until(() => ssh.of("wb-test-a", "uninstall").length === 2); + manager.update(true, ["wb-test-a"]); + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.equal(ssh.of("wb-test-a", "master").length, 1); + assert.equal(ssh.of("wb-test-a", "exit").length, 0); + assert.ok(ssh.master("wb-test-a")!.alive); + finish(); + await assert.rejects(removed, /refused/); + assert.equal(ssh.of("wb-test-a", "master").length, 1); + manager.update(true, ["wb-test-a"]); + await until(() => ssh.of("wb-test-a", "master").length === 2); +}); + +test("an alias removed during a successful uninstall is closed once", async (t) => { + const { flow, asked } = await promptingFlow(t); + let finish!: () => void; + const after = new Promise((resolve) => (finish = resolve)); + t.after(() => finish()); + const { manager, ssh, sentUntil } = await managerFor(t, { "wb-test-a": { uninstall: { ok: true, after }, exitDelayMs: 50 } }, undefined, undefined, flow); + + manager.update(true, ["wb-test-a"]); + await until(() => asked.length === 1); + asked[0].answer(true); + await sentUntil((hosts) => hosts[0]?.endpoint !== undefined); + const removed = manager.uninstall("wb-test-a"); + await until(() => ssh.of("wb-test-a", "uninstall").length === 2); + manager.update(true, []); + finish(); + await removed; + + assert.equal(ssh.master("wb-test-a")!.alive, false); + assert.equal(ssh.of("wb-test-a", "exit").length, 1); + await manager.dispose(); +}); + +test("quitting while a removed host's master closes waits for it", async (t) => { + const { flow, asked } = await promptingFlow(t); + const { manager, ssh, sentUntil } = await managerFor(t, { "wb-test-a": { uninstall: { ok: true }, exitDelayMs: 150 } }, undefined, undefined, flow); + + manager.update(true, ["wb-test-a"]); + await until(() => asked.length === 1); + asked[0].answer(true); + await sentUntil((hosts) => hosts[0]?.endpoint !== undefined); + const removed = manager.uninstall("wb-test-a"); + await until(() => ssh.of("wb-test-a", "exit").length === 1); + await manager.dispose(); + + assert.equal(ssh.master("wb-test-a")!.alive, false); + await removed; +}); + +test("a server's agents are read on their own once a session, and again only when asked", async (t) => { + const { manager, ssh, sentUntil } = await managerFor(t, { "wb-test-a": { detect: { code: 0, stdout: detectOutput([{ id: "pi", connected: false }]) } } }); + + manager.update(true, ["wb-test-a"]); + await sentUntil((hosts) => byAlias(hosts, "wb-test-a")?.endpoint !== undefined); + await until(() => ssh.of("wb-test-a", "detect").length === 1); + + manager.update(true, []); + manager.update(true, ["wb-test-a"]); + await sentUntil((hosts) => byAlias(hosts, "wb-test-a")?.endpoint !== undefined); + await until(() => ssh.of("wb-test-a", "exec").length === 2); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(ssh.of("wb-test-a", "detect").length, 1); + + assert.deepEqual(await manager.detectAgents("wb-test-a"), [{ id: "pi", connected: false }]); + assert.equal(ssh.of("wb-test-a", "detect").length, 2); + assert.equal(await manager.detectAgents("wb-test-b"), undefined); + assert.deepEqual(await manager.connectAgents("wb-test-a", ["pi"]), [{ id: "pi", connected: true, output: "" }]); + await assert.rejects(manager.connectAgents("wb-test-b", ["pi"]), /wb-test-b is not a remote host/); }); diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.ts index e51773b70..d2fabf456 100644 --- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.ts +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.ts @@ -6,9 +6,19 @@ import { readdir, rm } from "node:fs/promises"; import { createServer, type AddressInfo } from "node:net"; import { join } from "node:path"; -import type { ReviewGatewayHost, ReviewGatewayHostState } from "../../common/reviewProtocol.js"; -import { REVIEW_REMOTE_TIMEOUTS, ReviewRemoteHost, runSsh, systemClock, type ReviewRemoteClock, type ReviewRemoteHostOptions, type SpawnSsh } from "./reviewRemoteHost.js"; +import type { ReviewGatewayHost, ReviewGatewayHostState, ReviewRemoteAgent, ReviewRemoteAgentResult } from "../../common/reviewProtocol.js"; +import { + REVIEW_REMOTE_TIMEOUTS, + ReviewRemoteHost, + runSsh, + systemClock, + type ReviewRemoteClock, + type ReviewRemoteHostOptions, + type ReviewRemoteInstallFlow, + type SpawnSsh, +} from "./reviewRemoteHost.js"; import type { ReviewSshAskpass, SshPromptRequest } from "./reviewSshAskpass.js"; +import { uninstallRemote } from "./reviewRemoteUninstall.js"; import { prepareSshControlDirectory, reviewSshInstancePrefix, @@ -37,6 +47,7 @@ export interface ReviewRemoteHostsOptions { log(message: string): void; readonly clock?: ReviewRemoteClock; readonly timeouts?: ReviewRemoteHostOptions["timeouts"]; + readonly install?: ReviewRemoteInstallFlow; } export function freeLoopbackPort(): Promise { @@ -54,15 +65,41 @@ export class ReviewRemoteHosts { private readonly clock: ReviewRemoteClock; private readonly hosts = new Map(); private readonly closing = new Map>(); + private readonly uninstalling = new Map>(); + private readonly removed = new Set(); private readonly refused = new Map(); private order: string[] = []; private prepared: Promise | undefined; private sentAny = false; private disposed = false; private disposing: Promise | undefined; + private readonly agentsRead = new Set(); + + private readonly flow: ReviewRemoteInstallFlow | undefined; constructor(private readonly options: ReviewRemoteHostsOptions) { this.clock = options.clock ?? systemClock; + const flow = options.install; + let asking: Promise = Promise.resolve(); + const questions = new Map; abort: AbortController }>(); + this.flow = flow && { + ...flow, + confirm: (request) => { + const open = questions.get(request.alias); + if (open) return open.answer; + const abort = new AbortController(); + const answer = asking + .then(() => (abort.signal.aborted ? undefined : flow.confirm({ ...request, signal: abort.signal }))) + .finally(() => questions.get(request.alias)?.abort === abort && questions.delete(request.alias)); + asking = answer.catch(() => undefined); + questions.set(request.alias, { answer, abort }); + return answer; + }, + cancel: (alias) => { + questions.get(alias)?.abort.abort(); + questions.delete(alias); + }, + }; } update(enabled: boolean, aliases: readonly string[]): void { @@ -76,7 +113,9 @@ export class ReviewRemoteHosts { } this.order = wanted; this.refused.clear(); + for (const alias of this.removed) if (!wanted.includes(alias)) this.removed.delete(alias); for (const alias of wanted) { + if (this.removed.has(alias)) continue; const valid = validateSshAlias(alias); if (!valid.ok) { this.refused.set(alias, { alias, problem: { state: "unreachable", detail: `The SSH alias ${JSON.stringify(alias)} ${valid.reason}.` } }); @@ -84,7 +123,9 @@ export class ReviewRemoteHosts { } const existing = this.hosts.get(alias); if (existing) { - if (existing.state.problem && existing.state.problem.state !== "unreachable") existing.retry(); + if (existing.quiesced) { + if (!this.uninstalling.has(alias)) existing.unquiesce(); + } else if (existing.state.problem && existing.state.problem.state !== "unreachable") existing.retry(); continue; } const host = this.createHost(alias); @@ -98,9 +139,61 @@ export class ReviewRemoteHosts { } retry(alias: string): void { + const host = this.hosts.get(alias); + if (host?.quiesced && !this.uninstalling.has(alias)) host.unquiesce(); + else host?.retry(); + } + + async install(alias: string): Promise { + await this.options.install?.consent.set(alias, "allow"); this.hosts.get(alias)?.retry(); } + detectAgents(alias: string): Promise { + return this.hosts.get(alias)?.detectAgents() ?? Promise.resolve(undefined); + } + + connectAgents(alias: string, ids: readonly unknown[]): Promise { + const host = this.hosts.get(alias); + if (!host) return Promise.reject(new Error(`${alias} is not a remote host in Settings.`)); + return host.connectAgents(ids); + } + + uninstall(alias: string): Promise { + const running = this.uninstalling.get(alias); + if (running) return running; + const done = this.uninstallOnce(alias).finally(() => this.uninstalling.delete(alias)); + this.uninstalling.set(alias, done); + return done; + } + + private async uninstallOnce(alias: string): Promise { + const valid = validateSshAlias(alias); + if (!valid.ok) throw new Error(`The SSH alias ${JSON.stringify(alias)} ${valid.reason}.`); + const host = this.hosts.get(alias); + host?.quiesce(); + try { + const env = { ...(await this.options.environment()), ...(await this.askpass()).env(alias) }; + await uninstallRemote({ session: reviewSshSession(alias, this.options.controlDirectory, this.options.instance), spawn: this.options.spawn, env }); + } catch (error) { + this.options.log(`${alias}: Whiteboard was not removed; the host waits for the setting.`); + throw error; + } + await this.options.install?.consent.forget(alias); + if (host && this.hosts.get(alias) === host) { + this.hosts.delete(alias); + this.removed.add(alias); + this.publish(); + } + if (!host) return; + let closed = this.closing.get(host); + if (!closed) { + closed = host.dispose().finally(() => this.closing.delete(host)); + this.closing.set(host, closed); + } + await closed; + } + reattach(alias: string): void { void this.hosts.get(alias)?.reattach(); } @@ -117,9 +210,9 @@ export class ReviewRemoteHosts { dispose(): Promise { return (this.disposing ??= (async () => { this.disposed = true; - const hosts = [...this.hosts.values(), ...this.closing.keys()]; + const hosts = [...this.hosts.values()]; this.hosts.clear(); - await Promise.all(hosts.map((host) => host.dispose())); + await Promise.all([...hosts.map((host) => host.dispose()), ...this.closing.values()]); (await this.prepared?.catch(() => undefined))?.dispose(); })()); } @@ -141,6 +234,8 @@ export class ReviewRemoteHosts { log: this.options.log, clock: this.clock, timeouts: this.options.timeouts, + install: this.flow, + firstAttach: (key) => !this.agentsRead.has(key) && !!this.agentsRead.add(key), }); } @@ -193,6 +288,6 @@ export class ReviewRemoteHosts { private publish(): void { if (this.disposed || (!this.sentAny && !this.order.length)) return; this.sentAny = true; - this.options.send(this.order.map((alias) => this.refused.get(alias) ?? this.hosts.get(alias)!.state)); + this.options.send(this.order.flatMap((alias) => this.refused.get(alias) ?? this.hosts.get(alias)?.state ?? [])); } } diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstallConsent.test.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstallConsent.test.ts new file mode 100644 index 000000000..a53b34b74 --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstallConsent.test.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { openRemoteInstallConsent } from "./reviewRemoteInstallConsent.js"; + +async function file(t: test.TestContext) { + const dir = await mkdtemp(join(tmpdir(), "wb-consent-")); + t.after(() => rm(dir, { recursive: true, force: true })); + return join(dir, "remote-install-consent.json"); +} + +test("an answer is kept per alias, owner-only, until the user changes it", async (t) => { + const path = await file(t); + const consent = openRemoteInstallConsent(path); + + await consent.set("box", "deny"); + assert.equal(await openRemoteInstallConsent(path).get("box"), "deny"); + await consent.set("box", "allow"); + assert.equal(await consent.get("box"), "allow"); + assert.equal(await consent.get("other"), undefined); + assert.deepEqual(JSON.parse(await readFile(path, "utf8")), { aliases: { box: "allow" } }); + assert.equal((await stat(path)).mode & 0o777, 0o600); +}); + +test("an unreadable file is no consent, and is replaced on the next answer", async (t) => { + const path = await file(t); + await writeFile(path, "{not json"); + const consent = openRemoteInstallConsent(path); + + assert.equal(await consent.get("box"), undefined); + await consent.set("box", "allow"); + assert.equal(await consent.get("box"), "allow"); +}); diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstallConsent.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstallConsent.ts new file mode 100644 index 000000000..518ec0b3f --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstallConsent.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { randomBytes } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +export type ReviewRemoteInstallAnswer = "allow" | "deny"; + +export interface ReviewRemoteInstallConsent { + get(alias: string): Promise; + set(alias: string, answer: ReviewRemoteInstallAnswer): Promise; + forget(alias: string): Promise; +} + +type Stored = Record; + +export const reviewRemoteInstallConsentPath = (userDataPath: string) => join(userDataPath, "remote-install-consent.json"); + +export function openRemoteInstallConsent(path: string): ReviewRemoteInstallConsent { + let queue: Promise = Promise.resolve(); + const serial = (run: () => Promise): Promise => { + const next = queue.then(run, run); + queue = next.catch(() => undefined); + return next; + }; + + async function read(): Promise { + const stored: Stored = Object.create(null); + let value: { aliases?: Record }; + try { + value = JSON.parse(await readFile(path, "utf8")); + } catch { + return stored; + } + for (const [alias, answer] of Object.entries(value?.aliases ?? {})) { + if (answer === "allow" || answer === "deny") stored[alias] = answer; + } + return stored; + } + + const update = (change: (stored: Stored) => void) => + serial(async () => { + const stored = await read(); + const before = JSON.stringify(stored); + change(stored); + if (JSON.stringify(stored) === before) return; + await mkdir(dirname(path), { recursive: true }); + const part = `${path}.${randomBytes(4).toString("hex")}.part`; + try { + await writeFile(part, JSON.stringify({ aliases: stored }), { mode: 0o600 }); + await rename(part, path); + } finally { + await rm(part, { force: true }); + } + }); + + return { + get: (alias) => serial(async () => (await read())[alias]), + set: (alias, answer) => update((stored) => void (stored[alias] = answer)), + forget: (alias) => update((stored) => void delete stored[alias]), + }; +} diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewSshCommand.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewSshCommand.ts index 4f41b8582..8c63a4e52 100644 --- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewSshCommand.ts +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewSshCommand.ts @@ -82,8 +82,8 @@ export function sshMasterArgs(session: ReviewSshSession, env: NodeJS.ProcessEnv ]; } -export function sshExecArgs(session: ReviewSshSession, env: NodeJS.ProcessEnv = process.env): string[] { - return [...base(session, env), "-oControlMaster=no", "-T", "--", session.alias, "sh", "-s"]; +export function sshExecArgs(session: ReviewSshSession, env: NodeJS.ProcessEnv = process.env, command: readonly string[] = ["sh", "-s"]): string[] { + return [...base(session, env), "-oControlMaster=no", "-T", "--", session.alias, ...command]; } function localForward(localPort: number, remotePort: number): string { diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopChannel.test.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopChannel.test.ts index 25812b44e..e85dc4f70 100644 --- a/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopChannel.test.ts +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopChannel.test.ts @@ -61,6 +61,25 @@ test("relays ssh prompts to the window and takes its answer", async () => { relay.dispose(); }); +test("reads and connects a remote host's agents through the host, refusing malformed calls", async () => { + const calls: unknown[] = []; + const host = { + detectRemoteAgents: async (alias: string) => (calls.push(["detect", alias]), alias === "gpu" ? [{ id: "pi", connected: false }] : undefined), + connectRemoteAgents: async (alias: string, ids: unknown[]) => (calls.push(["connect", alias, ids]), [{ id: "pi", connected: true, output: "" }]), + }; + const channel = new ReviewDesktopChannel(host as never, { getWindows: () => [] } as never, () => false); + + assert.deepEqual(await channel.call("", "detectRemoteAgents", "gpu"), [{ id: "pi", connected: false }]); + assert.equal(await channel.call("", "detectRemoteAgents", "devbox"), null); + assert.deepEqual(await channel.call("", "connectRemoteAgents", { alias: "gpu", agents: ["pi"] }), [{ id: "pi", connected: true, output: "" }]); + for (const bad of [undefined, "gpu", { alias: 7, agents: ["pi"] }, { alias: "gpu", agents: "pi" }, { alias: "gpu", agents: Array(17).fill("pi") }]) { + await assert.rejects(channel.call("", "connectRemoteAgents", bad), /Unknown agents/); + } + await assert.rejects(channel.call("", "detectRemoteAgents", 7), /Unknown agents/); + + assert.deepEqual(calls, [["detect", "gpu"], ["detect", "devbox"], ["connect", "gpu", ["pi"]]]); +}); + test("hands a window a remote machine's VS Code server by its server id, and nothing for anything else", async () => { const asked: string[] = []; const endpoint = { host: "127.0.0.1", port: 50123, connectionToken: "vscode-token" }; diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopChannel.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopChannel.ts index 9aea2cbec..a538b0703 100644 --- a/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopChannel.ts +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopChannel.ts @@ -8,7 +8,10 @@ import { IServerChannel } from "../../base/parts/ipc/common/ipc.js"; import type { IWindowsMainService } from "../../platform/windows/electron-main/windows.js"; import type { ReviewDesktopConnection } from "../common/reviewDesktopBootstrap.js"; import { REVIEW_SSH_ANSWER_CALL, REVIEW_SSH_PROMPT_EVENT } from "../common/reviewSshPrompt.js"; -import { reviewSshPromptRelay, type ReviewSshPromptRelay } from "./remote/reviewSshPromptRelay.js"; +import { + reviewSshPromptRelay, + type ReviewSshPromptRelay, +} from "./remote/reviewSshPromptRelay.js"; import type { ReviewDesktopHost } from "./reviewDesktopHost.js"; export { REVIEW_DESKTOP_CHANNEL } from "../common/reviewDesktopBootstrap.js"; @@ -58,6 +61,23 @@ export class ReviewDesktopChannel implements IServerChannel { if (typeof arg === "string") this.host.retryRemoteHost(arg); return undefined as T; } + if (command === "detectRemoteAgents") { + if (typeof arg !== "string") throw new Error("Unknown agents request."); + return ((await this.host.detectRemoteAgents(arg)) ?? null) as T; + } + if (command === "connectRemoteAgents") { + const { alias, agents } = (arg ?? {}) as { alias?: unknown; agents?: unknown }; + if (typeof alias !== "string" || !Array.isArray(agents) || agents.length > 16) throw new Error("Unknown agents request."); + return (await this.host.connectRemoteAgents(alias, agents)) as T; + } + if (command === "installRemoteHost") { + if (typeof arg === "string") await this.host.installRemoteHost(arg); + return undefined as T; + } + if (command === "uninstallRemoteHost") { + if (typeof arg !== "string") throw new Error("uninstallRemoteHost needs an alias."); + return (await this.host.uninstallRemoteHost(arg)) as T; + } if (command === REVIEW_SSH_ANSWER_CALL) { const { id, answer } = (arg ?? {}) as { id?: unknown; answer?: unknown }; if (typeof id === "number") diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopHost.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopHost.ts index 59100d55a..fa0ad8c39 100644 --- a/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopHost.ts +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopHost.ts @@ -21,19 +21,43 @@ import { NullTelemetryService } from "../../platform/telemetry/common/telemetryU import { IUpdateService } from "../../platform/update/common/update.js"; import { UtilityProcess } from "../../platform/utilityProcess/electron-main/utilityProcess.js"; import type { ReviewDesktopConnection } from "../common/reviewDesktopBootstrap.js"; -import type { ReviewGatewayHostState } from "../common/reviewProtocol.js"; +import type { + ReviewGatewayHostState, + ReviewRemoteAgent, + ReviewRemoteAgentResult, +} from "../common/reviewProtocol.js"; import { REVIEW_REMOTE_HOSTS_ENABLED_SETTING, REVIEW_REMOTE_HOSTS_SETTING, + REVIEW_REMOTE_INSTALL_MODES, + REVIEW_REMOTE_INSTALL_SETTING, REVIEW_TELEMETRY_SETTING, } from "../common/reviewConfigurationDefaults.js"; import { remoteHostAliases } from "../common/reviewSshAlias.js"; +import { + REVIEW_REMOTE_INSTALL_NO, + REVIEW_REMOTE_INSTALL_YES, +} from "../common/reviewSshPrompt.js"; import { REVIEW_CRASH_DUMPS_DIRNAME } from "../node/reviewCrashReporter.js"; import { ReviewCrashDumps } from "./reviewCrashDumps.js"; import { ReviewCrashTelemetry } from "./reviewCrashTelemetry.js"; import { ReviewMainErrorTelemetry } from "./reviewMainErrorTelemetry.js"; import { reviewEnabledExtensionGroups } from "./remote/reviewEnabledExtensionGroups.js"; +import { + REVIEW_REMOTE_RELEASES_ENV, + remoteArtifact, + remoteReleasesOrigin, +} from "./remote/reviewRemoteArtifacts.js"; +import type { + ReviewRemoteInstallFlow, + ReviewRemoteInstallMode, +} from "./remote/reviewRemoteHost.js"; import { ReviewRemoteHosts } from "./remote/reviewRemoteHosts.js"; +import { + openRemoteInstallConsent, + reviewRemoteInstallConsentPath, +} from "./remote/reviewRemoteInstallConsent.js"; +import { installRemote } from "./remote/reviewRemoteInstaller.js"; import { createSshAskpass } from "./remote/reviewSshAskpass.js"; import { reviewSshConfigPath, @@ -247,6 +271,67 @@ export class ReviewDesktopHost extends Disposable { } } + installRemoteHost(alias: string): Promise { + return this.remoteHosts?.install(alias) ?? Promise.resolve(); + } + + detectRemoteAgents(alias: string): Promise { + return this.remoteHosts?.detectAgents(alias) ?? Promise.resolve(undefined); + } + + connectRemoteAgents(alias: string, ids: readonly unknown[]): Promise { + if (!this.remoteHosts) return Promise.reject(new Error("Remote hosts are off.")); + return this.remoteHosts.connectAgents(alias, ids); + } + + async uninstallRemoteHost(alias: string): Promise { + if (!this.remoteHosts) return "Remote hosts are off."; + try { + await this.remoteHosts.uninstall(alias); + return null; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } + + private remoteInstallFlow(): ReviewRemoteInstallFlow { + const { userDataPath } = this.environmentMainService; + const artifactsOptions = { + pin: this.productService.whiteboardRemote, + releases: process.env[REVIEW_REMOTE_RELEASES_ENV], + }; + return { + mode: () => { + const mode = this.configurationService.getValue( + REVIEW_REMOTE_INSTALL_SETTING, + ); + return REVIEW_REMOTE_INSTALL_MODES.includes( + mode as ReviewRemoteInstallMode, + ) + ? (mode as ReviewRemoteInstallMode) + : "ask"; + }, + consent: openRemoteInstallConsent( + reviewRemoteInstallConsentPath(userDataPath), + ), + confirm: async (request) => { + const answer = await reviewSshPromptRelay.prompt({ + ...request, + kind: "install", + }); + return answer === REVIEW_REMOTE_INSTALL_YES + ? true + : answer === REVIEW_REMOTE_INSTALL_NO + ? false + : undefined; + }, + run: installRemote, + origin: () => remoteReleasesOrigin(artifactsOptions), + artifact: (target, version) => + remoteArtifact(target, version, artifactsOptions), + }; + } + private startRemoteHosts( shellEnvironment: () => Promise, ): void { @@ -268,6 +353,7 @@ export class ReviewDesktopHost extends Disposable { reviewEnabledExtensionGroups(this.environmentMainService.extensionsPath), send: (hosts) => this.supervisor.setRemoteHosts(hosts), log: (message) => this.logService.info(`[Remote hosts] ${message}`), + install: this.remoteInstallFlow(), }); this.remoteHosts = manager; const update = () => { @@ -295,7 +381,8 @@ export class ReviewDesktopHost extends Disposable { this.configurationService.onDidChangeConfiguration((event) => { if ( event.affectsConfiguration(REVIEW_REMOTE_HOSTS_SETTING) || - event.affectsConfiguration(REVIEW_REMOTE_HOSTS_ENABLED_SETTING) + event.affectsConfiguration(REVIEW_REMOTE_HOSTS_ENABLED_SETTING) || + event.affectsConfiguration(REVIEW_REMOTE_INSTALL_SETTING) ) update(); }), diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.test.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.test.ts index 066e353b5..0b11f2101 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.test.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.test.ts @@ -149,7 +149,7 @@ test("reads the remote hosts' states from the Desktop server", async (t) => { const service = serviceWith(); t.after(() => service.dispose()); const requests: string[] = []; - const answer = [{ alias: "devbox", serverId: "s1", state: "online" }, { alias: "box2", state: "not-installed", detail: "Install it.", installCommand: "npm install -g @dev.fast/whiteboard@0.1.6" }]; + const answer = [{ alias: "devbox", serverId: "s1", state: "online" }, { alias: "box2", state: "not-installed", detail: "Install it.", installCommand: "npm install -g @dev.fast/whiteboard@0.1.6", declined: true }]; mockFetch(t, async (input, init) => { requests.push(`${String(input)} ${new Headers(init?.headers).get("x-review-token")}`); return Response.json(answer); diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.ts index 4193be0ba..8c214ffea 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.ts @@ -27,6 +27,9 @@ parseReviewTutorialOpenResponse, type ReviewCliInstallApplyResponse, type ReviewCliInstallStatus, type ReviewGatewayHostState, +type ReviewRemoteAgent, +type ReviewRemoteAgentId, +type ReviewRemoteAgentResult, type ReviewTutorialOpenResponse, type ReviewVerbResponse } from "../common/reviewProtocol.js"; @@ -78,6 +81,10 @@ export interface IReviewDesktopConnectionService { listSshAliases(): Promise; retryRemoteHost(alias: string): Promise; getRemoteLanguageEndpoint(serverId: string): Promise; + installRemoteHost(alias: string): Promise; + detectRemoteAgents(alias: string): Promise; + connectRemoteAgents(alias: string, agents: ReviewRemoteAgentId[]): Promise; + uninstallRemoteHost(alias: string): Promise; getTutorialStatus(): Promise<{ version: 1; reviewUuid: string | null }>; prepareTutorial(): Promise; openTutorial(): Promise; @@ -259,6 +266,23 @@ export class ReviewDesktopConnectionService extends Disposable implements IRevie : undefined; } + async installRemoteHost(alias: string): Promise { + await this.mainProcessService.getChannel(REVIEW_DESKTOP_CHANNEL).call("installRemoteHost", alias); + } + + detectRemoteAgents(alias: string): Promise { + return this.mainProcessService.getChannel(REVIEW_DESKTOP_CHANNEL).call("detectRemoteAgents", alias); + } + + connectRemoteAgents(alias: string, agents: ReviewRemoteAgentId[]): Promise { + return this.mainProcessService.getChannel(REVIEW_DESKTOP_CHANNEL).call("connectRemoteAgents", { alias, agents }); + } + + async uninstallRemoteHost(alias: string): Promise { + const problem: unknown = await this.mainProcessService.getChannel(REVIEW_DESKTOP_CHANNEL).call("uninstallRemoteHost", alias); + if (typeof problem === "string") throw new Error(problem); + } + async saveDiffrSummarizer(input: ReviewDiffrSummarizerInput): Promise { await this.initialize(); const response = await fetch(`${this.serverUrl}/diffr-config/summarizer`, { diff --git a/packages/review/src/cli-runner.ts b/packages/review/src/cli-runner.ts index c5beffbeb..d4eb83d65 100644 --- a/packages/review/src/cli-runner.ts +++ b/packages/review/src/cli-runner.ts @@ -564,6 +564,10 @@ export async function runReviewCli(input: ReviewCliInput): Promise { .option( "--groups ", "comma-separated optional extension groups the Desktop has enabled, such as go", + ) + .option( + "--replace", + "stop a running server of another version that the CLI or Desktop started, and start this one", ), "plain", ).action(async (_options, command: Command) => { @@ -571,6 +575,7 @@ export async function runReviewCli(input: ReviewCliInput): Promise { stateDir?: string; json?: boolean; groups?: string; + replace?: boolean; }>(); const stateDir = reviewServerStateDir(authoringEnv(options.stateDir)); @@ -584,6 +589,7 @@ export async function runReviewCli(input: ReviewCliInput): Promise { stateDir, env, groups: options.groups?.split(",").map((group) => group.trim()), + replace: options.replace, }); } catch (error) { if (!options.json) throw error; diff --git a/packages/review/src/remote-attach.ts b/packages/review/src/remote-attach.ts index 440ef86a1..aab05f3fd 100644 --- a/packages/review/src/remote-attach.ts +++ b/packages/review/src/remote-attach.ts @@ -1,3 +1,6 @@ +import { gt as greaterVersion, valid as validVersion } from "semver"; + +import { readReviewPackageVersion } from "./package-paths"; import { type EnsureRemoteLanguageServerInput, ensureRemoteLanguageServer, @@ -7,6 +10,8 @@ import { readReviewServerHealth, serverNotReady } from "./server-discovery"; import { type EnsureBackgroundServerInput, ensureBackgroundServer, + recordedBackgroundServer, + stopBackgroundServer, } from "./server/background-server"; export async function remoteAttach(input: { @@ -17,6 +22,8 @@ export async function remoteAttach(input: { groups?: string[]; ensureExtensions?: EnsureRemoteLanguageServerInput["ensure"]; installTimeoutMs?: number; + replace?: boolean; + version?: string; }) { const extensions = new AbortController(); @@ -33,8 +40,26 @@ export async function remoteAttach(input: { const toolchains = missingToolchains(input.groups ?? [], input.env); let server: Awaited>; + let previousVersion: string | undefined; + + let incompatibleRunning: + | { version: string; pid: number; startedBy: "user" | "cli" | "desktop" } + | undefined; try { + const version = input.version ?? readReviewPackageVersion(import.meta.url); + + const other = input.replace + ? await otherVersionRunning(input.stateDir, version) + : undefined; + + if (other && (other.startedBy === "user" || newer(other.version, version))) + incompatibleRunning = other; + else if (other) { + await stopBackgroundServer({ serverPid: other.pid }); + previousVersion = other.version; + } + server = await ensureBackgroundServer({ stateDir: input.stateDir, env: input.env, @@ -78,5 +103,28 @@ export async function remoteAttach(input: { return { group, installed, ...(details && { detail: details }) }; }), + ...(previousVersion !== undefined && { replaced: true, previousVersion }), + ...(incompatibleRunning && { incompatibleRunning }), }; } + +const newer = (running: string, own: string) => + validVersion(running) !== null && + validVersion(own) !== null && + greaterVersion(running, own); + +async function otherVersionRunning(stateDir: string, version: string) { + const recorded = await recordedBackgroundServer(stateDir); + + if (!recorded) return undefined; + const { discovery, health } = recorded; + const running = health.version ?? "unknown"; + + return running === version + ? undefined + : { + version: running, + pid: discovery.serverPid, + startedBy: discovery.startedBy, + }; +} diff --git a/packages/review/src/server/background-server.test.ts b/packages/review/src/server/background-server.test.ts index bd42e7817..3f63ec6a4 100644 --- a/packages/review/src/server/background-server.test.ts +++ b/packages/review/src/server/background-server.test.ts @@ -294,6 +294,110 @@ it("attaches with one JSON line between the sentinels, and its token reaches the ).not.toContain(attach.token); }, 60_000); +it("with --replace, stops a server of another version the CLI started and starts this one", async () => { + const old = await ensureBackgroundServer({ stateDir, env, cli: sourceCli }); + const oldVersion = (await readReviewServerHealth(old.discovery))!.version; + + const attach = await remoteAttach({ + stateDir, + env, + cli: sourceCli, + replace: true, + version: "9.9.9", + }); + + expect(attach).toMatchObject({ + startedServer: true, + replaced: true, + previousVersion: oldVersion, + }); + expect(attach).not.toHaveProperty("incompatibleRunning"); + expect(alive(old.discovery.serverPid)).toBe(false); + const discovery = (await readReviewServerDiscovery(stateDir))!; + expect(discovery.serverPid).not.toBe(old.discovery.serverPid); + expect(discovery.startedBy).toBe("desktop"); +}, 60_000); + +it("with --replace, leaves a server of another version a user started, and reports it", async () => { + const server = spawn( + sourceCli[0]!, + [ + ...sourceCli.slice(1), + "server", + "start", + "--json", + "--state-dir", + stateDir, + ], + { env, stdio: ["ignore", "pipe", "pipe"] }, + ); + + await readyLine(server); + const running = (await readReviewServerDiscovery(stateDir))!; + + const attach = await remoteAttach({ + stateDir, + env, + cli: sourceCli, + replace: true, + version: "9.9.9", + }); + + expect(attach).toMatchObject({ + startedServer: false, + incompatibleRunning: { + version: (await readReviewServerHealth(running))!.version, + pid: running.serverPid, + startedBy: "user", + }, + }); + expect(attach).not.toHaveProperty("replaced"); + expect(alive(running.serverPid)).toBe(true); +}, 60_000); + +it("with --replace, leaves a newer server another Desktop started, and reports it", async () => { + const newer = await ensureBackgroundServer({ + stateDir, + env, + cli: sourceCli, + startedBy: "desktop", + }); + + const attach = await remoteAttach({ + stateDir, + env, + cli: sourceCli, + replace: true, + version: "0.0.0", + }); + + expect(attach).toMatchObject({ + startedServer: false, + incompatibleRunning: { + version: (await readReviewServerHealth(newer.discovery))!.version, + pid: newer.discovery.serverPid, + startedBy: "desktop", + }, + }); + expect(attach).not.toHaveProperty("replaced"); + expect(alive(newer.discovery.serverPid)).toBe(true); +}, 60_000); + +it("with --replace, keeps a server of the same version", async () => { + const first = await cli(["remote", "attach", "--json", "--replace"]); + const second = await cli(["remote", "attach", "--json", "--replace"]); + + expect(first.code).toBe(0); + + const [started, kept] = [first, second].map((result) => + JSON.parse(result.stdout.split("\n")[1]!), + ); + + expect(started).toMatchObject({ startedServer: true }); + expect(kept).toMatchObject({ startedServer: false, token: started.token }); + expect(kept).not.toHaveProperty("replaced"); +}, 60_000); + it("prints a failed attach between the sentinels and exits non-zero", async () => { await writeFile(path.join(root, "file"), ""); diff --git a/packages/review/src/server/background-server.ts b/packages/review/src/server/background-server.ts index 88029af47..0c653e7fe 100644 --- a/packages/review/src/server/background-server.ts +++ b/packages/review/src/server/background-server.ts @@ -108,6 +108,15 @@ export async function headlessServerOwner(stateDir: string) { return liveLockOwner(headlessServerLockPath(resolved)); } +export async function recordedBackgroundServer(stateDir: string) { + const discovery = await readReviewServerDiscovery(stateDir).catch(() => null); + const health = discovery && (await readReviewServerHealth(discovery)); + + return discovery && health?.serverPid === discovery.serverPid + ? { discovery, health } + : undefined; +} + export async function stopBackgroundServer( discovery: Pick, first: NodeJS.Signals = "SIGTERM", diff --git a/packages/review/src/server/review-gateway-hosts.test.ts b/packages/review/src/server/review-gateway-hosts.test.ts index 0cad5062f..2ebcef23a 100644 --- a/packages/review/src/server/review-gateway-hosts.test.ts +++ b/packages/review/src/server/review-gateway-hosts.test.ts @@ -18,6 +18,12 @@ import { readBody, send } from "./review-gateway-transport.js"; const version = readReviewPackageVersion(import.meta.url); +const handInstall = (v: string, target = "linux-x64") => + [ + `mkdir -p ~/.dev/whiteboard-remote && rm -rf ~/.dev/whiteboard-remote/whiteboard-remote && curl -fsSL https://install.dev.fast/releases/${v}/remote/whiteboard-remote-${v}-${target}.tar.xz | tar -xJ -C ~/.dev/whiteboard-remote`, + "mkdir -p ~/.local/bin && ln -sf ~/.dev/whiteboard-remote/whiteboard-remote/whiteboard ~/.local/bin/whiteboard", + ].join("\n"); + let root: string; const closes: (() => void)[] = []; @@ -78,13 +84,11 @@ it("refuses a host on another version, naming both versions, with the install co expect(state?.detail).toContain("0.0.0-other"); expect(state?.detail).toContain("Install Whiteboard 0.0.0-other on devbox."); expect(state?.detail).not.toContain("npm install"); - expect(state?.installCommand).toBe( - "npm install -g @dev.fast/whiteboard@0.0.0-other", - ); + expect(state?.installCommand).toBe(handInstall("0.0.0-other")); expect(gateway.online()).toEqual([]); }); -it("names this machine's install command for a host without Whiteboard", () => { +it("names this machine's install command for a host without Whiteboard, for the host's arch", () => { const gateway = hosts("0.1.6"); gateway.set([ @@ -95,6 +99,14 @@ it("names this machine's install command for a host without Whiteboard", () => { detail: "Whiteboard is not installed on devbox.", }, }, + { + alias: "graviton", + target: "linux-arm64", + problem: { + state: "not-installed", + detail: "Whiteboard is not installed on graviton.", + }, + }, { alias: "other", problem: { state: "auth-failed", detail: "Permission denied." }, @@ -106,12 +118,97 @@ it("names this machine's install command for a host without Whiteboard", () => { alias: "devbox", state: "not-installed", detail: "Whiteboard is not installed on devbox.", - installCommand: "npm install -g @dev.fast/whiteboard@0.1.6", + installCommand: handInstall("0.1.6"), + }, + { + alias: "graviton", + state: "not-installed", + detail: "Whiteboard is not installed on graviton.", + installCommand: handInstall("0.1.6", "linux-arm64"), }, { alias: "other", state: "auth-failed", detail: "Permission denied." }, ]); }); +it("an unsupported host keeps its reason and gets no install command", () => { + const gateway = hosts("0.1.6"); + const detail = "This host runs glibc 2.31; Whiteboard needs 2.34 or newer."; + + gateway.set([{ alias: "old", problem: { state: "unsupported", detail } }]); + + expect(gateway.states()).toEqual([ + { alias: "old", state: "unsupported", detail }, + ]); +}); + +it("a declined host keeps the install command and says it was declined", () => { + const gateway = hosts("0.1.6"); + const detail = "Whiteboard is not installed on box."; + + gateway.set([ + { + alias: "box", + problem: { state: "not-installed", detail }, + declined: true, + }, + ]); + + expect(gateway.states()).toEqual([ + { + alias: "box", + state: "not-installed", + detail, + installCommand: handInstall("0.1.6"), + declined: true, + }, + ]); +}); + +it("an older server after a failed or declined install is incompatible, says why, and offers the install", async () => { + const fake = await startFake({ version: "0.1.5" }); + const gateway = hosts("0.1.6"); + + const installFailure = + "Installing Whiteboard 0.1.6 on box failed while installing the package: npm ERR! 404."; + + gateway.set([ + { alias: "box", endpoint: fake.endpoint, installFailure, declined: true }, + ]); + + await expect + .poll(() => gateway.states()[0]) + .toMatchObject({ + state: "incompatible", + detail: `box runs Whiteboard 0.1.5; this Desktop runs 0.1.6. Install Whiteboard 0.1.6 on box. ${installFailure}`, + declined: true, + }); +}); + +it("a declined host on this Desktop's version is online, with no install offered", async () => { + const fake = await startFake({ version: "0.1.6" }); + const gateway = hosts("0.1.6"); + + gateway.set([{ alias: "box", endpoint: fake.endpoint, declined: true }]); + expect(gateway.states()[0]).toMatchObject({ state: "connecting" }); + expect(gateway.states()[0]).not.toHaveProperty("declined"); + + await expect.poll(() => gateway.states()[0]?.state).toBe("online"); + expect(gateway.states()[0]).not.toHaveProperty("declined"); +}); + +it("a server a user started at another version is incompatible, with no install command", () => { + const gateway = hosts("0.1.7"); + + const detail = + "A Whiteboard server 0.1.6 started by a user is running on box; stop it to use this Desktop's version."; + + gateway.set([{ alias: "box", problem: { state: "incompatible", detail } }]); + + expect(gateway.states()).toEqual([ + { alias: "box", state: "incompatible", detail }, + ]); +}); + it("refuses a host whose version could not be read", async () => { const fake = await startFake({ version: "unknown" }); const gateway = hosts("unknown"); diff --git a/packages/review/src/server/review-gateway-hosts.ts b/packages/review/src/server/review-gateway-hosts.ts index 3d63a51ce..7a75747f2 100644 --- a/packages/review/src/server/review-gateway-hosts.ts +++ b/packages/review/src/server/review-gateway-hosts.ts @@ -1,6 +1,7 @@ -import type { - ReviewGatewayHost, - ReviewGatewayHostState, +import { + type REVIEW_REMOTE_INSTALL_STEPS, + type ReviewGatewayHost, + type ReviewGatewayHostState, } from "@dev.fast/review-protocol"; import { Agent } from "undici"; import { z } from "zod"; @@ -23,6 +24,16 @@ const INSTALLS = new Set([ "not-installed", ]); +const INSTALL_STEPS: Record< + (typeof REVIEW_REMOTE_INSTALL_STEPS)[number], + string +> = { + preparing: "Preparing to install Whiteboard", + "waiting-for-lock": "Waiting for another install to finish", + package: "Installing the Whiteboard package", + done: "Installed; starting the server", +}; + const healthSchema = z.object({ ok: z.literal(true), serverId: z.string().optional(), @@ -45,6 +56,11 @@ interface Host extends GatewayRemote { languageFeatures?: boolean; languageFeaturesDetail?: string; languageGroups?: ReviewGatewayHost["languageGroups"]; + installing?: ReviewGatewayHost["installing"]; + declined?: true; + asking?: string; + installFailure?: string; + target?: ReviewGatewayHost["target"]; serverId?: string; status: ReviewGatewayHostState["state"]; detail?: string; @@ -96,13 +112,25 @@ export function createGatewayHosts(input: { detail: `${aliases.join(", ")} report the same server id, so Whiteboard uses none of them. If they are one machine, keep one alias. If they are separate machines, run \`whiteboard server stop\` and then \`whiteboard server reset-id\` on all but one.`, }; + const detail = + host.status === "incompatible" && host.installFailure + ? `${host.detail ? `${host.detail} ` : ""}${host.installFailure}` + : host.detail; + return { ...known, state: host.status, - ...(host.detail !== undefined && { detail: host.detail }), - ...(INSTALLS.has(host.status) && { - installCommand: `npm install -g @dev.fast/whiteboard@${input.version}`, - }), + ...(detail !== undefined && { detail }), + ...(INSTALLS.has(host.status) && + host.problem?.state !== "incompatible" && { + installCommand: [ + `mkdir -p ~/.dev/whiteboard-remote && rm -rf ~/.dev/whiteboard-remote/whiteboard-remote && curl -fsSL https://install.dev.fast/releases/${input.version}/remote/whiteboard-remote-${input.version}-${host.target ?? "linux-x64"}.tar.xz | tar -xJ -C ~/.dev/whiteboard-remote`, + "mkdir -p ~/.local/bin && ln -sf ~/.dev/whiteboard-remote/whiteboard-remote/whiteboard ~/.local/bin/whiteboard", + ].join("\n"), + }), + ...(host.declined && + host.status !== "online" && + host.status !== "connecting" && { declined: true as const }), ...(host.status === "online" && languageOf(host)), }; } @@ -157,14 +185,26 @@ export function createGatewayHosts(input: { status: "connecting", retryMs: FIRST_RETRY_MS, ...languageOf(given), + ...(given.target && { target: given.target }), }; if (given.endpoint) host.endpoint = given.endpoint; + if (given.declined) host.declined = true; + + if (given.installFailure) host.installFailure = given.installFailure; + if (given.problem) { host.problem = given.problem; host.status = given.problem.state; host.detail = given.problem.detail; + } else if (given.installing && !given.endpoint) { + host.installing = given.installing; + host.status = "installing"; + host.detail = `${INSTALL_STEPS[given.installing.step]}.`; + } else if (given.asking && !given.endpoint) { + host.asking = given.asking; + host.detail = `Waiting for an answer: install Whiteboard ${given.asking} on ${given.alias}?`; } else if (!given.endpoint) host.detail = `Waiting for a connection to ${given.alias}.`; else { @@ -261,13 +301,28 @@ export function createGatewayHosts(input: { if ( current && - JSON.stringify([current.endpoint, current.problem]) === - JSON.stringify([given.endpoint, given.problem]) + JSON.stringify([ + current.endpoint, + current.problem, + current.installing, + current.declined, + current.asking, + current.installFailure, + ]) === + JSON.stringify([ + given.endpoint, + given.problem, + given.installing, + given.declined, + given.asking, + given.installFailure, + ]) ) { previous.delete(given.alias); current.languageFeatures = given.languageFeatures; current.languageFeaturesDetail = given.languageFeaturesDetail; current.languageGroups = given.languageGroups; + current.target = given.target; next.push(current); } else { const host = create(given); From c18b648e384bf8653691f05f43a92839dd517d01 Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Wed, 14 Oct 2026 13:13:59 -0400 Subject: [PATCH 07/14] Uninstall Whiteboard from a host whiteboard remote uninstall stops the servers it started and removes the install, keeping reviews unless told otherwise. --- .../remote/reviewRemoteUninstall.test.ts | 58 ++ .../remote/reviewRemoteUninstall.ts | 75 +++ packages/review/src/cli-install.ts | 4 +- packages/review/src/cli-runner.ts | 97 +++ packages/review/src/remote-language-server.ts | 54 +- packages/review/src/remote-uninstall.test.ts | 564 ++++++++++++++++++ packages/review/src/remote-uninstall.ts | 280 +++++++++ 7 files changed, 1123 insertions(+), 9 deletions(-) create mode 100644 apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteUninstall.test.ts create mode 100644 apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteUninstall.ts create mode 100644 packages/review/src/remote-uninstall.test.ts create mode 100644 packages/review/src/remote-uninstall.ts diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteUninstall.test.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteUninstall.test.ts new file mode 100644 index 000000000..1a6962f77 --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteUninstall.test.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import type { SpawnSsh } from "./reviewRemoteHost.js"; +import { REVIEW_REMOTE_INSTALL_MARKER } from "./reviewRemoteInstallScript.js"; +import { uninstallRemote } from "./reviewRemoteUninstall.js"; +import { reviewSshSession } from "./reviewSshCommand.js"; + +const localRemote = + (home: string): SpawnSsh => + (args, options) => + spawn("/bin/sh", ["-c", args.slice(args.indexOf("--") + 2).join(" ")], { ...options, env: { HOME: home, PATH: "/usr/bin:/bin" } }); + +async function fixture(t: test.TestContext) { + const home = await mkdtemp(join(tmpdir(), "wb uninstall-")); + t.after(() => rm(home, { recursive: true, force: true })); + const ran = join(home, "ran"); + const version = async (name: string, cli: string) => { + const dir = join(home, ".dev/whiteboard-remote/versions", name); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "cli.js"), cli); + await writeFile(join(dir, REVIEW_REMOTE_INSTALL_MARKER), JSON.stringify({ version: name, node: process.execPath, cli: join(dir, "cli.js") })); + }; + const uninstall = () => uninstallRemote({ session: reviewSshSession("devbox", tmpdir(), "test"), spawn: localRemote(home), env: {} }); + return { home, ran, version, uninstall }; +} + +test("runs the newest version's own remote uninstall, keeping reviews", async (t) => { + const f = await fixture(t); + await f.version("0.9.0", `require("fs").writeFileSync(${JSON.stringify(f.ran)}, "old")`); + await f.version( + "0.10.0", + `require("fs").writeFileSync(${JSON.stringify(f.ran)}, process.argv.slice(2).join(" ")); +console.log("banner"); +console.log(JSON.stringify({ event: "remote.uninstall", ok: true, removed: [], keptReviews: true }));`, + ); + + await f.uninstall(); + + assert.equal(await readFile(f.ran, "utf8"), "remote uninstall --keep-reviews --json"); +}); + +test("rejects when nothing is installed, or the command fails", async (t) => { + const f = await fixture(t); + await assert.rejects(f.uninstall(), { message: "Could not remove Whiteboard from devbox: Whiteboard Desktop installed nothing there." }); + + await f.version("0.10.0", `console.error("error: unknown command 'uninstall'"); process.exit(1)`); + await assert.rejects(f.uninstall(), { message: "Could not remove Whiteboard from devbox: exit 1: error: unknown command 'uninstall'." }); +}); diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteUninstall.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteUninstall.ts new file mode 100644 index 000000000..ed35cb9db --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteUninstall.ts @@ -0,0 +1,75 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { plainText } from "./reviewRemoteAgents.js"; +import { lastLine, runSsh, type SpawnSsh } from "./reviewRemoteHost.js"; +import { compareVersions } from "./reviewRemoteInstaller.js"; +import { REVIEW_REMOTE_INSTALL_MARKER, REVIEW_REMOTE_INSTALL_SAY, REVIEW_REMOTE_ROOT_SCRIPT, REVIEW_REMOTE_VERSION, shellQuote } from "./reviewRemoteInstallScript.js"; +import { sshExecArgs, type ReviewSshSession } from "./reviewSshCommand.js"; + +export const REVIEW_REMOTE_UNINSTALL_TIMEOUT = 60_000; + +const LIST = `${REVIEW_REMOTE_ROOT_SCRIPT}printf '%s ROOT %s\\n' ${REVIEW_REMOTE_INSTALL_SAY} "$root" +for d in "$root"/versions/*; do + [ -f "$d/${REVIEW_REMOTE_INSTALL_MARKER}" ] && printf '%s HAVE %s\\n' ${REVIEW_REMOTE_INSTALL_SAY} "\${d##*/}" +done +printf '%s LISTED\\n' ${REVIEW_REMOTE_INSTALL_SAY} +`; + +function uninstallScript(root: string, version: string): string { + return `m=${shellQuote(`${root}/versions/${version}/${REVIEW_REMOTE_INSTALL_MARKER}`)} +node=$(sed -n 's/.*"node":"\\([^"]*\\)".*/\\1/p' "$m") +cli=$(sed -n 's/.*"cli":"\\([^"]*\\)".*/\\1/p' "$m") +DEV_FAST_REVIEW_CLI_NO_DELEGATE=1 "$node" "$cli" remote uninstall --keep-reviews --json { + const { session, spawn, env } = input; + const timeout = input.timeout ?? REVIEW_REMOTE_UNINSTALL_TIMEOUT; + const failed = (detail: string) => new Error(`Could not remove Whiteboard from ${session.alias}: ${detail}`); + + const listed = await runSsh(spawn, env, sshExecArgs(session, env), timeout, LIST); + const said = (word: string) => + listed.stdout + .split("\n") + .filter((line) => line.startsWith(`${REVIEW_REMOTE_INSTALL_SAY} ${word}`)) + .map((line) => line.slice(REVIEW_REMOTE_INSTALL_SAY.length + word.length + 2).trim()); + if (!said("LISTED").length) throw failed(sshProblem(listed)); + const root = said("ROOT")[0]; + if (!root?.startsWith("/")) throw failed("DEV_REVIEW_HOME there is not an absolute, normalised path."); + const newest = said("HAVE") + .filter((name) => REVIEW_REMOTE_VERSION.test(name)) + .sort(compareVersions) + .at(-1); + if (!newest) throw failed("Whiteboard Desktop installed nothing there."); + + const ran = await runSsh(spawn, env, sshExecArgs(session, env), timeout, uninstallScript(root, newest)); + const result = ran.stdout + .split("\n") + .map((line) => { + try { + return JSON.parse(line) as { event?: unknown; ok?: unknown; reason?: unknown }; + } catch { + return undefined; + } + }) + .filter((value) => value?.event === "remote.uninstall") + .at(-1); + if (result?.ok === true) return; + throw failed(typeof result?.reason === "string" ? plainText(result.reason) : sshProblem(ran)); +} + +function sshProblem(result: Awaited>): string { + if (result.timedOut) return "it did not answer in time."; + if (result.error) return result.error.message; + const stderr = lastLine(result.stderr); + return `exit ${result.code}${stderr ? `: ${stderr}` : ""}.`; +} diff --git a/packages/review/src/cli-install.ts b/packages/review/src/cli-install.ts index 58461b4fd..052c74318 100644 --- a/packages/review/src/cli-install.ts +++ b/packages/review/src/cli-install.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { + REVIEW_REMOTE_WRAPPER_MARK, type ReviewCliInstallStamp, ReviewCliInstallStampSchema, type ReviewCliInstallStatus, @@ -58,7 +59,8 @@ function hasManagedShimMarker(source: string): boolean { return ( source.includes(SHIM_MARKER) || source.includes("Managed by Review Desktop") || - source.includes("Managed by Whiteboard Desktop") + source.includes("Managed by Whiteboard Desktop") || + source.includes(REVIEW_REMOTE_WRAPPER_MARK) ); } diff --git a/packages/review/src/cli-runner.ts b/packages/review/src/cli-runner.ts index d4eb83d65..e299676f8 100644 --- a/packages/review/src/cli-runner.ts +++ b/packages/review/src/cli-runner.ts @@ -662,6 +662,103 @@ export async function runReviewCli(input: ReviewCliInput): Promise { state.exitCode = result.failed.length > 0 ? 1 : 0; }); + configureJsonOutput( + remote + .command("uninstall") + .description( + "Remove what Whiteboard Desktop installed on this host, stopping a server it started", + ) + .addOption( + new Option( + "--keep-reviews", + "leave the saved reviews in place", + ).conflicts("deleteReviews"), + ) + .addOption( + new Option("--delete-reviews", "also delete the saved reviews"), + ) + .option( + "--state-dir ", + "directory for saved reviews and server discovery", + ), + "plain", + ).action(async (_options, command: Command) => { + const options = command.optsWithGlobals<{ + stateDir?: string; + json?: boolean; + keepReviews?: boolean; + deleteReviews?: boolean; + }>(); + + const stateDir = reviewServerStateDir(authoringEnv(options.stateDir)); + const { remoteUninstall } = await import("./remote-uninstall.js"); + + let deleteReviews = options.deleteReviews + ? true + : options.keepReviews + ? false + : undefined; + + if (deleteReviews === undefined && !options.json && input.stdin?.isTTY) { + const prompt = createInterface({ + input: input.stdin, + output: input.stderr, + }); + + try { + deleteReviews = /^y(es)?$/i.test( + ( + await prompt.question( + `Also delete the saved reviews in ${stateDir}? [y/N] `, + ) + ).trim(), + ); + } finally { + prompt.close(); + } + } + + const result = + deleteReviews === undefined + ? { + event: "remote.uninstall" as const, + ok: false as const, + reason: + "Say what happens to the saved reviews: pass --keep-reviews or --delete-reviews.", + } + : await remoteUninstall({ + home: env.HOME ?? "", + env, + stateDir, + deleteReviews, + }); + + if (!result.ok) { + if (!options.json) throw new Error(result.reason); + input.stdout.write(`${JSON.stringify(result)}\n`); + state.exitCode = 1; + + return; + } + + input.stdout.write( + options.json + ? `${JSON.stringify(result)}\n` + : [ + ...(result.stoppedServer + ? [ + `Stopped the Whiteboard server (process ${result.stoppedServer.pid}).`, + ] + : []), + ...result.removed.map((removed) => `Removed ${removed}`), + result.keptReviews + ? `Saved reviews kept in ${stateDir}` + : "Saved reviews deleted.", + "", + ].join("\n"), + ); + }); + async function writeServerStatus( discovery: ReviewServerDiscovery, stateDir: string, diff --git a/packages/review/src/remote-language-server.ts b/packages/review/src/remote-language-server.ts index 75c8a770c..895e8190b 100644 --- a/packages/review/src/remote-language-server.ts +++ b/packages/review/src/remote-language-server.ts @@ -199,16 +199,54 @@ export async function ensureRemoteLanguageServer( } } -async function installing(files: ReturnType) { - const running = await readFile(files.installingFile, "utf8") +async function readInstalling( + files: ReturnType, +) { + return readFile(files.installingFile, "utf8") .then((text) => installingSchema.safeParse(JSON.parse(text)).data) .catch(() => undefined); +} - return ( - running !== undefined && - running.started !== null && - processStartIdentity(running.pid) === running.started - ); +async function installing(files: ReturnType) { + const running = await readInstalling(files); + + return running !== undefined && ours(running); +} + +export async function remoteLanguageServerGroups(env: NodeJS.ProcessEnv) { + const files = remoteLanguageServerFiles(env); + + const recorded = await Promise.all([ + readRunning(files.runningFile), + readInstalling(files), + ]); + + return recorded.flatMap((entry) => (entry && ours(entry) ? [entry.pid] : [])); +} + +export async function stopProcessGroup(leader: number) { + for (const signal of ["SIGTERM", "SIGKILL"] as const) { + try { + process.kill(-leader, signal); + } catch { + return; + } + + for (let i = 0; i < 50; i++) { + if (!groupAlive(leader)) return; + await delay(100); + } + } +} + +function groupAlive(leader: number) { + try { + process.kill(-leader, 0); + + return true; + } catch (error) { + return error instanceof Error && "code" in error && error.code === "EPERM"; + } } async function installDetached( @@ -271,7 +309,7 @@ async function readRunning(file: string) { .catch(() => undefined); } -const ours = (running: Running) => +const ours = (running: { pid: number; started: string | null }) => running.started !== null && processStartIdentity(running.pid) === running.started; diff --git a/packages/review/src/remote-uninstall.test.ts b/packages/review/src/remote-uninstall.test.ts new file mode 100644 index 000000000..9b3ab176f --- /dev/null +++ b/packages/review/src/remote-uninstall.test.ts @@ -0,0 +1,564 @@ +import { type ChildProcess, spawn, spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { once } from "node:events"; +import { existsSync } from "node:fs"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { type Server, createServer } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; + +import { REVIEW_REMOTE_WRAPPER_MARK } from "@dev.fast/review-protocol"; +import { processStartIdentity } from "@dev.fast/trace-core"; +import { runReviewCli } from "@review/cli-runner.js"; +import { remoteServerPaths } from "@review/remote-extensions.js"; +import { remoteUninstall } from "@review/remote-uninstall.js"; +import { reviewServerDiscoveryPath } from "@review/server-discovery.js"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +let root: string; + +let home: string; + +let stateDir: string; + +let env: NodeJS.ProcessEnv; + +let install: string; + +let wrapper: string; + +const children: ChildProcess[] = []; + +const groups: number[] = []; + +const servers: Server[] = []; + +beforeEach(async () => { + root = await realpath(await mkdtemp(path.join(tmpdir(), "wb-uninstall-"))); + home = path.join(root, "home"); + stateDir = path.join(home, ".dev"); + env = { DEV_REVIEW_HOME: stateDir }; + install = path.join(stateDir, "whiteboard-remote"); + wrapper = path.join(home, ".local", "bin", "whiteboard"); + + const version = path.join(install, "versions", "0.1.6"); + await mkdir(path.join(version, "node_modules"), { recursive: true }); + await writeFile(path.join(version, ".whiteboard-install.json"), "{}\n"); + await mkdir(path.join(install, "node", "v24.18.0", "bin"), { + recursive: true, + }); + await mkdir(path.dirname(wrapper), { recursive: true }); + await writeFile( + wrapper, + `#!/bin/sh\n${REVIEW_REMOTE_WRAPPER_MARK}\nexec '${version}/whiteboard' "$@"\n`, + { mode: 0o755 }, + ); + + for (const name of [ + "review-api.db", + "review-api.db-wal", + "review-api.db-shm", + "review-api.db.workspaces", + ]) + await writeFile(path.join(stateDir, name), "reviews"); + await mkdir(path.join(stateDir, "review-tools"), { recursive: true }); + await writeFile(path.join(stateDir, "review-tools", "keep"), ""); +}); + +afterEach(async () => { + for (const child of children.splice(0)) child.kill("SIGKILL"); + + for (const leader of groups.splice(0)) + try { + process.kill(-leader, "SIGKILL"); + } catch {} + + for (const server of servers.splice(0)) server.close(); + await rm(root, { recursive: true, force: true }); +}); + +const homeEntries = async () => (await readdir(stateDir)).sort(); + +async function runningFrom(cli: string) { + const child = spawn( + process.execPath, + ["-e", "setInterval(() => {}, 1000)", cli], + { stdio: "ignore" }, + ); + + children.push(child); + await once(child, "spawn"); + + return child; +} + +async function groupFrom(file: string, child?: string) { + const forever = "setInterval(() => {}, 1000)"; + + const leader = spawn( + process.execPath, + [ + "-e", + child + ? `require("child_process").spawn(process.execPath, ["-e", ${JSON.stringify(forever)}, ${JSON.stringify(child)}], { stdio: "ignore" }); ${forever}` + : forever, + file, + ], + { stdio: "ignore", detached: true }, + ); + + groups.push(leader.pid!); + await once(leader, "spawn"); + + return leader.pid!; +} + +const runningWith = (text: string) => + spawnSync("pgrep", ["-f", text]).status === 0; + +async function languageServerRecords(server: number, ensure?: number) { + const { serverDataDir } = remoteServerPaths(env); + await mkdir(serverDataDir, { recursive: true }); + await writeFile( + path.join(serverDataDir, "server.json"), + JSON.stringify({ + pid: server, + started: processStartIdentity(server), + port: 1, + }), + ); + + if (ensure !== undefined) + await writeFile( + path.join(serverDataDir, "install.json"), + JSON.stringify({ pid: ensure, started: processStartIdentity(ensure) }), + ); +} + +async function serverRecord( + pid: number, + startedBy: "user" | "cli" | "desktop", +) { + const instanceId = randomUUID(); + const token = "token"; + + const server = createServer((request, response) => { + response.setHeader("content-type", "application/json"); + response.end( + request.headers["x-review-token"] === token + ? JSON.stringify({ + ok: true, + instanceId, + serverPid: pid, + version: "0.1.6", + }) + : "{}", + ); + }); + + servers.push(server); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as { port: number }; + await mkdir(path.dirname(reviewServerDiscoveryPath(stateDir)), { + recursive: true, + }); + await writeFile( + reviewServerDiscoveryPath(stateDir), + JSON.stringify({ + version: 1, + instanceId, + url: `http://127.0.0.1:${port}`, + serverPid: pid, + token, + startedBy, + }), + ); +} + +const alive = (pid: number) => { + try { + process.kill(pid, 0); + + return true; + } catch { + return false; + } +}; + +it("with --keep-reviews removes the install and Desktop's script, and leaves the review store", async () => { + const result = await remoteUninstall({ + env, + home, + stateDir, + deleteReviews: false, + }); + + expect(result).toEqual({ + event: "remote.uninstall", + ok: true, + removed: [install, wrapper], + keptReviews: true, + }); + expect(existsSync(install)).toBe(false); + expect(existsSync(wrapper)).toBe(false); + expect(await homeEntries()).toEqual([ + "review-api.db", + "review-api.db-shm", + "review-api.db-wal", + "review-api.db.workspaces", + "review-tools", + ]); +}); + +it("with --delete-reviews also removes the review store and nothing else in the review home", async () => { + const result = await remoteUninstall({ + env, + home, + stateDir, + deleteReviews: true, + }); + + expect(result).toMatchObject({ + ok: true, + keptReviews: false, + removed: [ + install, + wrapper, + path.join(stateDir, "review-api.db"), + path.join(stateDir, "review-api.db-wal"), + path.join(stateDir, "review-api.db-shm"), + path.join(stateDir, "review-api.db.workspaces"), + ], + }); + expect(await homeEntries()).toEqual(["review-tools"]); +}); + +it("leaves a ~/.local/bin/whiteboard that Desktop did not write", async () => { + await writeFile(wrapper, "#!/bin/sh\necho mine\n"); + + const result = await remoteUninstall({ + env, + home, + stateDir, + deleteReviews: false, + }); + + expect(result).toMatchObject({ ok: true, removed: [install] }); + expect(await readFile(wrapper, "utf8")).toBe("#!/bin/sh\necho mine\n"); +}); + +it("stops a server Desktop started and reports it", async () => { + const server = await runningFrom( + path.join(install, "versions", "0.1.6", "cli.js"), + ); + + await serverRecord(server.pid!, "desktop"); + + const result = await remoteUninstall({ + env, + home, + stateDir, + deleteReviews: false, + }); + + expect(result).toMatchObject({ + ok: true, + stoppedServer: { pid: server.pid, version: "0.1.6" }, + }); + expect(alive(server.pid!)).toBe(false); + expect(existsSync(install)).toBe(false); +}); + +it("refuses while a server the user started runs from the install, and removes nothing", async () => { + const server = await runningFrom( + path.join(install, "versions", "0.1.6", "cli.js"), + ); + + await serverRecord(server.pid!, "user"); + + const result = await remoteUninstall({ + env, + home, + stateDir, + deleteReviews: false, + }); + + expect(result).toEqual({ + event: "remote.uninstall", + ok: false, + reason: expect.stringContaining(`process ${server.pid}`), + }); + expect(alive(server.pid!)).toBe(true); + expect(existsSync(install)).toBe(true); + expect(existsSync(wrapper)).toBe(true); + expect(existsSync(path.join(install, "install.lock"))).toBe(false); +}); + +it("refuses while any other process runs from the install, naming it, and stops nothing", async () => { + const desktopServer = await runningFrom( + path.join(install, "versions", "0.1.6", "cli.js"), + ); + + await serverRecord(desktopServer.pid!, "desktop"); + + const mcp = await runningFrom( + path.join(install, "versions", "0.1.6", "cli.js mcp"), + ); + + const result = await remoteUninstall({ + env, + home, + stateDir, + deleteReviews: false, + }); + + expect(result).toEqual({ + event: "remote.uninstall", + ok: false, + reason: `Process ${mcp.pid} runs from ${install}. Stop it, then run whiteboard remote uninstall again.`, + }); + expect(alive(mcp.pid!)).toBe(true); + expect(alive(desktopServer.pid!)).toBe(true); + expect(existsSync(install)).toBe(true); +}); + +it("goes ahead past a stale install lock", async () => { + await mkdir(path.join(install, "install.lock")); + await writeFile( + path.join(install, "install.lock", "started"), + `${Math.floor(Date.now() / 1000) - 16 * 60}\n`, + ); + + const result = await remoteUninstall({ + env, + home, + stateDir, + deleteReviews: false, + }); + + expect(result).toMatchObject({ ok: true, removed: [install, wrapper] }); +}); + +it("removes nothing without an absolute home", async () => { + for (const relative of ["", ".dev"]) { + const result = await remoteUninstall({ + env, + home: relative, + stateDir, + deleteReviews: true, + }); + + expect(result).toMatchObject({ ok: false }); + } + + expect( + await remoteUninstall({ + env, + home, + stateDir: ".dev", + deleteReviews: true, + }), + ).toMatchObject({ ok: false }); + expect(existsSync(install)).toBe(true); + expect(await homeEntries()).toContain("review-api.db"); +}); + +it("refuses while an install holds the lock", async () => { + await mkdir(path.join(install, "install.lock")); + await writeFile(path.join(install, "install.lock", "owner"), "laptop\n"); + await writeFile( + path.join(install, "install.lock", "started"), + `${Math.floor(Date.now() / 1000)}\n`, + ); + + const result = await remoteUninstall({ + env, + home, + stateDir, + deleteReviews: false, + }); + + expect(result).toMatchObject({ + ok: false, + reason: expect.stringContaining("laptop"), + }); + expect(existsSync(path.join(install, "versions"))).toBe(true); +}); + +async function cli(argv: string[], change: NodeJS.ProcessEnv = {}) { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + let out = ""; + let err = ""; + stdout.on("data", (chunk) => (out += String(chunk))); + stderr.on("data", (chunk) => (err += String(chunk))); + + const code = await runReviewCli({ + argv, + stdout, + stderr, + env: { + PATH: process.env.PATH, + HOME: home, + DEV_REVIEW_HOME: stateDir, + DEV_FAST_REVIEW_TELEMETRY_DISABLED: "1", + DEV_FAST_REVIEW_CLI_NO_DELEGATE: "1", + ...change, + }, + }); + + return { code, stdout: out, stderr: err }; +} + +it("in --json mode with neither flag refuses and removes nothing", async () => { + const result = await cli(["remote", "uninstall", "--json"]); + + expect(result.code).toBe(1); + expect(JSON.parse(result.stdout)).toEqual({ + event: "remote.uninstall", + ok: false, + reason: expect.stringContaining("--keep-reviews or --delete-reviews"), + }); + expect(existsSync(install)).toBe(true); + expect(existsSync(wrapper)).toBe(true); +}); + +it("with DEV_REVIEW_HOME elsewhere removes the install there, with the VS Code server's data, and leaves ~/.dev alone", async () => { + const moved = path.join(root, "moved"); + const movedInstall = path.join(moved, "whiteboard-remote"); + const { serverDataDir } = remoteServerPaths({ DEV_REVIEW_HOME: moved }); + await mkdir(path.join(movedInstall, "versions", "0.1.6"), { + recursive: true, + }); + await mkdir(serverDataDir, { recursive: true }); + await writeFile(path.join(serverDataDir, "connection-token"), "token"); + + const result = await cli( + ["remote", "uninstall", "--keep-reviews", "--json"], + { + DEV_REVIEW_HOME: moved, + }, + ); + + expect(result).toMatchObject({ code: 0, stderr: "" }); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + removed: [movedInstall, wrapper], + }); + expect(existsSync(serverDataDir)).toBe(false); + expect(existsSync(install)).toBe(true); +}); + +it("stops the VS Code server with its extension host, and a detached extension install, then removes the install", async () => { + const pkg = path.join( + install, + "versions", + "0.1.6", + "node_modules", + "@dev.fast", + "whiteboard", + ); + + const extensionHost = path.join( + install, + "extensions", + "ms-python.python", + "server.js", + ); + + const server = await groupFrom( + path.join(pkg, "vscode-server", "out", "server-main.js"), + extensionHost, + ); + + const ensure = await groupFrom(path.join(pkg, "dist", "cli.js")); + await languageServerRecords(server, ensure); + + for (let i = 0; i < 100 && !runningWith(extensionHost); i++) + await new Promise((resolve) => setTimeout(resolve, 50)); + + const result = await remoteUninstall({ + env, + home, + stateDir, + deleteReviews: false, + }); + + expect(result).toMatchObject({ ok: true, removed: [install, wrapper] }); + expect(runningWith(`${install}/`)).toBe(false); +}); + +it("refuses a server the user started before it stops anything, naming only that server", async () => { + const server = await groupFrom( + path.join( + install, + "versions", + "0.1.6", + "vscode-server", + "out", + "server-main.js", + ), + path.join(install, "extensions", "ms-python.python", "server.js"), + ); + + await languageServerRecords(server); + + const user = await runningFrom( + path.join(install, "versions", "0.1.6", "cli.js"), + ); + + await serverRecord(user.pid!, "user"); + + const result = await remoteUninstall({ + env, + home, + stateDir, + deleteReviews: false, + }); + + expect(result).toMatchObject({ + ok: false, + reason: expect.stringContaining( + `A Whiteboard server you started (process ${user.pid})`, + ), + }); + expect((result as { reason: string }).reason).not.toContain(`${server}`); + expect(alive(user.pid!)).toBe(true); + expect(alive(server)).toBe(true); + expect(existsSync(path.join(install, "versions"))).toBe(true); +}); + +it("removes nothing under a DEV_REVIEW_HOME it cannot normalise, as the probe refuses it", async () => { + for (const moved of [ + "relative/home", + `${root}/a/../b`, + `${root}//b`, + `//${root}`, + `${root}/a\nb`, + `${root}/a\u001bb`, + ]) { + const result = await remoteUninstall({ + env: { DEV_REVIEW_HOME: moved }, + home, + stateDir, + deleteReviews: false, + }); + + expect(result).toMatchObject({ + ok: false, + reason: expect.stringContaining("not an absolute, normalised path"), + }); + } + + expect(existsSync(install)).toBe(true); +}); diff --git a/packages/review/src/remote-uninstall.ts b/packages/review/src/remote-uninstall.ts new file mode 100644 index 000000000..5d88040d7 --- /dev/null +++ b/packages/review/src/remote-uninstall.ts @@ -0,0 +1,280 @@ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { lstat, mkdir, readFile, readdir, rm, stat } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { + REVIEW_REMOTE_INSTALL_LOCK, + REVIEW_REMOTE_LOCK_STALE_SECONDS, + REVIEW_REMOTE_WRAPPER_MARK, +} from "@dev.fast/review-protocol"; + +import { whiteboardRemoteHome } from "./remote-extensions"; +import { + remoteLanguageServerGroups, + stopProcessGroup, +} from "./remote-language-server"; +import { DEV_REVIEW_HOME_ENV } from "./review-home-paths"; +import { + recordedBackgroundServer, + stopBackgroundServer, +} from "./server/background-server"; + +const LOCK_STALE_MS = REVIEW_REMOTE_LOCK_STALE_SECONDS * 1000; + +const REVIEW_STORE = [ + "review-api.db", + "review-api.db-wal", + "review-api.db-shm", + "review-api.db.workspaces", + "review-api.db.workspaces-wal", + "review-api.db.workspaces-shm", +]; + +export type RemoteUninstallResult = + | { + event: "remote.uninstall"; + ok: true; + removed: string[]; + keptReviews: boolean; + stoppedServer?: { pid: number; version: string | null }; + } + | { event: "remote.uninstall"; ok: false; reason: string }; + +export async function remoteUninstall(input: { + home: string; + env: NodeJS.ProcessEnv; + stateDir: string; + deleteReviews: boolean; +}): Promise { + const refuse = (reason: string): RemoteUninstallResult => ({ + event: "remote.uninstall", + ok: false, + reason, + }); + + // A relative home would aim every removal at the working directory. + if (!path.isAbsolute(input.home)) + return refuse( + `HOME is ${input.home ? JSON.stringify(input.home) : "not set"}; Whiteboard removes nothing without an absolute home.`, + ); + + if (!path.isAbsolute(input.stateDir)) + return refuse( + `The review home ${JSON.stringify(input.stateDir)} is not an absolute path.`, + ); + + const override = input.env[DEV_REVIEW_HOME_ENV]?.trim(); + + if ( + override && + (/[\x00-\x1f\x7f-\x9f]/.test(override) || + override.includes("//") || + path.resolve(override) !== (override.replace(/\/$/, "") || "/")) + ) + return refuse( + `DEV_REVIEW_HOME is ${JSON.stringify(override)}; Whiteboard removes nothing under a review home that is not an absolute, normalised path.`, + ); + + const install = whiteboardRemoteHome(input.env); + const wrapper = path.join(input.home, ".local", "bin", "whiteboard"); + const lock = path.join(install, REVIEW_REMOTE_INSTALL_LOCK); + + const busy = async () => + refuse( + `Desktop on ${(await readText(path.join(lock, "owner"))) || "another computer"} is installing Whiteboard here. Try again when it is done.`, + ); + + if (existsSync(lock)) { + if (!(await stale(lock))) return busy(); + await rm(lock, { recursive: true, force: true }); + } + + const held = existsSync(install); + + if ( + held && + !(await mkdir(lock).then( + () => true, + () => false, + )) + ) + return busy(); + + try { + return await removeInstall(input, install, wrapper, refuse); + } finally { + if (held) await rm(lock, { recursive: true, force: true }); + } +} + +async function removeInstall( + input: Parameters[0], + install: string, + wrapper: string, + refuse: (reason: string) => RemoteUninstallResult, +): Promise { + const server = await recordedBackgroundServer(input.stateDir); + + const recorded = server && { + ...server.discovery, + version: server.health.version ?? null, + }; + + const stoppable = + recorded && recorded.startedBy !== "user" ? recorded.serverPid : undefined; + + const groups = await remoteLanguageServerGroups(input.env); + const running = await processesFrom(`${install}/`); + + if (running === undefined) + return refuse( + `Cannot list this host's processes to check that none runs from ${install}.`, + ); + + const others = running + .filter(({ pid, pgid }) => pid !== stoppable && !groups.includes(pgid)) + .map(({ pid }) => pid); + + if (others.length) + return refuse( + `${others.map((pid) => (pid === recorded?.serverPid ? `A Whiteboard server you started (process ${pid})` : `Process ${pid}`)).join(", ")} ${others.length > 1 ? "run" : "runs"} from ${install}. Stop ${others.length > 1 ? "them" : "it"}, then run whiteboard remote uninstall again.`, + ); + + if (recorded?.startedBy === "user" && input.deleteReviews) + return refuse( + `A Whiteboard server you started (process ${recorded.serverPid}) uses the reviews in ${input.stateDir}. Stop it, then run whiteboard remote uninstall again.`, + ); + + await Promise.all(groups.map(stopProcessGroup)); + let stoppedServer: { pid: number; version: string | null } | undefined; + + if (recorded && stoppable !== undefined) { + try { + await stopBackgroundServer({ serverPid: stoppable }); + } catch (error) { + return refuse(error instanceof Error ? error.message : String(error)); + } + + stoppedServer = { pid: stoppable, version: recorded.version }; + } + + const left = await processesFrom(`${install}/`); + + if (left?.length !== 0) + return refuse( + `${left ? `Process ${left.map(({ pid }) => pid).join(", ")}` : "A process"} still runs from ${install}. Stop it, then run whiteboard remote uninstall again.`, + ); + + const removed: string[] = []; + + if (existsSync(install)) { + await rm(install, { recursive: true, force: true }); + removed.push(install); + } + + if (await desktopWrote(wrapper)) { + await rm(wrapper, { force: true }); + removed.push(wrapper); + } + + if (input.deleteReviews) + for (const name of REVIEW_STORE) { + const file = path.join(input.stateDir, name); + + if (existsSync(file)) { + await rm(file, { force: true }); + removed.push(file); + } + } + + const result: RemoteUninstallResult = { + event: "remote.uninstall", + ok: true, + removed, + keptReviews: !input.deleteReviews, + }; + + if (stoppedServer) result.stoppedServer = stoppedServer; + + return result; +} + +async function stale(lock: string) { + const started = await readText(path.join(lock, "started")); + + if (started !== "") + return ( + /^\d+$/.test(started) && + Date.now() - Number(started) * 1000 >= LOCK_STALE_MS + ); + + if (existsSync(path.join(lock, "started"))) return false; + + const since = (await stat(lock).catch(() => undefined))?.mtimeMs; + + return since !== undefined && Date.now() - since >= LOCK_STALE_MS; +} + +const readText = (file: string) => + readFile(file, "utf8").then( + (text) => text.trim(), + () => "", + ); + +async function processesFrom(prefix: string) { + let lines: { pid: number; pgid: number; args: string }[]; + + try { + const pids = (await readdir("/proc")).filter((name) => /^\d+$/.test(name)); + + lines = await Promise.all( + pids.map(async (pid) => { + const [args = "", stat = ""] = await Promise.all( + [`/proc/${pid}/cmdline`, `/proc/${pid}/stat`].map((file) => + readFile(file, "utf8").catch(() => ""), + ), + ); + + // The group follows the state and the parent, after the command's parentheses. + const pgid = Number( + stat.slice(stat.lastIndexOf(")") + 2).split(" ")[2], + ); + + return { pid: Number(pid), pgid, args: args.replaceAll("\0", " ") }; + }), + ); + } catch { + try { + const { stdout } = await promisify(execFile)( + "ps", + ["-eo", "pid=,pgid=,args="], + { maxBuffer: 16 << 20 }, + ); + + lines = stdout.split("\n").map((line) => { + const [, pid = "", pgid = "", args = ""] = + /^\s*(\d+)\s+(\d+)\s(.*)$/.exec(line) ?? []; + + return { pid: Number(pid), pgid: Number(pgid), args }; + }); + } catch { + return undefined; + } + } + + return lines.filter( + ({ pid, args }) => pid && pid !== process.pid && args.includes(prefix), + ); +} + +async function desktopWrote(file: string) { + const entry = await lstat(file).catch(() => undefined); + + if (!entry?.isFile()) return false; + + return (await readFile(file, "utf8")) + .split("\n") + .includes(REVIEW_REMOTE_WRAPPER_MARK); +} From f583fe2e2477ad0654adcbfe2964a0c2c34db0b8 Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Wed, 14 Oct 2026 13:13:59 -0400 Subject: [PATCH 08/14] Connect a host's agents whiteboard connect --detect lists the agents on a machine, and --yes runs their install commands; Desktop runs both over SSH. --- .../remote/reviewRemoteAgents.test.ts | 88 ++++++ .../remote/reviewRemoteAgents.ts | 68 +++++ packages/review/app/src/connect-card.tsx | 19 +- packages/review/src/cli-runner.ts | 157 +++++++--- packages/review/src/cli.test.ts | 134 +++++++- packages/review/src/connect-agents.test.ts | 274 +++++++++++++++++ packages/review/src/connect-agents.ts | 285 ++++++++++++++++++ packages/review/src/connect-prompts.ts | 66 +++- 8 files changed, 1013 insertions(+), 78 deletions(-) create mode 100644 apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteAgents.test.ts create mode 100644 apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteAgents.ts create mode 100644 packages/review/src/connect-agents.test.ts create mode 100644 packages/review/src/connect-agents.ts diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteAgents.test.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteAgents.test.ts new file mode 100644 index 000000000..02d7d5c0a --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteAgents.test.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { parseRemoteAgents, parseRemoteConnect, remoteConnectScript } from "./reviewRemoteAgents.js"; + +test("detection keeps only known agents, once each, and nothing the remote adds", () => { + const stdout = [ + "Welcome to Ubuntu", + JSON.stringify({ event: "connect.detect", agents: [] }), + JSON.stringify({ + event: "connect.detect", + agents: [ + { id: "pi", name: "\u001b[31mEvil\u001b[0m", connected: false, manual: "yes", configPath: "/home/dev/.pi" }, + { id: "claude", name: "Claude Code", connected: true }, + { id: "pi", connected: true }, + { id: "cursor", connected: false }, + { id: "codex", connected: "no" }, + { id: "__proto__", connected: false }, + "codex", + null, + ], + }), + "bye", + ].join("\n"); + + assert.deepEqual(parseRemoteAgents(stdout), [ + { id: "claude", connected: true }, + { id: "pi", connected: false }, + ]); + assert.deepEqual(parseRemoteAgents(JSON.stringify({ event: "connect.detect", agents: [{ id: "codex", connected: false, manual: true }] })), [ + { id: "codex", connected: false, manual: true }, + ]); + assert.equal(parseRemoteAgents("bash: whiteboard: command not found\n"), undefined); + assert.equal(parseRemoteAgents(JSON.stringify({ event: "connect.detect", agents: "all" })), undefined); +}); + +test("a connect result's output is plain text", () => { + const [result] = parseRemoteConnect( + JSON.stringify({ event: "connect.run", agents: [{ id: "pi", connected: false, output: "\u001b[31mnpm ERR!\u001b[0m\r\n404\u0007" }] }), + )!; + + assert.equal(result.id, "pi"); + assert.equal(result.connected, false); + assert.doesNotMatch(result.output, /[\x00-\x1f\x7f-\x9f]/); + assert.equal(parseRemoteConnect(JSON.stringify({ event: "connect.run", agents: [{ id: "pi", connected: true }] }))![0].output, ""); +}); + +async function runScript(t: test.TestContext, script: (home: string) => string, shellNoise = "motd\n"): Promise { + const home = await mkdtemp(join(tmpdir(), "wb-agents-")); + t.after(() => rm(home, { recursive: true, force: true })); + const echo = '#!/bin/sh\nprintf "%s|" "$0" "$@"; printf "\\nPATH=%s DELEGATE=%s\\n" "$PATH" "$DEV_FAST_REVIEW_CLI_NO_DELEGATE"\n'; + await mkdir(join(home, ".local", "bin"), { recursive: true }); + for (const file of [join(home, "node"), join(home, ".local", "bin", "whiteboard")]) { + await writeFile(file, echo); + await chmod(file, 0o755); + } + const shell = join(home, "login-shell"); + await writeFile(shell, `#!/bin/sh\nprintf '${shellNoise}'\nPATH=/agents/bin:$PATH\nexport PATH\nshift\nexec /bin/sh -c "$1"\n`); + await chmod(shell, 0o755); + return new Promise((resolve, reject) => { + const child = execFile("/bin/sh", ["-s"], { env: { HOME: home, SHELL: shell, PATH: "/usr/bin:/bin" } }, (error, stdout) => (error ? reject(error) : resolve(stdout))); + child.stdin!.end(script(home)); + }); +} + +test("the installed CLI runs by its paths, with the login shell's PATH, as itself", async (t) => { + const out = await runScript(t, (home) => remoteConnectScript({ nodePath: join(home, "node"), cliPath: "/opt/wb/it's/cli.js" }, ["--yes", "--json", "pi"]), "Welcome\nWHITEBOARD-PATH=/not/this\n"); + + const [argv, env] = out.trim().split("\n"); + assert.match(argv, /\/node\|\/opt\/wb\/it's\/cli\.js\|connect\|--yes\|--json\|pi\|$/); + assert.match(env, /^PATH=\/agents\/bin:\/usr\/bin:\/bin:\/usr\/bin:\/bin DELEGATE=1$/); +}); + +test("without an installed CLI, the one on PATH or in ~/.local/bin runs", async (t) => { + const out = await runScript(t, () => remoteConnectScript(undefined, ["--detect", "--json"])); + + assert.match(out, /\/\.local\/bin\/whiteboard\|connect\|--detect\|--json\|/); + assert.match(out, /DELEGATE=1/); +}); diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteAgents.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteAgents.ts new file mode 100644 index 000000000..45fba95e2 --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteAgents.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { stripVTControlCharacters } from "node:util"; +import { REVIEW_REMOTE_AGENT_IDS, type ReviewRemoteAgent, type ReviewRemoteAgentId, type ReviewRemoteAgentResult } from "../../common/reviewProtocol.js"; +import { pathCliScript } from "./reviewRemoteAttachScript.js"; +import { shellQuote } from "./reviewRemoteInstallScript.js"; + +export type ReviewRemoteCli = { readonly nodePath: string; readonly cliPath: string } | undefined; + +const LOGIN_PATH = `if [ -n "$SHELL" ]; then + p=$("$SHELL" -lic 'printf "\\nWHITEBOARD-PATH=%s\\n" "$PATH"' /dev/null | tr -d '\\r' | sed -n 's/^WHITEBOARD-PATH=//p' | tail -n 1) + case "$p" in /*) PATH="$p:$PATH"; export PATH ;; esac +fi +DEV_FAST_REVIEW_CLI_NO_DELEGATE=1 +export DEV_FAST_REVIEW_CLI_NO_DELEGATE +`; + +export function remoteConnectScript(cli: ReviewRemoteCli, args: readonly string[]): string { + const words = ["connect", ...args].map((word) => shellQuote(word)).join(" "); + return `${LOGIN_PATH}${cli ? `exec ${shellQuote(cli.nodePath)} ${shellQuote(cli.cliPath)} ${words}\n` : pathCliScript(words)}`; +} + +export const plainText = (text: string) => stripVTControlCharacters(text).replace(/[\x00-\x1f\x7f-\x9f]+/g, " ").trim(); + +export const isRemoteAgentId = (value: unknown): value is ReviewRemoteAgentId => REVIEW_REMOTE_AGENT_IDS.some((id) => id === value); + +function lastEvent(stdout: string, event: string): Record | undefined { + for (const line of stdout.split("\n").reverse()) { + let value: unknown; + try { + value = JSON.parse(line); + } catch { + continue; + } + if (value && typeof value === "object" && (value as Record).event === event) return value as Record; + } + return undefined; +} + +function agentsOf(record: Record | undefined, read: (item: Record) => T | undefined): T[] | undefined { + if (!record || !Array.isArray(record.agents)) return undefined; + const found = new Map(); + for (const item of record.agents) { + if (!item || typeof item !== "object" || !isRemoteAgentId((item as Record).id)) continue; + const agent = read(item as Record); + if (agent && !found.has(agent.id)) found.set(agent.id, agent); + } + return REVIEW_REMOTE_AGENT_IDS.flatMap((id) => found.get(id) ?? []); +} + +export function parseRemoteAgents(stdout: string): ReviewRemoteAgent[] | undefined { + return agentsOf(lastEvent(stdout, "connect.detect"), (item) => + typeof item.connected === "boolean" + ? { id: item.id as ReviewRemoteAgentId, connected: item.connected, ...(item.manual === true && { manual: true as const }) } + : undefined, + ); +} + +export function parseRemoteConnect(stdout: string): ReviewRemoteAgentResult[] | undefined { + return agentsOf(lastEvent(stdout, "connect.run"), (item) => + typeof item.connected === "boolean" + ? { id: item.id as ReviewRemoteAgentId, connected: item.connected, output: plainText(typeof item.output === "string" ? item.output : "") } + : undefined, + ); +} diff --git a/packages/review/app/src/connect-card.tsx b/packages/review/app/src/connect-card.tsx index 1b5dced46..39367923a 100644 --- a/packages/review/app/src/connect-card.tsx +++ b/packages/review/app/src/connect-card.tsx @@ -1,6 +1,7 @@ import { fontSize, radius } from "@canvas/scale.stylex"; import { Button } from "@canvas/ui/button"; import { + REVIEW_CLI_INSTALL_TARGET_LABELS, type ReviewCanvasInstallContent, type ReviewCliInstallStatus, type ReviewCliInstallTarget, @@ -19,16 +20,6 @@ import { promptStyles } from "./prompt-styles"; import { tokens } from "./tokens.stylex"; import { SegmentedControl, segmentStyles } from "./ui/segmented"; -export const TARGET_LABELS: Record = { - claude: "Claude Code", - codex: "Codex", - cursor: "Cursor", - opencode: "OpenCode", - pi: "Pi", - omp: "oh-my-pi", - copilot: "Copilot CLI", -}; - /** The rest share the Other menu. */ const TAB_TARGETS = ["claude", "codex", "cursor", "opencode"] as const; @@ -100,7 +91,7 @@ export function ConnectCard({ setExpanded(false); }; - const agent = TARGET_LABELS[target]; + const agent = REVIEW_CLI_INSTALL_TARGET_LABELS[target]; const plugin = status.connect.plugins[target]; @@ -152,7 +143,7 @@ export function ConnectCard({ label: ( <> - {TARGET_LABELS[tab]} + {REVIEW_CLI_INSTALL_TARGET_LABELS[tab]} ), }; @@ -274,7 +265,7 @@ function OtherAgentMenu({ return { value: other, - label: TARGET_LABELS[other], + label: REVIEW_CLI_INSTALL_TARGET_LABELS[other], icon: , }; })} @@ -288,7 +279,7 @@ function OtherAgentMenu({ triggerProps={{ "aria-pressed": selected !== undefined }} > {Logo ? : null} - {selected ? TARGET_LABELS[selected] : "Other…"} + {selected ? REVIEW_CLI_INSTALL_TARGET_LABELS[selected] : "Other…"} ); } diff --git a/packages/review/src/cli-runner.ts b/packages/review/src/cli-runner.ts index e299676f8..fa644b6d7 100644 --- a/packages/review/src/cli-runner.ts +++ b/packages/review/src/cli-runner.ts @@ -6,6 +6,7 @@ import { Writable } from "node:stream"; import { fileURLToPath } from "node:url"; import { + REVIEW_CLI_INSTALL_TARGET_LABELS, REVIEW_REMOTE_ATTACH_BEGIN, REVIEW_REMOTE_ATTACH_END, } from "@dev.fast/review-protocol"; @@ -44,6 +45,12 @@ import { windowsInstallerCommand, } from "./cli-install"; import { cliRuntimeInfo, describeCliRuntime } from "./cli-runtime-info"; +import { + AGENT_CONNECT_TARGETS, + type AgentConnectTarget, + connectAgents, + detectAgents, +} from "./connect-agents"; import { connectPrompts } from "./connect-prompts"; import { ReviewInstanceUnavailableError, @@ -984,53 +991,125 @@ export async function runReviewCli(input: ReviewCliInput): Promise { "copilot", "all", ]), + ) + .addOption( + new Option( + "--detect", + "list the agents on this machine and whether each is connected; changes nothing", + ).conflicts("yes"), + ) + .option( + "--yes", + "run the commands the prompt gives, without an agent: claude, codex, opencode or pi", ), "plain", ); - connect.action(async (targets: string[], options: { json?: boolean }) => { - const selected = parseTargets(targets); + connect.action( + async ( + targets: string[], + options: { json?: boolean; detect?: boolean; yes?: boolean }, + ) => { + const output = { + json: options.json, + stdout: input.stdout, + stderr: input.stderr, + }; + + if (options.detect) { + const agents = await detectAgents({ homeDir: scope.homeDir, env }); + + if (options.json) { + emitJsonEvent(output, { event: "connect.detect", agents }); - const { homeDir, devHome } = scope; + return; + } + + humanStream(output).write( + agents.length + ? `${agents.map((agent) => `${agent.name}: ${agent.connected ? "connected" : "not connected"}${agent.manual ? " (its command is not on PATH)" : ""}`).join("\n")}\n` + : "No agents found.\n", + ); + + return; + } + + if (options.yes) { + const agents = parseTargets(targets); + + if ( + targets.length === 0 || + !agents.every((target): target is AgentConnectTarget => + AGENT_CONNECT_TARGETS.some((known) => known === target), + ) + ) + throw new Error( + "connect --yes takes one or more of claude, codex, opencode or pi.", + ); - const prompts = connectPrompts({ - legacyPaths: await scanLegacySkills(homeDir), - hasShim: - (await isOwnedShim(pathShimPath(homeDir))) || - (await windowsInstallerCommand( - findReviewPackageRoot(import.meta.url), + const results = await connectAgents({ + agents, + homeDir: scope.homeDir, env, - )) !== undefined, - traceEnabled: await traceMachineEnabled({ homeDir, env }), - fffBinaryPath: path.join(homeDir, ".local", "bin", "fff-mcp"), - fffCorpusRoot: path.join(devHome, "trace-search"), - }); + }); - const output = { - json: options.json, - stdout: input.stdout, - stderr: input.stderr, - }; + state.exitCode = results.every((result) => result.connected) ? 0 : 1; - if (options.json) { - emitJsonEvent(output, { - event: "connect", - prompts: Object.fromEntries( - selected.map((target) => [target, prompts[target]]), - ), + if (options.json) { + emitJsonEvent(output, { event: "connect.run", agents: results }); + + return; + } + + humanStream(output).write( + results + .map( + (result) => + `${result.output}${result.name}: ${result.connected ? "connected" : "not connected"}\n`, + ) + .join(""), + ); + + return; + } + + const selected = parseTargets(targets); + + const { homeDir, devHome } = scope; + + const prompts = connectPrompts({ + legacyPaths: await scanLegacySkills(homeDir), + hasShim: + (await isOwnedShim(pathShimPath(homeDir))) || + (await windowsInstallerCommand( + findReviewPackageRoot(import.meta.url), + env, + )) !== undefined, + traceEnabled: await traceMachineEnabled({ homeDir, env }), + fffBinaryPath: path.join(homeDir, ".local", "bin", "fff-mcp"), + fffCorpusRoot: path.join(devHome, "trace-search"), }); - return; - } + if (options.json) { + emitJsonEvent(output, { + event: "connect", + prompts: Object.fromEntries( + selected.map((target) => [target, prompts[target]]), + ), + }); - const sections = selected.map((target) => - selected.length > 1 - ? `## ${TARGET_LABELS[target]}\n\n${prompts[target]}` - : prompts[target], - ); + return; + } - humanStream(output).write(`${sections.join("\n\n")}\n`); - }); + const sections = selected.map((target) => + selected.length > 1 + ? `## ${REVIEW_CLI_INSTALL_TARGET_LABELS[target]}\n\n${prompts[target]}` + : prompts[target], + ); + + humanStream(output).write(`${sections.join("\n\n")}\n`); + }, + ); const migrate = configureOutput( program.command("migrate", { hidden: true }), @@ -1459,16 +1538,6 @@ export async function runReviewCli(input: ReviewCliInput): Promise { } } -const TARGET_LABELS: Record = { - claude: "Claude Code", - codex: "Codex", - cursor: "Cursor", - opencode: "OpenCode", - pi: "Pi", - omp: "oh-my-pi", - copilot: "Copilot CLI", -}; - function parseTargets(targets: readonly string[]): InstallTarget[] { if (targets.length === 0 || targets.includes("all")) { return [...ALL_INSTALL_TARGETS]; diff --git a/packages/review/src/cli.test.ts b/packages/review/src/cli.test.ts index 259109dde..58a1d558d 100644 --- a/packages/review/src/cli.test.ts +++ b/packages/review/src/cli.test.ts @@ -4,12 +4,14 @@ import { readFile, readdir, rm, + stat, writeFile, } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { PassThrough, Readable } from "node:stream"; +import { REVIEW_REMOTE_WRAPPER_MARK } from "@dev.fast/review-protocol"; import { StoreClient, runTraceSessions as runTraceSessionsActual, @@ -116,6 +118,87 @@ describe("Whiteboard CLI", () => { expect(Object.keys(event.prompts)).toEqual(["claude"]); }); + it("counts the launcher Desktop writes on a remote host as the managed command", async () => { + const { code, stdout } = await runConnect( + ["connect", "claude", "--json"], + async (home) => { + await mkdir(path.join(home, ".local", "bin"), { recursive: true }); + await writeFile( + path.join(home, ".local", "bin", "whiteboard"), + `#!/bin/sh\n${REVIEW_REMOTE_WRAPPER_MARK}\nexec '${home}/.dev/whiteboard-remote/versions/0.1.6/whiteboard' "$@"\n`, + ); + }, + ); + + expect(code).toBe(0); + const prompt = JSON.parse(stdout).prompts.claude; + expect(prompt).toContain("claude plugin install whiteboard@devfast"); + expect(prompt).not.toContain("install the whiteboard command"); + }); + + it("lists the agents whose configuration exists, as one JSON line", async () => { + const { code, stdout } = await runConnect( + ["connect", "--detect", "--json"], + async (home) => { + await mkdir(path.join(home, ".codex")); + }, + ); + + expect(code).toBe(0); + expect(stdout.trimEnd().split("\n")).toHaveLength(1); + expect(JSON.parse(stdout)).toEqual({ + event: "connect.detect", + agents: [ + { + id: "codex", + name: "Codex", + connected: false, + manual: true, + }, + ], + }); + }); + + it("detects without changing any file", async () => { + let before = ""; + let after = ""; + + const { code, stdout } = await runConnect( + ["connect", "--detect", "--json"], + async (home) => { + await installTestShim(home); + await mkdir(path.join(home, ".claude", "plugins"), { recursive: true }); + await writeFile( + path.join(home, ".claude", "plugins", "installed_plugins.json"), + "{}", + ); + await mkdir(path.join(home, ".pi")); + before = await fileTree(home); + }, + { after: async (home) => void (after = await fileTree(home)) }, + ); + + expect(code).toBe(0); + expect( + JSON.parse(stdout).agents.map((agent: { id: string }) => agent.id), + ).toEqual(["claude", "pi"]); + expect(after).toBe(before); + }); + + it("refuses to run commands for an agent it cannot connect alone", async () => { + const { code, stdout } = await runConnect([ + "connect", + "--yes", + "cursor", + "--json", + ]); + + expect(code).toBe(1); + expect(JSON.parse(stdout).error.message).toContain( + "claude, codex, opencode or pi", + ); + }); + it("routes own-upload status filters without requesting trace content", async () => { const runTraceStatus = vi.fn(async () => 0); @@ -760,18 +843,20 @@ describe("Whiteboard CLI", () => { await mkdir(directory, { recursive: true }); await writeFile(path.join(directory, "review.json"), record); }, - async (home) => { - const directory = path.join( - home, - ".dev", - "reviews", - "11111111-1111-4111-8111-111111111111", - ); - - expect( - await readFile(path.join(directory, "review.json"), "utf8"), - ).toBe(record); - expect(await readdir(directory)).toEqual(["review.json"]); + { + after: async (home) => { + const directory = path.join( + home, + ".dev", + "reviews", + "11111111-1111-4111-8111-111111111111", + ); + + expect( + await readFile(path.join(directory, "review.json"), "utf8"), + ).toBe(record); + expect(await readdir(directory)).toEqual(["review.json"]); + }, }, ); @@ -792,6 +877,23 @@ function outputStream(): PassThrough { return new PassThrough(); } +async function fileTree(root: string): Promise { + const entries = await readdir(root, { recursive: true, withFileTypes: true }); + + const lines = await Promise.all( + entries.map(async (entry) => { + const file = path.join(entry.parentPath, entry.name); + const info = await stat(file); + + return `${path.relative(root, file)} ${info.size} ${info.mode} ${info.mtimeMs}`; + }), + ); + + const top = await stat(root); + + return [`. ${top.mtimeMs}`, ...lines.sort()].join("\n"); +} + async function installTestShim(home: string): Promise { const bin = path.join(home, ".local", "bin"); await mkdir(bin, { recursive: true }); @@ -801,7 +903,10 @@ async function installTestShim(home: string): Promise { async function runConnect( argv: string[], setup?: (homeDir: string) => Promise, - verify?: (homeDir: string) => Promise, + extra?: { + env?: (homeDir: string) => NodeJS.ProcessEnv; + after?: (homeDir: string) => Promise; + }, ): Promise<{ code: number; stdout: string; stderr: string }> { const homeDir = await mkdtemp(path.join(os.tmpdir(), "review-connect-")); @@ -824,12 +929,13 @@ async function runConnect( HOME: homeDir, TRACE_HOME_DIR: homeDir, DEV_REVIEW_HOME: path.join(homeDir, ".dev"), + ...extra?.env?.(homeDir), }, stdout, stderr, }); - await verify?.(homeDir); + await extra?.after?.(homeDir); return { code, stdout: stdoutText, stderr: stderrText }; } finally { diff --git a/packages/review/src/connect-agents.test.ts b/packages/review/src/connect-agents.test.ts new file mode 100644 index 000000000..d24968e9b --- /dev/null +++ b/packages/review/src/connect-agents.test.ts @@ -0,0 +1,274 @@ +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { connectAgents, detectAgents } from "./connect-agents"; + +const homes: string[] = []; + +afterEach(async () => { + await Promise.all( + homes.splice(0).map((home) => rm(home, { recursive: true, force: true })), + ); +}); + +async function fakeHome(): Promise<{ + homeDir: string; + bin: string; + env: NodeJS.ProcessEnv; +}> { + const homeDir = await mkdtemp(path.join(os.tmpdir(), "wb-agents-")); + homes.push(homeDir); + const bin = path.join(homeDir, "bin"); + await mkdir(bin); + + return { + homeDir, + bin, + env: { HOME: homeDir, PATH: `${bin}:/usr/bin:/bin`, XDG_CONFIG_HOME: "" }, + }; +} + +async function fakeCli(bin: string, name: string, body = ""): Promise { + const file = path.join(bin, name); + await writeFile( + file, + `#!/bin/sh\necho "${name} $*" >> "$HOME/calls.log"\n${body}\n`, + ); + await chmod(file, 0o755); +} + +const write = async (file: string, text: string) => { + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, text); +}; + +describe("detectAgents", () => { + it("lists only the harnesses whose configuration directory exists", async () => { + const { homeDir, bin, env } = await fakeHome(); + await mkdir(path.join(homeDir, ".codex")); + await fakeCli(bin, "codex"); + + expect(await detectAgents({ homeDir, env })).toEqual([ + { id: "codex", name: "Codex", connected: false }, + ]); + }); + + it("reads each harness's own record of the plugin or package", async () => { + const { homeDir, bin, env } = await fakeHome(); + + for (const cli of ["claude", "codex", "opencode", "pi"]) + await fakeCli(bin, cli); + await write( + path.join(homeDir, ".claude", "plugins", "installed_plugins.json"), + JSON.stringify({ + version: 2, + plugins: { "whiteboard@devfast": [{ scope: "user" }] }, + }), + ); + await write( + path.join(homeDir, ".codex", "config.toml"), + 'model = "x"\n\n[plugins."whiteboard@devfast"]\nenabled = true\n', + ); + await write( + path.join(homeDir, ".config", "opencode", "opencode.jsonc"), + '{\n // mine\n "plugin": [\n "@dev.fast/opencode-whiteboard@0.1.0"\n ]\n}\n', + ); + await write( + path.join(homeDir, ".pi", "agent", "settings.json"), + JSON.stringify({ packages: [{ source: "npm:@dev.fast/pi-whiteboard" }] }), + ); + + expect( + (await detectAgents({ homeDir, env })).map(({ id, connected }) => [ + id, + connected, + ]), + ).toEqual([ + ["claude", true], + ["codex", true], + ["opencode", true], + ["pi", true], + ]); + }); + + it("finds OpenCode's whiteboard MCP server in its JSONC config", async () => { + const opencode = async (text: string) => { + const { homeDir, bin, env } = await fakeHome(); + await fakeCli(bin, "opencode"); + await write( + path.join(homeDir, ".config", "opencode", "opencode.jsonc"), + text, + ); + + return (await detectAgents({ homeDir, env }))[0]?.connected; + }; + + expect( + await opencode( + '{\n "$schema": "https://opencode.ai/config.json", // mine\n "mcp": {\n /* added */ "whiteboard": { "type": "local" },\n },\n}\n', + ), + ).toBe(true); + expect( + await opencode('{ "mcp": { "servers": { "whiteboard": {} } } }'), + ).toBe(true); + expect( + await opencode('{ "mcp": { "other": { "url": "//whiteboard" } } }'), + ).toBe(false); + }); + + it("is not connected by a disabled or other plugin, and a harness without its CLI on PATH is manual", async () => { + const { homeDir, env } = await fakeHome(); + await write( + path.join(homeDir, ".codex", "config.toml"), + '[plugins."whiteboard@devfast"]\nenabled = false\n[plugins."other@devfast"]\nenabled = true\n', + ); + await write( + path.join(homeDir, ".pi", "agent", "settings.json"), + JSON.stringify({ packages: ["npm:@dev.fast/pi-whiteboard-extra"] }), + ); + + expect(await detectAgents({ homeDir, env })).toEqual([ + { + id: "codex", + name: "Codex", + connected: false, + manual: true, + }, + { id: "pi", name: "Pi", connected: false, manual: true }, + ]); + }); + + it("reads unreadable records as not connected", async () => { + const { homeDir, bin, env } = await fakeHome(); + await fakeCli(bin, "claude"); + await write( + path.join(homeDir, ".claude", "plugins", "installed_plugins.json"), + "{not json", + ); + + expect(await detectAgents({ homeDir, env })).toEqual([ + { id: "claude", name: "Claude Code", connected: false }, + ]); + }); +}); + +describe("connectAgents", () => { + it("runs one harness's prompt commands, and changes no other harness", async () => { + const { homeDir, bin, env } = await fakeHome(); + await mkdir(path.join(homeDir, ".codex")); + const codexConfig = path.join(homeDir, ".codex", "config.toml"); + await writeFile(codexConfig, 'model = "x"\n'); + await fakeCli(bin, "codex"); + await fakeCli( + bin, + "pi", + `mkdir -p "$HOME/.pi/agent" && printf '{"packages":["%s"]}' "$2" > "$HOME/.pi/agent/settings.json"`, + ); + await mkdir(path.join(homeDir, ".pi")); + + const results = await connectAgents({ agents: ["pi"], homeDir, env }); + + expect(results).toEqual([ + expect.objectContaining({ id: "pi", connected: true }), + ]); + expect(await readFile(path.join(homeDir, "calls.log"), "utf8")).toBe( + "pi install npm:@dev.fast/pi-whiteboard\n", + ); + expect(await readFile(codexConfig, "utf8")).toBe('model = "x"\n'); + }); + + async function claudeHome(installExit: number) { + const home = await fakeHome(); + const registration = path.join(home.homeDir, ".claude.json"); + await mkdir(path.join(home.homeDir, ".claude")); + await writeFile(registration, '{"mcpServers":{"whiteboard":{}}}'); + await fakeCli( + home.bin, + "claude", + `case "$2" in +install) [ ${installExit} = 0 ] || { echo "network down" >&2; exit ${installExit}; } + mkdir -p "$HOME/.claude/plugins" && echo '{"version":2,"plugins":{"whiteboard@devfast":[{"scope":"user"}]}}' > "$HOME/.claude/plugins/installed_plugins.json" ;; +remove) rm "$HOME/.claude.json"; echo "Removed" ;; +esac`, + ); + + return { ...home, registration }; + } + + it("replaces an old registration only once the plugin is installed", async () => { + const { homeDir, env, registration } = await claudeHome(0); + + const [result] = await connectAgents({ agents: ["claude"], homeDir, env }); + + expect(result).toMatchObject({ id: "claude", connected: true }); + await expect(readFile(registration, "utf8")).rejects.toThrow("ENOENT"); + }); + + it("stops at a failed install, and keeps the old registration", async () => { + const { homeDir, env, registration } = await claudeHome(1); + + const [result] = await connectAgents({ agents: ["claude"], homeDir, env }); + + expect(result).toMatchObject({ id: "claude", connected: false }); + expect(result?.output).toContain("network down"); + expect(await readFile(registration, "utf8")).toContain("whiteboard"); + expect( + await readFile(path.join(homeDir, "calls.log"), "utf8"), + ).not.toContain("mcp remove"); + }); + + it("tolerates a cleanup that finds nothing to remove", async () => { + const { homeDir, bin, env } = await fakeHome(); + await mkdir(path.join(homeDir, ".codex")); + await fakeCli( + bin, + "codex", + `case "$1 $2" in +"plugin add") printf '[plugins."whiteboard@devfast"]\nenabled = true\n' > "$HOME/.codex/config.toml" ;; +"mcp remove") echo "No MCP server named whiteboard" >&2; exit 1 ;; +esac`, + ); + + const [result] = await connectAgents({ agents: ["codex"], homeDir, env }); + + expect(result).toMatchObject({ id: "codex", connected: true }); + expect(result?.output).not.toContain("Stopped"); + }); + + it("judges by the harness's record afterwards, and reports a failure with its output", async () => { + const { homeDir, bin, env } = await fakeHome(); + await mkdir(path.join(homeDir, ".codex")); + await fakeCli(bin, "codex", 'echo "network down" >&2; exit 2'); + + const [result] = await connectAgents({ agents: ["codex"], homeDir, env }); + + expect(result).toMatchObject({ id: "codex", connected: false }); + expect(result?.output).toContain("network down"); + }); + + it("ends a command that outlives the bound", async () => { + const { homeDir, bin, env } = await fakeHome(); + await mkdir(path.join(homeDir, ".pi")); + await fakeCli(bin, "pi", "sleep 30"); + + const [result] = await connectAgents({ + agents: ["pi"], + homeDir, + env, + timeoutMs: 200, + }); + + expect(result).toMatchObject({ id: "pi", connected: false }); + expect(result?.output).toContain("did not finish within"); + }); +}); diff --git a/packages/review/src/connect-agents.ts b/packages/review/src/connect-agents.ts new file mode 100644 index 000000000..f7498a599 --- /dev/null +++ b/packages/review/src/connect-agents.ts @@ -0,0 +1,285 @@ +import { spawn } from "node:child_process"; +import { constants } from "node:fs"; +import { access, readFile } from "node:fs/promises"; +import path from "node:path"; + +import { + REVIEW_CLI_INSTALL_TARGET_LABELS, + REVIEW_REMOTE_AGENT_IDS, + type ReviewRemoteAgentId, +} from "@dev.fast/review-protocol"; +import { + type AgentTraceHookAgent, + agentTraceHomeDirectory, +} from "@dev.fast/trace-core"; +import { parse as parseToml } from "smol-toml"; +import { z } from "zod"; + +import { CONNECT_COMMANDS, type ConnectCommand } from "./connect-prompts"; +import { isDirectory } from "./fs-utils"; + +export const AGENT_CONNECT_TARGETS: readonly (ReviewRemoteAgentId & + AgentTraceHookAgent)[] = REVIEW_REMOTE_AGENT_IDS; + +export type AgentConnectTarget = ReviewRemoteAgentId; + +export interface DetectedAgent { + id: AgentConnectTarget; + name: string; + connected: boolean; + manual?: true; +} + +export interface ConnectedAgent { + id: AgentConnectTarget; + name: string; + connected: boolean; + output: string; +} + +const OUTPUT_LIMIT = 4000; + +export const AGENT_CONNECT_TIMEOUT_MS = 120_000; + +interface Scope { + homeDir: string; + env: NodeJS.ProcessEnv; +} + +export async function detectAgents(scope: Scope): Promise { + const found: DetectedAgent[] = []; + + for (const id of AGENT_CONNECT_TARGETS) { + if ( + !(await isDirectory( + agentTraceHomeDirectory(id, scope.homeDir, scope.env), + )) + ) + continue; + + const manual = !(await onPath(CONNECT_COMMANDS[id][0].argv[0], scope.env)); + + found.push({ + id, + name: REVIEW_CLI_INSTALL_TARGET_LABELS[id], + connected: await connected(id, scope), + ...(manual && { manual: true as const }), + }); + } + + return found; +} + +export async function connectAgents( + input: Scope & { + agents: readonly AgentConnectTarget[]; + timeoutMs?: number; + }, +): Promise { + const results: ConnectedAgent[] = []; + + for (const id of input.agents) { + const deadline = Date.now() + (input.timeoutMs ?? AGENT_CONNECT_TIMEOUT_MS); + let output = ""; + + const commands: readonly ConnectCommand[] = CONNECT_COMMANDS[id]; + + for (const { argv, ifAny } of commands) { + const left = deadline - Date.now(); + + if (left <= 0) break; + const step = await run(argv, input.env, left); + + output += `$ ${argv.join(" ")}\n${step.output}`; + + if (!step.ok && !ifAny) { + output += `Stopped: ${argv.join(" ")} failed.\n`; + break; + } + } + + results.push({ + id, + name: REVIEW_CLI_INSTALL_TARGET_LABELS[id], + connected: await connected(id, input), + output: output.slice(-OUTPUT_LIMIT), + }); + } + + return results; +} + +const WHITEBOARD_PLUGIN = "whiteboard@devfast"; + +const ClaudePluginsSchema = z.object({ + plugins: z.record(z.string(), z.unknown()), +}); + +const CodexConfigSchema = z.object({ + plugins: z.record(z.string(), z.unknown()).optional(), +}); + +const CodexPluginSchema = z.object({ enabled: z.boolean().optional() }); + +const PiSettingsSchema = z.object({ + packages: z.array( + z.union([ + z.string(), + z.object({ source: z.string() }).transform((entry) => entry.source), + ]), + ), +}); + +const PI_PACKAGE = /^npm:@dev\.fast\/pi-whiteboard(@.*)?$/; + +const OPENCODE_PLUGIN = + /"plugin"\s*:\s*\[[^\]]*"@dev\.fast\/opencode-whiteboard(@[^"]*)?"/; + +const OpenCodeConfigSchema = z.object({ + mcp: z.object({ + whiteboard: z.unknown().optional(), + servers: z.object({ whiteboard: z.unknown().optional() }).optional(), + }), +}); + +/** OpenCode's config is JSONC: drop comments and trailing commas, keep strings. */ +const JSONC_EXTRAS = + /("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\/|,(?=\s*[}\]])/g; + +function openCodeConnected(text: string): boolean { + if (OPENCODE_PLUGIN.test(text)) return true; + + try { + const { mcp } = OpenCodeConfigSchema.parse( + JSON.parse(text.replace(JSONC_EXTRAS, "$1")), + ); + + return ( + mcp.whiteboard !== undefined || mcp.servers?.whiteboard !== undefined + ); + } catch { + return false; + } +} + +async function connected( + id: AgentConnectTarget, + scope: Scope, +): Promise { + const home = agentTraceHomeDirectory(id, scope.homeDir, scope.env); + + const read = (...parts: string[]) => + readFile(path.join(home, ...parts), "utf8"); + + try { + switch (id) { + case "claude": { + const installs = ClaudePluginsSchema.parse( + JSON.parse(await read("plugins", "installed_plugins.json")), + ).plugins[WHITEBOARD_PLUGIN]; + + return Array.isArray(installs) + ? installs.length > 0 + : Boolean(installs); + } + + case "codex": { + const entry = CodexConfigSchema.parse( + parseToml(await read("config.toml")), + ).plugins?.[WHITEBOARD_PLUGIN]; + + return ( + entry !== undefined && + CodexPluginSchema.parse(entry).enabled !== false + ); + } + + case "opencode": { + for (const name of ["opencode.json", "opencode.jsonc", "config.json"]) { + if (openCodeConnected(await read(name).catch(() => ""))) return true; + } + + return false; + } + + case "pi": + return PiSettingsSchema.parse( + JSON.parse(await read("agent", "settings.json")), + ).packages.some((entry) => PI_PACKAGE.test(entry)); + } + } catch { + return false; + } +} + +async function onPath( + command: string, + env: NodeJS.ProcessEnv, +): Promise { + for (const directory of (env.PATH ?? "").split(path.delimiter)) { + if (!path.isAbsolute(directory)) continue; + const candidate = path.join(directory, command); + + try { + await access(candidate, constants.X_OK); + + return candidate; + } catch {} + } + + return undefined; +} + +async function run( + argv: readonly string[], + env: NodeJS.ProcessEnv, + timeoutMs: number, +): Promise<{ output: string; ok: boolean }> { + const [command = "", ...args] = argv; + const file = await onPath(command, env); + + if (!file) + return { output: `${command} was not found on PATH.\n`, ok: false }; + + return new Promise((resolve) => { + let output = ""; + + const keep = (chunk: Buffer) => { + output = (output + chunk.toString("utf8")).slice(-OUTPUT_LIMIT); + }; + + const child = spawn(file, args, { + env, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + + const timer = setTimeout(() => { + output += `\n${command} did not finish within ${Math.round(timeoutMs / 1000)} seconds.\n`; + + try { + process.kill(-child.pid!, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } + }, timeoutMs); + + child.stdout.on("data", keep); + child.stderr.on("data", keep); + child.once("error", (error) => { + clearTimeout(timer); + resolve({ output: `${output}${error.message}\n`, ok: false }); + }); + child.once("close", (code) => { + clearTimeout(timer); + resolve( + code === 0 + ? { output, ok: true } + : { + output: `${output}${output.endsWith("\n") || !output ? "" : "\n"}(exit ${code ?? "signal"})\n`, + ok: false, + }, + ); + }); + }); +} diff --git a/packages/review/src/connect-prompts.ts b/packages/review/src/connect-prompts.ts index 6dc718158..17a903235 100644 --- a/packages/review/src/connect-prompts.ts +++ b/packages/review/src/connect-prompts.ts @@ -89,6 +89,64 @@ export const CLAUDE_WINDOWS_MCP_ADD = `claude mcp add -s user whiteboard -- ${WI /** Copilot CLI loads the Claude plugin, so on Windows it too registers the server directly. */ export const COPILOT_WINDOWS_MCP_ADD = `copilot mcp add whiteboard -- ${WINDOWS_MCP_LAUNCH.command} ${WINDOWS_MCP_LAUNCH.args.join(" ")}`; +export interface ConnectCommand { + argv: readonly string[]; + ifAny?: true; +} + +export const CONNECT_COMMANDS = { + claude: [ + { + argv: ["claude", "plugin", "marketplace", "add", "devdotfast/whiteboard"], + }, + { + argv: [ + "claude", + "plugin", + "install", + "whiteboard@devfast", + "--scope", + "user", + ], + }, + { + argv: ["claude", "mcp", "remove", "-s", "user", "whiteboard"], + ifAny: true, + }, + ], + codex: [ + { + argv: ["codex", "plugin", "marketplace", "add", "devdotfast/whiteboard"], + }, + { argv: ["codex", "plugin", "add", "whiteboard@devfast"] }, + { argv: ["codex", "mcp", "remove", "whiteboard"], ifAny: true }, + ], + opencode: [ + { + argv: [ + "opencode", + "mcp", + "add", + "--global", + "whiteboard", + "--", + REVIEW_MCP_LAUNCH.command, + ...REVIEW_MCP_LAUNCH.args, + ], + }, + ], + pi: [{ argv: ["pi", "install", PI_WHITEBOARD_PACKAGE] }], +} as const satisfies Record; + +function runBlock(commands: readonly ConnectCommand[]): string { + const lines = commands.map( + ({ argv, ifAny }) => + `${argv.join(" ")}${ifAny ? " # old manual registration, if any" : ""}`, + ); + + return `Run:\n\n\`\`\`sh\n${lines.join("\n")}\n\`\`\``; +} + function pluginSteps( target: Exclude, platform: NodeJS.Platform, @@ -100,13 +158,9 @@ function pluginSteps( `Run:\n\n\`\`\`sh\nclaude plugin uninstall whiteboard@devfast # its launch cannot start on Windows, if installed\nclaude mcp remove -s user whiteboard # old registration, if any\n${CLAUDE_WINDOWS_MCP_ADD}\n\`\`\``, ]; - return [ - "Run:\n\n```sh\nclaude plugin marketplace add devdotfast/whiteboard\nclaude plugin install whiteboard@devfast --scope user\nclaude mcp remove -s user whiteboard # old manual registration, if any\n```", - ]; + return [runBlock(CONNECT_COMMANDS.claude)]; case "codex": - return [ - "Run:\n\n```sh\ncodex plugin marketplace add devdotfast/whiteboard\ncodex plugin add whiteboard@devfast\ncodex mcp remove whiteboard # old manual registration, if any\n```", - ]; + return [runBlock(CONNECT_COMMANDS.codex)]; case "opencode": { const launch = reviewMcpLaunch(true, platform); From 47843f97b3522c7d24edc231fda778628f5bd3df Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Wed, 14 Oct 2026 13:13:59 -0400 Subject: [PATCH 09/14] Show installs, agents and removal in Settings The remote hosts section offers Install, Connect and "Also remove Whiteboard", and failed details copy on click. --- .../browser/reviewRemoteHostsSettings.test.ts | 7 +- .../browser/reviewRemoteHostsSettings.ts | 6 +- .../src/vs/review/common/reviewSshPrompt.ts | 5 +- .../remote/reviewSshPrompt.contribution.ts | 8 +- .../remote/reviewSshPromptInput.test.ts | 21 +- .../contrib/remote/reviewSshPromptInput.ts | 20 +- packages/review/app/src/copy-text.tsx | 78 ++++-- .../src/remote-hosts-section.browser.test.tsx | 243 +++++++++++++++++- .../review/app/src/remote-hosts-section.tsx | 239 +++++++++++++++-- packages/review/app/src/review-home-view.tsx | 8 +- 10 files changed, 570 insertions(+), 65 deletions(-) diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/reviewRemoteHostsSettings.test.ts b/apps/review-desktop/code-oss/src/vs/review/browser/reviewRemoteHostsSettings.test.ts index 1beaece1c..2c6cbf868 100644 --- a/apps/review-desktop/code-oss/src/vs/review/browser/reviewRemoteHostsSettings.test.ts +++ b/apps/review-desktop/code-oss/src/vs/review/browser/reviewRemoteHostsSettings.test.ts @@ -13,6 +13,7 @@ function fixture(values: Record) { const settings = new Map(Object.entries(values)); const writes: unknown[] = []; const retried: string[] = []; + const uninstalled: string[] = []; const hosts = reviewRemoteHostsSettings({ get: (key) => settings.get(key), update: async (key, value) => { @@ -23,9 +24,13 @@ function fixture(values: Record) { listSshAliases: async () => ["devbox"], readRemoteHosts: async () => [{ alias: "devbox", state: "online" }], retryRemoteHost: async (alias) => { retried.push(alias); }, + installRemoteHost: async (alias) => { retried.push(`install ${alias}`); }, + detectRemoteAgents: async () => null, + connectRemoteAgents: async () => [], + uninstallRemoteHost: async (alias) => { uninstalled.push(alias); }, }, }); - return { hosts, writes, retried }; + return { hosts, writes, retried, uninstalled }; } test("reads the two settings as main does", () => { diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/reviewRemoteHostsSettings.ts b/apps/review-desktop/code-oss/src/vs/review/browser/reviewRemoteHostsSettings.ts index 20194ba4a..940e1f91c 100644 --- a/apps/review-desktop/code-oss/src/vs/review/browser/reviewRemoteHostsSettings.ts +++ b/apps/review-desktop/code-oss/src/vs/review/browser/reviewRemoteHostsSettings.ts @@ -11,7 +11,7 @@ import type { IReviewDesktopConnectionService } from "../services/reviewDesktopC export function reviewRemoteHostsSettings(input: { get(key: string): unknown; update(key: string, value: string[]): Promise; - connection: Pick; + connection: Pick; }): ReviewRemoteHostsSettings { const configured = () => remoteHostAliases(input.get(REVIEW_REMOTE_HOSTS_SETTING)); return { @@ -20,6 +20,10 @@ export function reviewRemoteHostsSettings(input: { suggestions: () => input.connection.listSshAliases(), states: () => input.connection.readRemoteHosts(), retry: (alias) => input.connection.retryRemoteHost(alias), + install: (alias) => input.connection.installRemoteHost(alias), + agents: (alias) => input.connection.detectRemoteAgents(alias), + connectAgents: (alias, agents) => input.connection.connectRemoteAgents(alias, agents), + uninstall: (alias) => input.connection.uninstallRemoteHost(alias), set: async (aliases) => { for (const alias of aliases) { const valid = validateSshAlias(alias); diff --git a/apps/review-desktop/code-oss/src/vs/review/common/reviewSshPrompt.ts b/apps/review-desktop/code-oss/src/vs/review/common/reviewSshPrompt.ts index 6fb30527e..9ad20688a 100644 --- a/apps/review-desktop/code-oss/src/vs/review/common/reviewSshPrompt.ts +++ b/apps/review-desktop/code-oss/src/vs/review/common/reviewSshPrompt.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See LICENSE in the repository root for license information. *--------------------------------------------------------------------------------------------*/ -export type SshPromptKind = "secret" | "confirm" | "text"; +export type SshPromptKind = "secret" | "confirm" | "text" | "install"; export type ReviewSshPromptEvent = | { readonly id: number; readonly alias: string; readonly text: string; readonly kind: SshPromptKind } @@ -12,6 +12,9 @@ export type ReviewSshPromptEvent = export const REVIEW_SSH_PROMPT_EVENT = "onSshPrompt"; export const REVIEW_SSH_ANSWER_CALL = "answerSshPrompt"; +export const REVIEW_REMOTE_INSTALL_YES = "install"; +export const REVIEW_REMOTE_INSTALL_NO = "decline"; + export const REVIEW_SSH_ASKPASS_SOCKET_ENV = "DEV_FAST_REVIEW_SSH_ASKPASS_SOCKET"; export const REVIEW_SSH_ASKPASS_ALIAS_ENV = "DEV_FAST_REVIEW_SSH_ASKPASS_ALIAS"; diff --git a/apps/review-desktop/code-oss/src/vs/review/contrib/remote/reviewSshPrompt.contribution.ts b/apps/review-desktop/code-oss/src/vs/review/contrib/remote/reviewSshPrompt.contribution.ts index 0ccdb8cf2..0684d3a3f 100644 --- a/apps/review-desktop/code-oss/src/vs/review/contrib/remote/reviewSshPrompt.contribution.ts +++ b/apps/review-desktop/code-oss/src/vs/review/contrib/remote/reviewSshPrompt.contribution.ts @@ -53,8 +53,14 @@ class ReviewSshPrompts extends Disposable { const input = createSshPromptInput( this.quickInputService, prompt, - localize("review.sshPrompt.title", "SSH: {0}", prompt.alias), + prompt.kind === "install" + ? localize("review.remoteInstallPrompt.title", "Install Whiteboard on {0}?", prompt.alias) + : localize("review.sshPrompt.title", "SSH: {0}", prompt.alias), settle, + { + install: localize("review.remoteInstallPrompt.install", "Install"), + decline: localize("review.remoteInstallPrompt.decline", "Don't install"), + }, ); input.onDidHide(() => settle(undefined)); this.current = { id: prompt.id, input }; diff --git a/apps/review-desktop/code-oss/src/vs/review/contrib/remote/reviewSshPromptInput.test.ts b/apps/review-desktop/code-oss/src/vs/review/contrib/remote/reviewSshPromptInput.test.ts index dee65627f..cd36e2612 100644 --- a/apps/review-desktop/code-oss/src/vs/review/contrib/remote/reviewSshPromptInput.test.ts +++ b/apps/review-desktop/code-oss/src/vs/review/contrib/remote/reviewSshPromptInput.test.ts @@ -17,17 +17,17 @@ function fakeInput() { return { handlers, value: "", - selectedItems: [] as { label: string }[], + selectedItems: [] as { answer: string }[], onDidAccept: on("accept"), onWillHide: on("willHide"), - } as Record & { handlers: Record void>; value: string; selectedItems: { label: string }[] }; + } as Record & { handlers: Record void>; value: string; selectedItems: { answer: string }[] }; } -function create(kind: "secret" | "confirm" | "text", text: string) { +function create(kind: "secret" | "confirm" | "text" | "install", text: string) { const input = fakeInput(); const answers: (string | undefined)[] = []; const service = { createInputBox: () => input, createQuickPick: () => input } as never; - createSshPromptInput(service, { id: 1, alias: "wb-test-a", text, kind }, "SSH: wb-test-a", (answer) => answers.push(answer)); + createSshPromptInput(service, { id: 1, alias: "wb-test-a", text, kind }, "SSH: wb-test-a", (answer) => answers.push(answer), { install: "Install", decline: "Don't install" }); return { input, answers }; } @@ -55,7 +55,18 @@ test("a secret is hidden, answered with what was typed, and cleared before the w test("a confirm prompt answers with the picked yes or no", () => { const { input, answers } = create("confirm", "Are you sure you want to continue connecting (yes/no/[fingerprint])? "); - input.selectedItems = [{ label: "no" }]; + input.selectedItems = [{ answer: "no" }]; input.handlers.accept(); assert.deepEqual(answers, ["no"]); }); + +test("an install prompt offers Install and Don't install, and answers which was picked", () => { + const { input, answers } = create("install", "Whiteboard 0.1.6 is not installed on wb-test-a."); + assert.deepEqual( + (input.items as { label: string }[]).map((item) => item.label), + ["Install", "Don't install"], + ); + input.selectedItems = [(input.items as { answer: string }[])[1]]; + input.handlers.accept(); + assert.deepEqual(answers, ["decline"]); +}); diff --git a/apps/review-desktop/code-oss/src/vs/review/contrib/remote/reviewSshPromptInput.ts b/apps/review-desktop/code-oss/src/vs/review/contrib/remote/reviewSshPromptInput.ts index abfdc3e48..63a9cbf63 100644 --- a/apps/review-desktop/code-oss/src/vs/review/contrib/remote/reviewSshPromptInput.ts +++ b/apps/review-desktop/code-oss/src/vs/review/contrib/remote/reviewSshPromptInput.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { IQuickInput, IQuickInputService, IQuickPickItem } from "../../../platform/quickinput/common/quickInput.js"; -import type { ReviewSshPromptEvent } from "../../common/reviewSshPrompt.js"; +import { REVIEW_REMOTE_INSTALL_NO, REVIEW_REMOTE_INSTALL_YES, type ReviewSshPromptEvent } from "../../common/reviewSshPrompt.js"; export type ShownSshPrompt = Exclude; @@ -13,12 +13,22 @@ export function createSshPromptInput( prompt: ShownSshPrompt, title: string, accept: (answer: string | undefined) => void, + labels: { install: string; decline: string }, ): IQuickInput { let input: IQuickInput; - if (prompt.kind === "confirm") { - const pick = quickInputService.createQuickPick(); - pick.items = [{ label: "yes" }, { label: "no" }]; - pick.onDidAccept(() => accept(pick.selectedItems[0]?.label)); + if (prompt.kind === "confirm" || prompt.kind === "install") { + const pick = quickInputService.createQuickPick(); + pick.items = + prompt.kind === "install" + ? [ + { label: labels.install, answer: REVIEW_REMOTE_INSTALL_YES }, + { label: labels.decline, answer: REVIEW_REMOTE_INSTALL_NO }, + ] + : [ + { label: "yes", answer: "yes" }, + { label: "no", answer: "no" }, + ]; + pick.onDidAccept(() => accept(pick.selectedItems[0]?.answer)); input = pick; } else { const box = quickInputService.createInputBox(); diff --git a/packages/review/app/src/copy-text.tsx b/packages/review/app/src/copy-text.tsx index b5885d422..34e49061e 100644 --- a/packages/review/app/src/copy-text.tsx +++ b/packages/review/app/src/copy-text.tsx @@ -1,6 +1,6 @@ import { IconButton } from "@canvas/ui/button"; import * as stylex from "@stylexjs/stylex"; -import { type ReactElement, useEffect, useState } from "react"; +import { type ReactElement, type ReactNode, useEffect, useState } from "react"; import { CheckIcon, CopyIcon as CopyGlyph } from "./icons"; import { useTooltip } from "./use-tooltip"; @@ -72,6 +72,60 @@ export function CopyIcon() { const COPIED_FOR_MS = 1200; +function useCopy(text: string) { + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), COPIED_FOR_MS); + + return () => clearTimeout(timer); + }, [copied]); + + // The workbench denies DOM clipboard requests, so copyText falls back to execCommand. + const copy = () => + void copyText(text).then((ok) => { + if (ok) setCopied(true); + }); + + return [copied, copy] as const; +} + +export function CopyableText({ + text, + children = text, +}: { + text: string; + children?: ReactNode; +}): ReactElement { + const [copied, copy] = useCopy(text); + + const tooltip = useTooltip( + copied ? "Copied" : "Click to copy", + { instant: true }, + ); + + return ( + { + // A drag that selects part of the text should not copy all of it. + if (document.getSelection()?.isCollapsed !== false) copy(); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + copy(); + }} + > + {children} + + ); +} + /** An icon button that copies `text` and shows a check while it is copied. */ export function CopyButton({ text, @@ -86,16 +140,9 @@ export function CopyButton({ xstyle?: stylex.StyleXStyles; iconStyle: stylex.StyleXStyles; }): ReactElement { - const [copied, setCopied] = useState(false); + const [copied, copy] = useCopy(text); const tooltip = useTooltip(label); - useEffect(() => { - if (!copied) return; - const timer = setTimeout(() => setCopied(false), COPIED_FOR_MS); - - return () => clearTimeout(timer); - }, [copied]); - return ( { - // The workbench denies DOM clipboard requests; copyText falls back to - // execCommand and reports whether anything was copied. - void copyText(text).then((ok) => { - if (ok) setCopied(true); - }); - }} + onClick={copy} > {copied ? ( @@ -121,6 +162,11 @@ export function CopyButton({ } const styles = stylex.create({ + copyable: { + cursor: "pointer", + textDecorationLine: { default: "none", ":hover": "underline" }, + textUnderlineOffset: "2px", + }, icon: { width: "12px", height: "12px", diff --git a/packages/review/app/src/remote-hosts-section.browser.test.tsx b/packages/review/app/src/remote-hosts-section.browser.test.tsx index 3c1627099..3f7e5d709 100644 --- a/packages/review/app/src/remote-hosts-section.browser.test.tsx +++ b/packages/review/app/src/remote-hosts-section.browser.test.tsx @@ -32,6 +32,12 @@ function remoteHosts( states: vi.fn(async () => states), set: vi.fn(async (aliases) => aliases), retry: vi.fn(async () => {}), + install: vi.fn(async () => {}), + agents: vi.fn(async () => null), + connectAgents: vi.fn( + async () => [], + ), + uninstall: vi.fn(async () => {}), }; } @@ -94,7 +100,8 @@ test("the section is absent with the setting off", async () => { }); test("lists each host with its state and detail, and the install command from Desktop as code", async () => { - const install = "npm install -g @dev.fast/whiteboard@0.1.6"; + const install = + 'curl -fsSL https://example.test/a.tar.xz | tar -xJ\nexport PATH="$HOME/bin:$PATH"'; await render( remoteHosts( @@ -105,7 +112,7 @@ test("lists each host with its state and detail, and the install command from De alias: "box2", state: "not-installed", detail: - "Whiteboard is not installed on box2. Install Whiteboard 0.1.6 there; Node 24 is needed.", + "Whiteboard is not installed on box2. Install Whiteboard 0.1.6 there.", installCommand: install, }, { alias: "box4", state: "connecting" }, @@ -116,12 +123,15 @@ test("lists each host with its state and detail, and the install command from De await vi.waitFor(() => expect(rows()[0]).toContain("online")); expect(rows()[1]).toContain("not installed"); expect(rows()[1]).toContain( - "Whiteboard is not installed on box2. Install Whiteboard 0.1.6 there; Node 24 is needed.", + "Whiteboard is not installed on box2. Install Whiteboard 0.1.6 there.", ); expect(rows()[2]).toContain("connecting"); expect( [...section()!.querySelectorAll("code")].map((code) => code.textContent), ).toEqual([install]); + expect( + getComputedStyle(section()!.querySelector("code")!).whiteSpace, + ).toMatch(/^pre/); expect( page .getByRole("button", { name: /^Retry / }) @@ -160,7 +170,7 @@ test("says whether an online host has language features, and why not", async () await vi.waitFor(() => expect(rows()[0]).toContain("online")); expect(rows()[0]).toContain("Language features: available"); expect(rows()[1]).toContain( - "Language features: unavailable — it runs e10c782, this Desktop 3c82a2a", + "Language features: unavailable · it runs e10c782, this Desktop 3c82a2a", ); expect(rows()[2]).not.toContain("Language features"); }); @@ -196,26 +206,34 @@ test("lists an online host's language groups after its language features, each w await vi.waitFor(() => expect(rows()[0]).toContain("online")); expect( [ - ...document.querySelector("[data-remote-host]")!.querySelectorAll("span"), + ...document + .querySelector("[data-remote-host]")! + .querySelectorAll(":scope > div > span"), ].map((line) => line.textContent), ).toEqual([ "devbox", "online", "Language features: available", "rust: installed", - "swift: installed — swift was not found on the login shell's PATH", - "csharp: not installed — download failed", + "swift: installed · swift was not found on the login shell's PATH", + "csharp: not installed · download failed", ]); expect(rows()[1]).not.toContain("rust"); }); -test("offers Retry to a host that failed to authenticate, is unreachable or is not installed", async () => { +test("offers Retry to a host that failed to authenticate, is unreachable, not installed or unsupported", async () => { const hosts = remoteHosts( - ["devbox", "box2", "box3"], + ["devbox", "box2", "box3", "box4", "box5"], [ { alias: "devbox", state: "auth-failed", detail: "Permission denied" }, { alias: "box2", state: "unreachable", detail: "timed out" }, { alias: "box3", state: "not-installed", detail: "Not installed." }, + { + alias: "box4", + state: "unsupported", + detail: "This host runs glibc 2.31; Whiteboard needs 2.34 or newer.", + }, + { alias: "box5", state: "installing" }, ], ); @@ -225,12 +243,129 @@ test("offers Retry to a host that failed to authenticate, is unreachable or is n page.getByRole("button", { name: "Retry devbox" }).elements(), ).toHaveLength(1), ); + expect(rows()[3]).toContain("unsupported"); + expect(rows()[3]).toContain("glibc 2.31"); + expect(rows()[4]).toContain("installing"); + expect( + page + .getByRole("button", { name: /^Retry / }) + .elements() + .map((button) => button.getAttribute("aria-label")), + ).toEqual(["Retry devbox", "Retry box2", "Retry box3", "Retry box4"]); await page.getByRole("button", { name: "Retry box2" }).click(); expect(hosts.retry).toHaveBeenCalledWith("box2"); await page.getByRole("button", { name: "Retry box3" }).click(); expect(hosts.retry).toHaveBeenCalledWith("box3"); }); +test("shows the install step, offers Install to a declined host, and Retry after a failed install", async () => { + const hosts = remoteHosts( + ["box1", "box2", "box3", "box4"], + [ + { + alias: "box1", + state: "installing", + detail: "Installing the Whiteboard package.", + }, + { + alias: "box2", + state: "not-installed", + detail: "Whiteboard is not installed on box2.", + installCommand: "curl -fsSL https://example.test/a.tar.xz | tar -xJ", + declined: true, + }, + { + alias: "box3", + state: "not-installed", + detail: + "Installing Whiteboard 0.1.6 on box3 failed while installing the package: the package does not match its pinned integrity.", + }, + { + alias: "box4", + state: "incompatible", + detail: + "box4 runs Whiteboard 0.1.5; this Desktop runs 0.1.6. Install Whiteboard 0.1.6 on box4.", + declined: true, + }, + ], + ); + + await render(hosts); + await vi.waitFor(() => + expect(rows()[0]).toContain( + "installing · Installing the Whiteboard package.", + ), + ); + expect( + page + .getByRole("button", { name: /^(Retry|Install) / }) + .elements() + .map((button) => button.getAttribute("aria-label")), + ).toEqual([ + "Install box2", + "Retry box2", + "Retry box3", + "Install box4", + "Retry box4", + ]); + await page.getByRole("button", { name: "Install box2" }).click(); + expect(hosts.install).toHaveBeenCalledWith("box2"); + await page.getByRole("button", { name: "Retry box3" }).click(); + expect(hosts.retry).toHaveBeenCalledWith("box3"); +}); + +test("offers an online host's unconnected agents by Desktop's names, connects them, and shows the result as plain text", async () => { + const hosts = remoteHosts( + ["box1", "box2"], + [ + { alias: "box1", serverId: "s1", state: "online" }, + { alias: "box2", state: "unreachable", detail: "timed out" }, + ], + ); + + vi.mocked(hosts.agents) + .mockResolvedValueOnce([ + { id: "claude", connected: true }, + { id: "codex", connected: false }, + { id: "pi", connected: false }, + { id: "opencode", connected: false, manual: true }, + ]) + .mockResolvedValue([ + { id: "claude", connected: true }, + { id: "codex", connected: false }, + { id: "pi", connected: true }, + { id: "opencode", connected: false, manual: true }, + ]); + vi.mocked(hosts.connectAgents).mockResolvedValue([ + { id: "codex", connected: false, output: "codex: command failed" }, + { id: "pi", connected: true, output: "" }, + ]); + + await render(hosts); + await vi.waitFor(() => + expect(rows()[0]).toContain("Agents on box1: Codex, Pi — Connect"), + ); + expect(hosts.agents).toHaveBeenCalledWith("box1"); + expect(hosts.agents).not.toHaveBeenCalledWith("box2"); + expect(hosts.connectAgents).not.toHaveBeenCalled(); + expect(rows()[0]).toContain( + "Paste into OpenCode on box1: Run `whiteboard connect opencode` and follow the instructions to connect this agent to Whiteboard.", + ); + + await page.getByRole("button", { name: "Connect agents on box1" }).click(); + + expect(hosts.connectAgents).toHaveBeenCalledWith("box1", ["codex", "pi"]); + await vi.waitFor(() => + expect(rows()[0]).toContain( + "Codex was not connected on box1: codex: command failed Pi is connected on box1.", + ), + ); + expect(section()!.querySelector("b")).toBeNull(); + await vi.waitFor(() => + expect(rows()[0]).toContain("Agents on box1: Codex — Connect"), + ); +}); + test("adding -bad is refused with the reason", async () => { const hosts = remoteHosts([]); @@ -264,6 +399,94 @@ test("adds a host from the suggestions, and removes one", async () => { expect(hosts.set).toHaveBeenLastCalledWith(["other", "devbox"]); await vi.waitFor(() => expect(rows()).toHaveLength(2)); await page.getByRole("button", { name: "Remove other" }).click(); - expect(hosts.set).toHaveBeenLastCalledWith(["devbox"]); + expect(hosts.set).toHaveBeenLastCalledWith(["other", "devbox"]); + await page.getByRole("button", { name: "Remove host" }).click(); + await vi.waitFor(() => + expect(hosts.set).toHaveBeenLastCalledWith(["devbox"]), + ); + await vi.waitFor(() => expect(rows()).toHaveLength(1)); +}); + +test("removing a host leaves Whiteboard on it unless asked", async () => { + const hosts = remoteHosts(["devbox", "box2"]); + + await render(hosts); + await page.getByRole("button", { name: "Remove devbox" }).click(); + const also = page.getByLabelText("Also remove Whiteboard from devbox"); + await expect.element(also).not.toBeChecked(); + await page.getByRole("button", { name: "Remove host" }).click(); + await vi.waitFor(() => + expect(hosts.set).toHaveBeenLastCalledWith(["box2"]), + ); + expect(hosts.uninstall).not.toHaveBeenCalled(); + + await page.getByRole("button", { name: "Remove box2" }).click(); + await expect + .element(page.getByLabelText("Also remove Whiteboard from box2")) + .not.toBeChecked(); + expect(section()!.querySelector("b")).toBeNull(); +}); + +test("also removing Whiteboard uninstalls before the host goes, and a failure is shown while the host is still removed", async () => { + const hosts = remoteHosts(["devbox", "box2"]); + const order: string[] = []; + + vi.mocked(hosts.uninstall).mockImplementation(async (alias) => { + order.push(`uninstall ${alias}`); + + if (alias === "box2") + throw new Error( + "Could not remove Whiteboard from box2: A Whiteboard server you started (process 7) runs from it.", + ); + }); + vi.mocked(hosts.set).mockImplementation(async (aliases) => { + order.push(`set ${aliases.join(",")}`); + + return aliases; + }); + await render(hosts); + + await page.getByRole("button", { name: "Remove devbox" }).click(); + await page.getByLabelText("Also remove Whiteboard from devbox").click(); + await page.getByRole("button", { name: "Remove host" }).click(); await vi.waitFor(() => expect(rows()).toHaveLength(1)); + expect(order).toEqual(["uninstall devbox", "set box2"]); + expect(section()!.querySelector('[role="alert"]')).toBeNull(); + + await page.getByRole("button", { name: "Remove box2" }).click(); + await page.getByLabelText("Also remove Whiteboard from box2").click(); + await page.getByRole("button", { name: "Remove host" }).click(); + await vi.waitFor(() => + expect(section()!.querySelector('[role="alert"]')?.textContent).toBe( + "Could not remove Whiteboard from box2: A Whiteboard server you started (process 7) runs from it.", + ), + ); + expect(rows()).toEqual([]); + expect(order.slice(2)).toEqual(["uninstall box2", "set "]); +}); + +test("a failed host's detail copies on click; a healthy one does not", async () => { + const writeText = vi + .spyOn(navigator.clipboard, "writeText") + .mockResolvedValue(); + + await render( + remoteHosts( + ["devbox", "box2"], + [ + { alias: "devbox", serverId: "s1", state: "online" }, + { alias: "box2", state: "unreachable", detail: "ssh: timed out" }, + ], + ), + ); + + await vi.waitFor(() => + expect(rows()[1]).toContain("unreachable · ssh: timed out"), + ); + expect(page.getByRole("button", { name: "online" }).query()).toBeNull(); + + await page + .getByRole("button", { name: "unreachable · ssh: timed out" }) + .click(); + expect(writeText).toHaveBeenCalledWith("unreachable · ssh: timed out"); }); diff --git a/packages/review/app/src/remote-hosts-section.tsx b/packages/review/app/src/remote-hosts-section.tsx index f6f51775b..375bfb079 100644 --- a/packages/review/app/src/remote-hosts-section.tsx +++ b/packages/review/app/src/remote-hosts-section.tsx @@ -1,12 +1,16 @@ import { Button } from "@canvas/ui/button"; +import { Checkbox } from "@canvas/ui/checkbox"; import { TextField } from "@canvas/ui/text-field"; -import type { - ReviewGatewayHostState, - ReviewRemoteHostsSettings, +import { + REVIEW_CLI_INSTALL_TARGET_LABELS, + type ReviewGatewayHostState, + type ReviewRemoteAgent, + type ReviewRemoteHostsSettings, } from "@dev.fast/review-protocol"; import * as stylex from "@stylexjs/stylex"; import { useEffect, useState } from "react"; +import { CopyableText } from "./copy-text"; import { settingsStyles as styles } from "./settings-styles"; import { tokens } from "./tokens.stylex"; @@ -14,8 +18,10 @@ const STATES_EVERY_MS = 3000; const RETRIED = new Set([ "auth-failed", + "incompatible", "not-installed", "unreachable", + "unsupported", ]); export function RemoteHostsSection({ @@ -29,6 +35,8 @@ export function RemoteHostsSection({ const [alias, setAlias] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(); + const [removing, setRemoving] = useState(); + const [uninstall, setUninstall] = useState(false); useEffect(() => { void hosts @@ -85,6 +93,25 @@ export function RemoteHostsSection({ if (await save([...configured, alias.trim()])) setAlias(""); }; + const remove = async (name: string) => { + setRemoving(undefined); + let problem: string | undefined; + + if (uninstall) { + setBusy(true); + + try { + await hosts.uninstall(name); + } catch (cause) { + problem = cause instanceof Error ? cause.message : String(cause); + } + } + + await save(configured.filter((other) => other !== name)); + + if (problem) setError(problem); + }; + return (

Remote hosts

@@ -96,28 +123,35 @@ export function RemoteHostsSection({
{name} {states ? ( - - {(state?.state ?? "connecting").replace("-", " ")} - {state?.detail ? ` · ${state.detail}` : null} - + ) : null} {state?.state === "online" ? ( - - {state.languageFeatures - ? "Language features: available" - : `Language features: unavailable${state.languageFeaturesDetail ? ` — ${state.languageFeaturesDetail}` : ""}`} - + ) : null} {state?.state === "online" ? state.languageGroups?.map(({ group, installed, detail }) => ( - - {`${group}: ${installed ? "installed" : "not installed"}${detail ? ` — ${detail}` : ""}`} - + failed={!!detail} + text={`${group}: ${installed ? "installed" : "not installed"}${detail ? ` · ${detail}` : ""}`} + /> )) : null} + {state?.state === "online" ? ( + + ) : null} {state?.installCommand ? ( @@ -127,6 +161,16 @@ export function RemoteHostsSection({ ) : null}
+ {state?.declined ? ( + + ) : null} {state && RETRIED.has(state.state) ? (
+ {removing === name ? ( +
+ + + +
+ ) : null} ); })} @@ -158,8 +223,8 @@ export function RemoteHostsSection({
Add a host - An alias from your SSH configuration. Whiteboard must be installed - there. + An alias from your SSH configuration. Whiteboard offers to install + itself there.
@@ -186,21 +251,148 @@ export function RemoteHostsSection({ {error ? (

- {error} +

) : null}
); } +function RemoteHostAgents({ + hosts, + alias, +}: { + hosts: ReviewRemoteHostsSettings; + alias: string; +}) { + const [agents, setAgents] = useState(null); + const [busy, setBusy] = useState(false); + const [result, setResult] = useState<{ text: string; failed: boolean }>(); + + useEffect(() => { + let live = true; + + void hosts + .agents(alias) + .then((next) => { + if (live) setAgents(next); + }) + .catch(() => undefined); + + return () => { + live = false; + }; + }, [hosts, alias]); + + const offered = agents?.filter((agent) => !agent.connected && !agent.manual); + const manual = agents?.filter((agent) => !agent.connected && agent.manual); + + const connect = async (ids: ReviewRemoteAgent["id"][]) => { + setBusy(true); + setResult(undefined); + + try { + const results = await hosts.connectAgents(alias, ids); + + setResult({ + failed: results.some((done) => !done.connected), + text: results + .map((done) => + done.connected + ? `${REVIEW_CLI_INSTALL_TARGET_LABELS[done.id]} is connected on ${alias}.` + : `${REVIEW_CLI_INSTALL_TARGET_LABELS[done.id]} was not connected on ${alias}: ${done.output || "it printed nothing"}`, + ) + .join(" "), + }); + setAgents(await hosts.agents(alias)); + } catch (cause) { + setResult({ + failed: true, + text: `Could not connect agents on ${alias}: ${cause instanceof Error ? cause.message : String(cause)}`, + }); + } finally { + setBusy(false); + } + }; + + return ( + <> + {offered?.length ? ( + + Agents on {alias}:{" "} + {offered + .map((agent) => REVIEW_CLI_INSTALL_TARGET_LABELS[agent.id]) + .join(", ")}{" "} + —{" "} + + + ) : null} + {manual?.map((agent) => ( + + Paste into {REVIEW_CLI_INSTALL_TARGET_LABELS[agent.id]} on {alias}:{" "} + + Run `whiteboard connect {agent.id}` and follow the instructions to + connect this agent to Whiteboard. + + + ))} + {result ? ( + + ) : null} + + ); +} + +function Detail({ + text, + failed, + role, +}: { + text: string; + failed: boolean; + role?: "status"; +}) { + return ( + + {failed ? : text} + + ); +} + const local = stylex.create({ detail: { overflowWrap: "anywhere", userSelect: "text", }, + failed: { + color: tokens.changeRemoved, + }, actions: { gap: "8px", }, + confirm: { + display: "flex", + gridColumn: "1 / -1", + flexWrap: "wrap", + alignItems: "center", + gap: "8px", + }, alias: { minWidth: 0, flex: 1, @@ -209,5 +401,6 @@ const local = stylex.create({ color: tokens.ink, fontFamily: tokens.fontMono, userSelect: "all", + whiteSpace: "pre-wrap", }, }); diff --git a/packages/review/app/src/review-home-view.tsx b/packages/review/app/src/review-home-view.tsx index e1ceed2e7..2666edea5 100644 --- a/packages/review/app/src/review-home-view.tsx +++ b/packages/review/app/src/review-home-view.tsx @@ -19,7 +19,7 @@ import { useState, } from "react"; -import { copyText } from "./copy-text"; +import { CopyableText, copyText } from "./copy-text"; import { countLabel, formatRelativeTime } from "./format"; import { homeStyles } from "./home-styles"; import { CanvasUiContext, useCanvasMenu } from "./host/canvas-ui"; @@ -1063,7 +1063,11 @@ export function ReviewHome({ ) : null} {error &&

{error}

} - {hostMessage ?

{hostMessage}

: null} + {hostMessage ? ( +

+ +

+ ) : null} {!visible.length && ( Date: Wed, 14 Oct 2026 13:14:00 -0400 Subject: [PATCH 10/14] Install a dev tarball by hand and serve tarballs to the harness remote-dev-install.mjs installs this checkout's tarball on a host or publishes it; the e2e harness serves tarballs as a release would. --- .../scripts/e2e/remote/docker-hosts.mjs | 112 +++------- .../scripts/e2e/remote/image/setup.sh | 5 + .../scripts/e2e/remote/releases.mjs | 76 +++++++ .../scripts/e2e/remote/remote.mjs | 25 ++- .../scripts/remote-dev-install.mjs | 192 ++++++++++++++++++ 5 files changed, 319 insertions(+), 91 deletions(-) create mode 100644 apps/review-desktop/scripts/e2e/remote/releases.mjs create mode 100644 apps/review-desktop/scripts/remote-dev-install.mjs diff --git a/apps/review-desktop/scripts/e2e/remote/docker-hosts.mjs b/apps/review-desktop/scripts/e2e/remote/docker-hosts.mjs index 2bf5eb791..d4737ddcf 100644 --- a/apps/review-desktop/scripts/e2e/remote/docker-hosts.mjs +++ b/apps/review-desktop/scripts/e2e/remote/docker-hosts.mjs @@ -1,20 +1,17 @@ /** Hosts in Docker containers: the image, `up`, `install`, and the sealed network. */ import { createHash, randomBytes } from "node:crypto"; -import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { copyFile, readFile } from "node:fs/promises"; import path from "node:path"; -import { stageVscodeServer } from "../../stage-vscode-server.mjs"; +import { buildDevRemoteTarball } from "../../build-remote-tarball.mjs"; +import { remoteDevInstall } from "../../remote-dev-install.mjs"; + import { addOnce, docker, exec, run, sleep } from "./exec.mjs"; import { hostOf } from "./run-state.mjs"; import { waitForSsh } from "./ssh.mjs"; const imageDir = path.join(import.meta.dirname, "image"); -const packageDir = path.resolve( - import.meta.dirname, - "../../../../../packages/review", -); - const TOOLCHAIN_IMAGES = { rust: "rust:1.98-bookworm", swift: "swift:6.4-noble", @@ -198,14 +195,6 @@ export async function up(runState, name, options) { ); if (options.sealed) { - host.defaultRoute = await docker( - "exec", - container, - "ip", - "route", - "show", - "default", - ); host.sealed = true; await runState.save(); await seal(runState, host); @@ -308,75 +297,36 @@ export function containerOf(runState, name) { return host; } -export async function packStaged(tarball, { version, runtime } = {}) { - const scratch = await mkdtemp(`${path.dirname(tarball)}/pack-`); - - try { - await run("pnpm", [ - "--dir", - packageDir, - "pack", - "--pack-destination", - scratch, - ]); - - const [packed] = (await readdir(scratch)).filter((f) => f.endsWith(".tgz")); - await run("tar", ["-xzf", path.join(scratch, packed), "-C", scratch]); - - const manifestPath = path.join(scratch, "package/package.json"); - const manifest = JSON.parse(await readFile(manifestPath, "utf8")); +export async function packTarball(out, { version, arch = "x64" } = {}) { + const built = await buildDevRemoteTarball({ + target: `linux-${arch}`, + version, + stdio: ["ignore", process.stderr, process.stderr], + }); - if (version) { - manifest.version = version; - await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - } + await copyFile(built.file, out); - await stageVscodeServer(path.join(scratch, "package"), { runtime }); - await run("tar", ["-czf", tarball, "-C", scratch, "package"], { - env: { ...process.env, COPYFILE_DISABLE: "1" }, - }); - - return `${manifest.name}@${manifest.version}`; - } finally { - await rm(scratch, { recursive: true, force: true }); - } + return { ...built, file: out }; } -export async function install(runState, name, { version, runtime } = {}) { +/** Installs this checkout's tarball as a hand install would, with a `whiteboard` on the default PATH. */ +export async function install(runState, name, { version, commit } = {}) { const host = containerOf(runState, name); - const tarball = `${runState.dir}/staged-${randomBytes(3).toString("hex")}.tgz`; - - try { - const packed = await packStaged(tarball, { version, runtime }); - - await docker("cp", tarball, `${host.container}:/tmp/wb-test-package.tgz`); - - if (host.sealed) - await netAdmin( - runState, - host.image, - `container:${host.container}`, - `ip route add ${host.defaultRoute}`, - ); - - try { - await docker( - "exec", - host.container, - "npm", - "install", - "-g", - "--no-audit", - "--no-fund", - "/tmp/wb-test-package.tgz", - ); - } finally { - if (host.sealed) await seal(runState, host); - } - - await docker("exec", host.container, "rm", "/tmp/wb-test-package.tgz"); - console.log(packed); - } finally { - await rm(tarball, { force: true }); - } + + console.log( + await remoteDevInstall(host.alias, { + sshConfig: `${runState.dir}/ssh_config`, + version, + commit, + stdio: ["ignore", process.stderr, process.stderr], + }), + ); + await docker( + "exec", + host.container, + "ln", + "-sf", + `/home/${host.user}/.local/bin/whiteboard`, + "/usr/local/bin/whiteboard", + ); } diff --git a/apps/review-desktop/scripts/e2e/remote/image/setup.sh b/apps/review-desktop/scripts/e2e/remote/image/setup.sh index a069133ce..ff70a81a0 100755 --- a/apps/review-desktop/scripts/e2e/remote/image/setup.sh +++ b/apps/review-desktop/scripts/e2e/remote/image/setup.sh @@ -10,6 +10,11 @@ if command -v apk >/dev/null; then adduser -D -s "$(command -v "$LOGIN_SHELL")" dev else export DEBIAN_FRONTEND=noninteractive + # Bullseye's security pool left the mirrors in 2026 while its index still lists it: use the image's snapshot. + if grep -qs 'VERSION_CODENAME=bullseye' /etc/os-release; then + sed -n 's|^# deb http://snapshot|deb [check-valid-until=no] http://snapshot|p' /etc/apt/sources.list > /tmp/sources.list + mv /tmp/sources.list /etc/apt/sources.list + fi apt-get update -qq apt-get install -y -qq --no-install-recommends $packages xz-utils >/dev/null rm -rf /var/lib/apt/lists/* diff --git a/apps/review-desktop/scripts/e2e/remote/releases.mjs b/apps/review-desktop/scripts/e2e/remote/releases.mjs new file mode 100644 index 000000000..b02eec0b0 --- /dev/null +++ b/apps/review-desktop/scripts/e2e/remote/releases.mjs @@ -0,0 +1,76 @@ +/** This checkout's remote tarballs, served over plain HTTP as install.dev.fast serves a release's, for DEV_FAST_REVIEW_REMOTE_RELEASES. */ +import { createReadStream } from "node:fs"; +import { copyFile, mkdtemp, rm, stat } from "node:fs/promises"; +import { createServer } from "node:http"; +import { networkInterfaces, tmpdir } from "node:os"; +import path from "node:path"; + +import { buildDevRemoteTarball } from "../../build-remote-tarball.mjs"; + +/** An address both the laptop and its containers reach: host.docker.internal does not resolve on a macOS laptop. */ +function laptopAddress() { + const address = Object.values(networkInterfaces()) + .flat() + .find( + (entry) => + entry.family === "IPv4" && + !entry.internal && + !entry.address.startsWith("169.254."), + )?.address; + + if (!address) + throw new Error("no network address the containers can reach this laptop at"); + + return address; +} + +export async function serveReleases() { + const dir = await mkdtemp(path.join(tmpdir(), "wb-releases-")); + + const server = createServer(async (request, response) => { + let name; + + try { + name = decodeURIComponent(new URL(request.url, "http://x").pathname.slice(1)); + } catch { + return response.writeHead(400).end(); + } + + const file = path.join(dir, path.basename(name)); + + const found = + name === "" || + (name === path.basename(name) && + (await stat(file).then((s) => s.isFile(), () => false))); + + response.writeHead(found ? 200 : 404); + + if (!found || name === "" || request.method === "HEAD") return response.end(); + + createReadStream(file).pipe(response); + }); + + await new Promise((resolve) => server.listen(0, "0.0.0.0", resolve)); + + return { + url: `http://${laptopAddress()}:${server.address().port}/`, + async stage(target, version) { + const built = await buildDevRemoteTarball({ + target, + version, + stdio: ["ignore", process.stderr, process.stderr], + }); + + for (const suffix of ["", ".sha256"]) + await copyFile( + `${built.file}${suffix}`, + path.join(dir, `${path.basename(built.file)}${suffix}`), + ); + }, + async close() { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + await rm(dir, { recursive: true, force: true }); + }, + }; +} diff --git a/apps/review-desktop/scripts/e2e/remote/remote.mjs b/apps/review-desktop/scripts/e2e/remote/remote.mjs index 327bd47f8..583c3bba5 100644 --- a/apps/review-desktop/scripts/e2e/remote/remote.mjs +++ b/apps/review-desktop/scripts/e2e/remote/remote.mjs @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { awsUp } from "./aws-hosts.mjs"; -import { containerOf, install, packStaged, up } from "./docker-hosts.mjs"; +import { containerOf, install, packTarball, up } from "./docker-hosts.mjs"; import { docker, inherit } from "./exec.mjs"; import { down, downEveryRun, downRun } from "./removal.mjs"; import { checkName, hostOf, openRun } from "./run-state.mjs"; @@ -16,8 +16,8 @@ const usage = `usage: remote.mjs [--auth key|password] [--banner] [--shell bash|fish] [--jump ] [--sealed] [--delay-ms ] [--no-forwarding] [--port ] [--toolchain rust|swift|dotnet] - install [--version ] [--runtime ] - pack --out [--version ] [--runtime ] + install [--version ] [--commit ] + pack --out [--version ] [--arch x64|arm64] ssh -- forward pause | resume | logs @@ -50,7 +50,7 @@ async function main(argv) { port: { type: "string" }, toolchain: { type: "string" }, version: { type: "string" }, - runtime: { type: "string" }, + commit: { type: "string" }, out: { type: "string" }, arch: { type: "string" }, all: { type: "boolean" }, @@ -74,18 +74,23 @@ async function main(argv) { case "aws-up": return awsUp(await openRun(true), name, values); case "install": + if (values.commit !== undefined && !/^[0-9a-f]{40}$/.test(values.commit)) + throw new Error("--commit must be 40 hex digits"); + return install(await openRun(false), name, { version: values.version, - runtime: values.runtime && path.resolve(values.runtime), + commit: values.commit, }); case "pack": - if (!values.out) throw new Error("pack needs --out "); + if (!values.out) throw new Error("pack needs --out "); return console.log( - await packStaged(path.resolve(values.out), { - version: values.version, - runtime: values.runtime && path.resolve(values.runtime), - }), + JSON.stringify( + await packTarball(path.resolve(values.out), { + version: values.version, + arch: values.arch, + }), + ), ); case "ssh": { const runState = await openRun(false); diff --git a/apps/review-desktop/scripts/remote-dev-install.mjs b/apps/review-desktop/scripts/remote-dev-install.mjs new file mode 100644 index 000000000..be3dcf77e --- /dev/null +++ b/apps/review-desktop/scripts/remote-dev-install.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +/** Installs this checkout's remote tarball on an SSH host by hand, as Settings' hand-install lines do; see docs/remote-hosts.md. */ +import { execFileSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +import { + REMOTE_TARGETS, + buildDevRemoteTarball, +} from "./build-remote-tarball.mjs"; + +const ROOT = "~/.dev/whiteboard-remote"; + +const BUCKET = "dev-fast-desktop-artifacts"; + +const RELEASES = "https://install.dev.fast/releases"; + +const TARGETS = { x86_64: "linux-x64", aarch64: "linux-arm64" }; + +const sshArgs = (sshConfig) => [ + ...(sshConfig ? ["-F", sshConfig] : []), + "-o", + "BatchMode=yes", +]; + +const ssh = (alias, sshConfig, command) => + execFileSync("ssh", [...sshArgs(sshConfig), alias, command], { + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }).trim(); + +function hostTarget(alias, { sshConfig } = {}) { + const machine = ssh(alias, sshConfig, "uname -m"); + const target = TARGETS[machine]; + + if (!target) + throw new Error( + `${alias} runs ${machine}; Whiteboard needs x86_64 or aarch64.`, + ); + + return target; +} + +/** Unpacks this checkout's tarball in ~/.dev/whiteboard-remote, links ~/.local/bin/whiteboard, and returns its `whiteboard version --json`; `commit` rewrites its VS Code server's commit. */ +export async function remoteDevInstall( + alias, + { target, sshConfig, version, commit, stdio = "inherit" } = {}, +) { + target ??= hostTarget(alias, { sshConfig }); + + const built = await buildDevRemoteTarball({ target, version, stdio }); + const copy = `/tmp/whiteboard-remote-${randomBytes(4).toString("hex")}.tar.xz`; + + const removeCopy = () => { + try { + ssh(alias, sshConfig, `rm -f ${copy}`); + } catch {} + }; + + try { + execFileSync( + "scp", + [...sshArgs(sshConfig), "-q", built.file, `${alias}:${copy}`], + { stdio: ["ignore", "inherit", "inherit"] }, + ); + + return ssh( + alias, + sshConfig, + [ + "set -e", + `trap 'rm -f ${copy}' EXIT`, + `mkdir -p ${ROOT}`, + `rm -rf ${ROOT}/whiteboard-remote`, + `tar -xJ -C ${ROOT} < ${copy}`, + ...(commit + ? [ + `sed -i 's/"commit": "[0-9a-f]*"/"commit": "${commit}"/' ${ROOT}/whiteboard-remote/vscode-server/product.json`, + ] + : []), + "mkdir -p ~/.local/bin", + `ln -sf ${ROOT}/whiteboard-remote/whiteboard ~/.local/bin/whiteboard`, + "~/.local/bin/whiteboard version --json", + ].join("\n"), + ); + } catch (error) { + removeCopy(); + throw error; + } +} + +/** Puts a tarball and its .sha256 at releases/dev//remote/ on install.dev.fast, unless the same .sha256 is there. */ +async function publishRemoteTarball({ file, sha256, state }) { + const account = process.env.CLOUDFLARE_ACCOUNT_ID; + const token = process.env.CLOUDFLARE_API_TOKEN; + + if (!account || !token) + throw new Error( + "DEV_FAST_REVIEW_REMOTE_PUBLISH=1 needs CLOUDFLARE_ACCOUNT_ID (the hex host of R2_ENDPOINT_URL) and CLOUDFLARE_API_TOKEN.", + ); + + const name = path.basename(file); + const segments = ["releases", "dev", state, "remote"]; + + const url = (key) => + `${RELEASES}/${[...segments.slice(1), key].map(encodeURIComponent).join("/")}`; + + const sum = `${sha256} ${name}\n`; + + const published = await fetch(url(`${name}.sha256`)).then((response) => + response.ok ? response.text() : undefined, + ); + + if (published === sum) return false; + + for (const [key, body, type] of [ + [name, await readFile(file), "application/x-xz"], + [`${name}.sha256`, sum, "text/plain"], + ]) { + const object = [...segments, key].map(encodeURIComponent).join("/"); + + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${account}/r2/buckets/${BUCKET}/objects/${object}`, + { + method: "PUT", + headers: { Authorization: `Bearer ${token}`, "Content-Type": type }, + body, + }, + ); + + if (!response.ok) + throw new Error( + `Publishing ${key} answered ${response.status}: ${(await response.text()).slice(0, 500)}`, + ); + } + + return true; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + target: { type: "string" }, + "ssh-config": { type: "string" }, + }, + }); + + const [alias] = positionals; + const publish = process.env.DEV_FAST_REVIEW_REMOTE_PUBLISH === "1"; + const sshConfig = values["ssh-config"]; + + if ((!alias && !publish) || positionals.length > 1) + throw new Error( + "usage: remote-dev-install.mjs [--target linux-x64|linux-arm64] [--ssh-config ]\n DEV_FAST_REVIEW_REMOTE_PUBLISH=1 remote-dev-install.mjs [] ...", + ); + + if (values.target && !REMOTE_TARGETS.includes(values.target)) + throw new Error(`--target must be one of ${REMOTE_TARGETS.join(", ")}`); + + const stdio = ["ignore", process.stderr, process.stderr]; + + let state; + + // Both targets, so the printed releases URL serves either arch. + if (publish) + for (const each of REMOTE_TARGETS) { + const built = await buildDevRemoteTarball({ target: each, stdio }); + + state = built.state; + console.error( + `${path.basename(built.file)}: ${(await publishRemoteTarball(built)) ? "published" : "already published"}`, + ); + } + + if (alias) + console.log( + await remoteDevInstall(alias, { + target: values.target, + sshConfig, + stdio, + }), + ); + + if (publish) + console.log( + `export DEV_FAST_REVIEW_REMOTE_RELEASES=${RELEASES}/dev/${state}/remote/`, + ); +} From c7b26bc2d1d6d27eebdc97df46f953b7e22285de Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Wed, 14 Oct 2026 13:14:00 -0400 Subject: [PATCH 11/14] Add the remote-install journey and document remote installs The journey installs on fresh containers and covers decline, unsupported, drop, upgrade, connect, removal and hand installs. --- apps/review-desktop/UPSTREAM | 3 +- apps/review-desktop/scripts/e2e/TESTING.md | 65 +- .../scripts/e2e/journeys/remote-host.mjs | 22 +- .../scripts/e2e/journeys/remote-install.mjs | 1009 +++++++++++++++++ .../scripts/e2e/journeys/remote-lsp.mjs | 71 +- .../scripts/e2e/remote-lsp-languages.mjs | 4 +- apps/review-desktop/scripts/e2e/run.mjs | 6 + docs/remote-hosts.md | 107 +- 8 files changed, 1214 insertions(+), 73 deletions(-) create mode 100644 apps/review-desktop/scripts/e2e/journeys/remote-install.mjs diff --git a/apps/review-desktop/UPSTREAM b/apps/review-desktop/UPSTREAM index f9b8a0b0b..5a35951ab 100644 --- a/apps/review-desktop/UPSTREAM +++ b/apps/review-desktop/UPSTREAM @@ -494,7 +494,8 @@ one of these entries: `workbench-dev.html`: allow the local Review session connection and the native canvas Trusted Types policy in built and development launches. - `src/vs/base/common/product.ts`: declares the `reviewVersion` field described - above, so the About panel can read it typed. + above, so the About panel can read it typed, and `whiteboardRemote`, the + per-arch remote tarballs that release builds pin for remote installs. - `src/vs/code/electron-main/app.ts`: own the embedded Review server lifecycle and register the Review connection channel that hands the renderer the server endpoint the main process validated. Also swaps two update- and menu-related diff --git a/apps/review-desktop/scripts/e2e/TESTING.md b/apps/review-desktop/scripts/e2e/TESTING.md index a0f7a88ce..fbb75eeba 100644 --- a/apps/review-desktop/scripts/e2e/TESTING.md +++ b/apps/review-desktop/scripts/e2e/TESTING.md @@ -43,7 +43,7 @@ prints a JSON summary on stdout, one entry per journey, `ok | failed | skipped`. ## Phases Phase 1 runs offline, after a one-time network fetch of the curated VSIX cache -that `lsp-python` triggers. Phase 2 (`lsp-go`, `lsp-rust`, `remote-host`, `remote-lsp`) +that `lsp-python` triggers. Phase 2 (`lsp-go`, `lsp-rust`, `remote-host`, `remote-lsp`, `remote-install`) downloads toolchains or a container image and runs only with `REVIEW_E2E_NETWORK=1` or when named with `--journey`. A journey that exports `manual = true` (`remote-lsp-rust`, `remote-lsp-swift`, `remote-lsp-csharp`, @@ -104,16 +104,18 @@ and fails when AWS cannot be checked. `aws-up` needs a valid profile's default region, and each instance terminates itself after three hours. +`install` builds this checkout's tarball for the container's arch, installs it +with `../remote-dev-install.mjs` and links `/usr/local/bin/whiteboard` to it. +`--commit` rewrites its VS Code server's commit. `pack` writes the tarball. `--sealed` deletes the container's default route and checks that an outbound -request fails; `install` gives the route back only while `npm` runs, then -checks again. `--delay-ms` delays both directions with `netem`. Both run their +request fails. `--delay-ms` delays both directions with `netem`. Both run their network commands from a throwaway container, so the remote itself never holds `NET_ADMIN`. ## The remote-host journey `remote-host` runs the review server on another machine: a container from -`remote.mjs` with `sshd` on a loopback port and the package from this checkout. +`remote.mjs` with `sshd` on a loopback port and this checkout's remote tarball. It needs Docker; without it the journey is skipped with `skip: remote-host needs Docker for its SSH server`. The Desktop gets `DEV_FAST_REVIEW_SSH_CONFIG`, so its `ssh` uses the run's configuration and @@ -202,8 +204,9 @@ attach` on a remote, change only that remote's VS Code server: - `DEV_FAST_REVIEW_REMOTE_RECONNECTION_GRACE_SECONDS=<1–9999>` replaces its 10-minute reconnection grace; the journey gives B 20 s for step 6. Against two hosts you prepared, such as AWS instances, set -`REVIEW_E2E_REMOTE_HOSTS` to their two names in the `WB_TEST_RUN` run. Install -Node 24 and the package on each by hand, from `$R pack --out `. The +`REVIEW_E2E_REMOTE_HOSTS` to their two names in the `WB_TEST_RUN` run. Unpack +the tarball from `$R pack --out --arch ` on each and +put its `whiteboard-remote` directory on `PATH`. The journey then skips `up` and `install` and the package swap (step 8). It freezes B with `kill -STOP` on its servers, and it leaves the run for you to remove: @@ -211,7 +214,7 @@ remove: ```sh export WB_TEST_RUN=aws-$$ $R aws-up a --arch x64; $R aws-up b --arch arm64 -$R pack --out /tmp/wb.tgz # then, on each host: Node 24, `sudo npm install -g` the tarball +$R pack --out /tmp/wb.tar.xz --arch arm64 # then, on each host: `tar -xJf` it and add it to PATH REVIEW_E2E_REMOTE_HOSTS=a,b node apps/review-desktop/scripts/e2e/run.mjs --runtime "$REVIEW_E2E_RUNTIME" --journey remote-lsp $R down --all; $R verify-clean ``` @@ -251,3 +254,51 @@ node apps/review-desktop/scripts/e2e/run.mjs --runtime "$REVIEW_E2E_RUNTIME" --j The base images stay in Docker after `down`; remove them with `docker rmi` when done (`rust:1.98-bookworm`, `swift:6.4-noble`, `mcr.microsoft.com/dotnet/sdk:10.0-noble`). + +## The remote-install journey + +`remote-install` checks that Desktop installs itself on containers that have no +Whiteboard. It needs Docker and runs in development mode only. It serves this +checkout's tarballs to Desktop through `DEV_FAST_REVIEW_REMOTE_RELEASES`. + +| Container | `up` flags | What it proves | +|---|---|---| +| `fresh` | `--platform linux/amd64 --node none` | The x64 install, the next version, Connect, and removal with Whiteboard. | +| `old` | `--image debian:11 --node none` | glibc 2.31 is `unsupported`, and nothing is written. | +| `arm` | `--platform linux/arm64 --node none` | The ARM64 install. A user's server at the old version makes it `incompatible`. | +| `fresh2` | `--platform linux/amd64 --node none --delay-ms 100` | A connection dropped during the install. The reconnect finishes it. | +| `deny` | `--node none` | **Don't install**: `not-installed`, nothing written, and no second question. | +| `hand` | `--platform linux/amd64 --node none` | A hand install on `PATH` is `online` without a question. | + +On an Apple-silicon Mac, `fresh` and `fresh2` run under emulation. + +The journey bumps the patch version in `packages/review/package.json` and +restores it at the end. Do not edit that file during a run. Run the journey +under a trap that also restores it: + +```sh +R="node apps/review-desktop/scripts/e2e/remote/remote.mjs" +export WB_TEST_RUN=e2e-$$ +trap '$R down --all; $R verify-clean; git checkout -- packages/review/package.json' EXIT +DEV_FAST_REVIEW_DESKTOP_BACKGROUND=1 node apps/review-desktop/scripts/e2e/run.mjs --runtime "$REVIEW_E2E_RUNTIME" --journey remote-install +``` + +The connect step needs npm and GitHub on `fresh`. + +### On AWS + +With `REVIEW_E2E_REMOTE_HOSTS` set to `aws-up` hosts in the `WB_TEST_RUN` run, +the journey installs on those hosts instead of containers. It never removes the +run. First publish this checkout's tarballs with +`DEV_FAST_REVIEW_REMOTE_PUBLISH=1 node apps/review-desktop/scripts/remote-dev-install.mjs`, +and export the `DEV_FAST_REVIEW_REMOTE_RELEASES` line it prints. + +```sh +R="node apps/review-desktop/scripts/e2e/remote/remote.mjs" +export WB_TEST_RUN=aws-$$ +trap '$R down --all; $R verify-clean' EXIT +$R aws-up a --arch x64 +$R aws-up b --arch arm64 +REVIEW_E2E_REMOTE_HOSTS=a,b DEV_FAST_REVIEW_DESKTOP_BACKGROUND=1 \ + node apps/review-desktop/scripts/e2e/run.mjs --runtime "$REVIEW_E2E_RUNTIME" --journey remote-install +``` diff --git a/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs b/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs index cab55e246..dae1f47ce 100644 --- a/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs +++ b/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs @@ -52,6 +52,15 @@ echo "$id" pwd `; +/** Home's row no longer shows the host; its summary carries what the repository filter labels it. */ +export async function listedAs(ctx, title) { + const review = (await ctx.apiOk("/reviews-api")).find( + (summary) => summary.title === title, + ); + + return review && `${review.host}: ${review.repositoryGroup?.label}`; +} + async function remote(...args) { return ( await exec(process.execPath, [remoteScript, ...args], { @@ -266,15 +275,12 @@ async function journey(ctx, page, until) { // 2. Home lists the remote review under the host's label. await openHome(ctx); - const rows = page - .locator("main.review-home") - .getByRole("region", { name: "Sessions", exact: true }) - .locator("tbody tr"); + const rows = page.locator("main.review-home [data-home-row]"); const row = (text) => rows.filter({ hasText: text }); await row(title).waitFor({ timeout: 60000 }); - assert.ok((await row(title).innerText()).includes(`${alias}: wbrepo`)); + assert.equal(await listedAs(ctx, title), `${alias}: wbrepo`); assert.equal(await row(title).getAttribute("data-unavailable"), null); ctx.check(`2. Home lists the remote review as ${alias}: wbrepo`); @@ -531,7 +537,7 @@ async function journey(ctx, page, until) { ); assert.match( await hostRow().locator("code").innerText(), - /^npm install -g @dev\.fast\/whiteboard@\d+\.\d+\.\d+/, + /^mkdir -p ~\/\.dev\/whiteboard-remote && rm -rf ~\/\.dev\/whiteboard-remote\/whiteboard-remote && curl -fsSL https:\/\/install\.dev\.fast\/releases\/\d+\.\d+\.\d+/, ); await openHome(ctx); @@ -571,6 +577,10 @@ async function journey(ctx, page, until) { settings = await openSettings(ctx); section = settings.getByRole("region", { name: "Remote hosts" }); await section.getByRole("button", { name: `Remove ${alias}` }).click(); + await section + .getByRole("group", { name: `Remove ${alias}` }) + .getByRole("button", { name: "Remove host" }) + .click(); await until(async () => (await hostRow().count()) === 0, "the row to go"); await openHome(ctx); await row("Laptop order").waitFor(); diff --git a/apps/review-desktop/scripts/e2e/journeys/remote-install.mjs b/apps/review-desktop/scripts/e2e/journeys/remote-install.mjs new file mode 100644 index 000000000..d9c9000bc --- /dev/null +++ b/apps/review-desktop/scripts/e2e/journeys/remote-install.mjs @@ -0,0 +1,1009 @@ +/** Desktop installs itself on remote hosts it reaches over SSH: containers from remote/remote.mjs with nothing installed by hand, added in Settings as a user would. */ +import assert from "node:assert/strict"; +import { execFile, spawn } from "node:child_process"; +import { readFile, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { REMOTE_TARGETS } from "../../build-remote-tarball.mjs"; +import { openHome, openSettings, sourcePackage } from "../harness.mjs"; +import { serveReleases } from "../remote/releases.mjs"; +import { listedAs } from "./remote-host.mjs"; + +const exec = promisify(execFile); + +const remoteScript = path.join(import.meta.dirname, "../remote/remote.mjs"); + +const runId = process.env.WB_TEST_RUN ?? `e2e${Date.now().toString(36)}`; + +const runDir = `/tmp/wbt.${runId}`; + +const prepared = + process.env.REVIEW_E2E_REMOTE_HOSTS?.split(",").filter(Boolean); + +const CONTAINERS = { + fresh: ["--platform", "linux/amd64", "--node", "none"], + old: ["--image", "debian:11", "--node", "none"], + arm: ["--platform", "linux/arm64", "--node", "none"], + fresh2: ["--platform", "linux/amd64", "--node", "none", "--delay-ms", "100"], + deny: ["--node", "none"], + hand: ["--platform", "linux/amd64", "--node", "none"], +}; + +const alias = (name) => `wb-test-${name}`; + +const ROOT = "~/.dev/whiteboard-remote"; + +const WHITEBOARD = "~/.local/bin/whiteboard"; + +const CLAUDE_CODE = "2.1.286"; + +const title = "Installed remote order"; + +const MINUTE = 60_000; + +export const name = "remote-install"; + +export const phase = 2; + +export const options = { + settings: { "review.experimental.remoteHosts.enabled": true }, + env: { DEV_FAST_REVIEW_SSH_CONFIG: `${runDir}/ssh_config` }, +}; + +let releases; + +const hasDocker = () => + exec("docker", ["info", "--format", "{{.ServerVersion}}"], { + timeout: 30_000, + }); + +export async function prepare({ app }) { + if (prepared || app || !(await hasDocker().then(() => true, () => false))) + return undefined; + + releases = await serveReleases(); + + try { + for (const target of REMOTE_TARGETS) await releases.stage(target); + } catch (error) { + await releases.close(); + throw error; + } + + return { + env: { DEV_FAST_REVIEW_REMOTE_RELEASES: releases.url }, + dispose: () => releases.close(), + }; +} + +async function remote(args, timeout = 15 * MINUTE) { + return ( + await exec(process.execPath, [remoteScript, ...args], { + env: { ...process.env, WB_TEST_RUN: runId }, + maxBuffer: 16 * 1024 * 1024, + timeout, + killSignal: "SIGKILL", + }) + ).stdout.trim(); +} + +function onRemote(host, command, { input = "", timeout = 2 * MINUTE } = {}) { + return new Promise((resolve, reject) => { + const child = spawn( + "ssh", + ["-F", `${runDir}/ssh_config`, "-o", "BatchMode=yes", host, command], + { stdio: ["pipe", "pipe", "pipe"] }, + ); + + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => child.kill("SIGKILL"), timeout); + + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + child.once("error", reject); + child.once("close", (code, signal) => { + clearTimeout(timer); + + if (code === 0) resolve(stdout.trim()); + else + reject( + new Error( + `${command} on ${host}: ${signal ?? code}: ${stderr.slice(-2000)}`, + ), + ); + }); + child.stdin.end(input); + }); +} + +const remoteApi = async (host, tool, input) => + JSON.parse( + await onRemote(host, `${WHITEBOARD} api ${tool} -`, { + input: JSON.stringify(input), + }), + ); + +export async function run(ctx) { + // A packaged build ignores DEV_FAST_REVIEW_SSH_CONFIG, so its ssh would read the user's configuration. + if (ctx.report.mode === "packaged") + throw new Error("skip: remote-install runs in development mode only"); + + if (!prepared) + try { + await hasDocker(); + } catch (error) { + throw new Error( + `skip: remote-install needs Docker for its SSH servers (${error.message.split("\n")[0]})`, + ); + } + + const manifestPath = path.join(sourcePackage, "package.json"); + const manifest = await readFile(manifestPath, "utf8"); + + try { + await (prepared ? preparedJourney(ctx) : journey(ctx, manifestPath)); + } finally { + if ((await readFile(manifestPath, "utf8")) !== manifest) + await writeFile(manifestPath, manifest); + + await closeDesktop(ctx); + + if (!prepared) + await remote(["down", "--all"], 5 * MINUTE).catch((error) => + console.error(`[remote-install] down --all: ${error.message}`), + ); + } +} + +async function closeDesktop(ctx) { + const session = await ctx.browser.newBrowserCDPSession().catch(() => null); + + await Promise.race([ + session?.send("Browser.close").catch(() => {}), + new Promise((resolve) => setTimeout(resolve, 2000)), + ]); + + for (let i = 0; i < 40 && (await desktopSsh()).length; i++) + await new Promise((resolve) => setTimeout(resolve, 250)); + + for (const [pid] of await desktopSsh()) process.kill(pid, "SIGTERM"); +} + +async function desktopSsh() { + const { stdout } = await exec("ps", ["-axo", "pid=,args="], { + timeout: 10_000, + }); + + return stdout + .split("\n") + .map((line) => line.trim().match(/^(\d+) (.*)$/)) + .filter( + (match) => + match && + /(^|\/)ssh /.test(match[2]) && + match[2].includes(`-F ${runDir}/ssh_config -S `), + ) + .map((match) => [Number(match[1]), match[2]]); +} + +const masterPid = async (host) => + (await desktopSsh()).find( + ([, args]) => args.includes(" -M -N ") && args.endsWith(`-- ${host}`), + )?.[0]; + +function watcher(ctx) { + const history = new Map(); + let stopped = false; + + const poll = (async () => { + while (!stopped) { + try { + for (const host of await ctx.apiOk("/remote-hosts")) { + const seen = history.get(host.alias) ?? []; + const last = seen.at(-1); + + if ( + last?.state !== host.state || + last?.detail !== host.detail || + last?.declined !== host.declined + ) + seen.push({ ...host, at: Date.now() }); + + history.set(host.alias, seen); + } + } catch {} + + await new Promise((resolve) => setTimeout(resolve, 200)); + } + })(); + + return { + history: (host) => history.get(host) ?? [], + stop: async () => { + stopped = true; + await poll; + }, + }; +} + +async function watchPage(page) { + await page.evaluate(() => { + const ui = (window.__remoteInstall = { prompts: [], rows: {} }); + let open = false; + + const look = () => { + const title = [ + ...document.querySelectorAll(".quick-input-widget .quick-input-title"), + ].find( + (e) => + e.offsetParent !== null && + e.textContent.startsWith("Install Whiteboard on "), + ); + + if (title && !open) + ui.prompts.push({ title: title.textContent, at: Date.now() }); + + open = Boolean(title); + + for (const row of document.querySelectorAll("[data-remote-host]")) { + const [label, detail] = row.firstElementChild?.children ?? []; + const rows = (ui.rows[label?.textContent] ??= []); + const text = detail?.textContent; + + if (text && rows.at(-1) !== text) rows.push(text); + } + }; + + new MutationObserver(look).observe(document.body, { + subtree: true, + childList: true, + characterData: true, + attributes: true, + attributeFilter: ["style", "class", "aria-hidden"], + }); + }); +} + +async function mainLog(ctx) { + const logs = path.join(ctx.userData, "logs"); + + const files = (await readdir(logs, { recursive: true })).filter((file) => + file.endsWith("main.log"), + ); + + return ( + await Promise.all( + files.map((file) => readFile(path.join(logs, file), "utf8")), + ) + ).join("\n"); +} + +const pageRecord = (ctx) => ctx.page.evaluate(() => window.__remoteInstall); + +async function journey(ctx, manifestPath) { + const { until } = ctx; + const timings = {}; + + for (const [host, flags] of Object.entries(CONTAINERS)) + await remote(["up", host, ...flags]); + ctx.check( + `0. containers up, Whiteboard on none: ${Object.entries(CONTAINERS) + .map(([host, flags]) => `${host} (${flags.join(" ")})`) + .join(", ")}`, + ); + + const watch = watcher(ctx); + + try { + await watchPage(ctx.page); + await steps(ctx, until, watch, timings, manifestPath); + } finally { + await watch.stop(); + } +} + +async function hostsSection(ctx) { + const settings = await openSettings(ctx); + + return settings.getByRole("region", { name: "Remote hosts" }); +} + +const hostRow = (section, host) => + section.locator("[data-remote-host]").filter({ hasText: host }); + +async function addHost(ctx, host) { + const section = await hostsSection(ctx); + + await section.getByLabel("SSH alias").fill(host); + await section.getByRole("button", { name: "Add", exact: true }).click(); + await hostRow(section, host).waitFor({ timeout: 30_000 }); + + return section; +} + +async function removeHost(ctx, host, { uninstall = false } = {}) { + const section = await hostsSection(ctx); + + await section.getByRole("button", { name: `Remove ${host}` }).click(); + + const confirm = section.getByRole("group", { name: `Remove ${host}` }); + + if (uninstall) + await confirm + .getByRole("checkbox", { name: `Also remove Whiteboard from ${host}` }) + .check(); + + await confirm.getByRole("button", { name: "Remove host" }).click(); + await ctx.until( + async () => (await hostRow(section, host).count()) === 0, + `the ${host} row to go`, + 2 * MINUTE, + ); + + return section; +} + +async function answerPrompt(ctx, host, label) { + const widget = ctx.page.locator(".quick-input-widget"); + const heading = `Install Whiteboard on ${host}?`; + + await ctx.until( + async () => + (await widget.isVisible()) && + (await widget.locator(".quick-input-title").first().textContent()) === + heading, + `the install question for ${host}`, + 2 * MINUTE, + ); + + const text = await widget + .locator(".quick-input-description") + .first() + .textContent(); + + await widget + .locator(".monaco-list-row", { hasText: new RegExp(`^${label}$`) }) + .first() + .click({ timeout: 10_000 }); + + return { text, at: Date.now() }; +} + +async function reached(ctx, watch, host, states, label, timeout) { + return ctx.until( + () => watch.history(host).find((entry) => states.includes(entry.state)), + `${host} ${label}`, + timeout, + ); +} + +const installSteps = (watch, host) => + watch + .history(host) + .filter((entry) => entry.state === "installing") + .map((entry) => entry.detail); + +const STEP_ORDER = [ + "Preparing to install Whiteboard", + "Waiting for another install to finish", + "Installing the Whiteboard package", + "Installed; starting the server", +]; + +function assertStepOrder(details, label) { + const order = details.map((detail) => + STEP_ORDER.findIndex((step) => detail.startsWith(step)), + ); + + assert.ok(!order.includes(-1), `${label}: unknown step in ${details}`); + assert.deepEqual( + order, + [...order].sort((a, b) => a - b), + `${label}: steps out of order: ${details}`, + ); +} + +const promptsFor = async (ctx, host) => + (await pageRecord(ctx)).prompts.filter( + (prompt) => prompt.title === `Install Whiteboard on ${host}?`, + ); + +const marker = async (host, version) => + JSON.parse( + await onRemote( + host, + `cat ${ROOT}/versions/${version}/.whiteboard-install.json`, + ), + ); + +async function install(ctx, watch, host, { timeout = 10 * MINUTE } = {}) { + await addHost(ctx, host); + + const { text, at } = await answerPrompt(ctx, host, "Install"); + + const online = await reached( + ctx, + watch, + host, + ["online", "not-installed", "unsupported", "incompatible", "auth-failed"], + "online after the install", + timeout, + ); + + assert.equal( + online.state, + "online", + `${host}: ${online.state}: ${online.detail}`, + ); + + const steps = installSteps(watch, host); + + assertStepOrder(steps, host); + + return { text, steps, ms: online.at - at }; +} + +async function createRemoteReview(host, reviewTitle) { + const home = await onRemote(host, 'printf %s "$HOME"'); + + await onRemote( + host, + [ + "set -e", + "rm -rf ~/wbrepo", + "git init -q -b main ~/wbrepo", + "cd ~/wbrepo", + "git config user.email e2e@example.invalid", + "git config user.name e2e", + "printf 'export const one = 1;\\n' > f.ts", + "git add f.ts", + "git commit -qm one", + "printf 'export const one = 2;\\n' > f.ts", + "git commit -qam two", + ].join("\n"), + ); + + const created = await remoteApi(host, "session_create", { + title: reviewTitle, + open: false, + target: { + kind: "commits", + repositoryPath: `${home}/wbrepo`, + base: "HEAD~1", + head: "HEAD", + }, + }); + + assert.match(created.sessionId, /^[0-9a-f-]{36}$/, "the remote review's id"); +} + +async function homeRow(ctx, reviewTitle, timeout = MINUTE) { + await openHome(ctx); + + const row = ctx.page + .locator("main.review-home [data-home-row]") + .filter({ hasText: reviewTitle }); + + await row.waitFor({ timeout }); + + return row; +} + +async function targetOf(host, version) { + const { node } = await marker(host, version); + + return { + machine: await onRemote(host, "uname -m"), + node: await onRemote(host, `'${node}' -p process.arch`), + }; +} + +async function steps(ctx, until, watch, timings, manifestPath) { + const version = JSON.parse(await readFile(manifestPath, "utf8")).version; + + const fresh = alias("fresh"); + const first = await install(ctx, watch, fresh); + + timings.fresh = first.ms; + assert.match(first.text, /about 250 MB/); + assert.doesNotMatch(first.text, /\/home\//, "the question names a path"); + + const settingsSteps = (await pageRecord(ctx)).rows[fresh].filter((text) => + text.startsWith("installing · "), + ); + + assert.ok( + settingsSteps.length >= 2, + `Settings showed ${settingsSteps.length} install step(s): ${settingsSteps}`, + ); + assertStepOrder( + settingsSteps.map((text) => text.slice("installing · ".length)), + "Settings", + ); + await onRemote( + fresh, + `test -x ${ROOT}/versions/${version}/node_modules/@dev.fast/diffr-linux-x64/diffr`, + ); + await createRemoteReview(fresh, title); + + const listed = await homeRow(ctx, title); + + assert.equal(await listedAs(ctx, title), `${fresh}: wbrepo`); + assert.equal(await listed.getAttribute("data-unavailable"), null); + + const freshTarget = await targetOf(fresh, version); + + assert.deepEqual(freshTarget, { machine: "x86_64", node: "x64" }); + ctx.check( + `1. ${fresh} (linux/amd64): the question, ${first.steps.length} steps from the API (${first.steps.join(" → ")}), ${settingsSteps.length} in Settings, online ${first.ms} ms after Install; diffr is executable inside the tarball; a review written with ${WHITEBOARD} is in Home as ${fresh}: wbrepo`, + ); + + const old = alias("old"); + const newer = `find ~ -newer /tmp/wb-test-added | sort; test -e ${ROOT} && echo installed || true`; + + await onRemote(old, "touch /tmp/wb-test-added"); + const oldSection = await addHost(ctx, old); + + const refused = await reached( + ctx, + watch, + old, + ["unsupported", "online", "not-installed", "installing"], + "unsupported", + 2 * MINUTE, + ); + + assert.equal(refused.state, "unsupported", refused.detail); + assert.match( + refused.detail, + /glibc 2\.31; Whiteboard needs 2\.34 or newer/, + refused.detail, + ); + await until( + async () => + /unsupported · .*glibc 2\.31/.test( + await hostRow(oldSection, old).innerText(), + ), + "the Settings row to read unsupported", + 30_000, + ); + assert.equal(await onRemote(old, newer), "", "old was written to"); + assert.deepEqual(await promptsFor(ctx, old), []); + ctx.check( + `3. ${old} (debian:11): unsupported, "${refused.detail}"; no question; nothing in its home newer than just before it was added`, + ); + + const arm = alias("arm"); + const fifth = await install(ctx, watch, arm); + const armTarget = await targetOf(arm, version); + + timings.arm = fifth.ms; + assert.deepEqual(armTarget, { machine: "aarch64", node: "arm64" }); + ctx.check( + `4. ${arm} (linux/arm64): online ${fifth.ms} ms after Install; the tarball's bin/node runs as ${armTarget.node}`, + ); + + const fresh2 = alias("fresh2"); + + await addHost(ctx, fresh2); + await answerPrompt(ctx, fresh2, "Install"); + + const packaging = await reached( + ctx, + watch, + fresh2, + ["installing", "online"], + "the package step", + 10 * MINUTE, + ).then(() => + until( + () => + watch + .history(fresh2) + .find((entry) => + entry.detail?.startsWith("Installing the Whiteboard package"), + ), + `${fresh2} installing the package`, + 10 * MINUTE, + ), + ); + + const master = await masterPid(fresh2); + + assert.ok(master, `no ssh master for ${fresh2}`); + process.kill(master, "SIGKILL"); + + const killed = Date.now(); + + const recovered = await until( + () => + watch + .history(fresh2) + .find((entry) => entry.at > killed && entry.state === "online"), + `${fresh2} online after the dropped connection`, + 10 * MINUTE, + ); + + const dropped = (await mainLog(ctx)) + .split("\n") + .find((line) => line.includes(`[Remote hosts] ${fresh2}: unreachable: `)); + + assert.ok(dropped, `no unreachable line for ${fresh2} in main.log`); + assert.notEqual(await masterPid(fresh2), master); + assert.equal( + await onRemote(fresh2, `find ${ROOT} -name '*.part' | head -n 5`), + "", + ); + assert.equal( + await onRemote( + fresh2, + `ls ${ROOT}/versions; ls -d ${ROOT}/install.lock* 2>/dev/null || true`, + ), + version, + ); + assert.equal((await promptsFor(ctx, fresh2)).length, 1); + ctx.check( + `5. ${fresh2}: ssh master killed during "${packaging.detail}"; main.log: "${dropped.split("[Remote hosts] ")[1]}"; online ${recovered.at - killed} ms after the kill, asked once, no .part and no lock left`, + ); + + const deny = alias("deny"); + const denySection = await addHost(ctx, deny); + + await answerPrompt(ctx, deny, "Don't install"); + + const declined = await reached( + ctx, + watch, + deny, + ["not-installed", "online", "installing"], + "not-installed", + 2 * MINUTE, + ); + + assert.equal(declined.state, "not-installed", declined.detail); + assert.equal(declined.declined, true); + + const denyTarget = { x86_64: "linux-x64", aarch64: "linux-arm64" }[ + await onRemote(deny, "uname -m") + ]; + + assert.equal( + declined.installCommand, + [ + `mkdir -p ${ROOT} && rm -rf ${ROOT}/whiteboard-remote && curl -fsSL https://install.dev.fast/releases/${version}/remote/whiteboard-remote-${version}-${denyTarget}.tar.xz | tar -xJ -C ${ROOT}`, + "mkdir -p ~/.local/bin && ln -sf ~/.dev/whiteboard-remote/whiteboard-remote/whiteboard ~/.local/bin/whiteboard", + ].join("\n"), + ); + await denySection + .getByRole("button", { name: `Install ${deny}` }) + .waitFor({ timeout: 30_000 }); + assert.equal( + await hostRow(denySection, deny).locator("code").innerText(), + declined.installCommand, + ); + + const nothing = `ls -d ${ROOT} ${WHITEBOARD} 2>/dev/null || true`; + + assert.equal(await onRemote(deny, nothing), ""); + + const connects = async () => + (await mainLog(ctx)).split(`${deny}: connecting, ssh pid`).length - 1; + + const beforeRetry = await connects(); + + await denySection.getByRole("button", { name: `Retry ${deny}` }).click(); + await until( + async () => (await connects()) > beforeRetry, + `${deny} to connect again`, + MINUTE, + ); + await until( + async () => + (await ctx.apiOk("/remote-hosts")).find((host) => host.alias === deny) + ?.state === "not-installed", + `${deny} not-installed again`, + MINUTE, + ); + await new Promise((resolve) => setTimeout(resolve, 5000)); + assert.equal((await promptsFor(ctx, deny)).length, 1); + assert.equal(await onRemote(deny, nothing), ""); + ctx.check( + `6. ${deny}: declined → not-installed with "${declined.installCommand}" and Install in Settings; nothing written; Retry reconnected without asking again`, + ); + + for (const host of [old, arm, fresh2, deny]) await removeHost(ctx, host); + + for (const host of ["old", "fresh2", "deny"]) + await remote(["down", host], 5 * MINUTE); + + await onRemote(arm, `${WHITEBOARD} server stop --json`); + await onRemote( + arm, + `setsid nohup ${WHITEBOARD} server start --json > /tmp/user-server.log 2>&1 < /dev/null &`, + ); + + const userServer = JSON.parse( + await until( + () => + onRemote(arm, `${WHITEBOARD} server status --json`).catch(() => null), + "the user's server on arm", + MINUTE, + ), + ); + + assert.equal(userServer.version, version); + + const discovery = JSON.parse( + await onRemote(arm, "cat ~/.dev/review-server/server.json"), + ); + + assert.equal(discovery.startedBy, "user"); + + await onRemote( + fresh, + `mkdir -p ~/.local/bin ~/.claude && curl -fsSL https://registry.npmjs.org/@anthropic-ai/claude-code-linux-x64/-/claude-code-linux-x64-${CLAUDE_CODE}.tgz | tar -xzO package/claude > ~/.local/bin/claude && chmod +x ~/.local/bin/claude && ~/.local/bin/claude --version`, + { timeout: 10 * MINUTE }, + ); + + const freshServer = async () => + JSON.parse(await onRemote(fresh, `${WHITEBOARD} server status --json`)); + + const oldFreshServer = await freshServer(); + + const next = version.replace(/\d+$/, (patch) => String(Number(patch) + 1)); + const manifest = await readFile(manifestPath, "utf8"); + + await writeFile( + manifestPath, + manifest.replace(`"version": "${version}"`, `"version": "${next}"`), + ); + + for (const target of REMOTE_TARGETS) await releases.stage(target); + const sshBefore = (await desktopSsh()).map(([pid]) => pid); + const restarted = Date.now(); + + await ctx.restartDesktop(); + await watchPage(ctx.page); + await until( + async () => !(await desktopSsh()).some(([pid]) => sshBefore.includes(pid)), + "the previous Desktop's ssh to end", + 30_000, + ); + + assert.equal((await ctx.apiOk("/health")).version, next); + + const upgraded = await until( + () => + watch + .history(fresh) + .find( + (entry) => + entry.at > restarted && + ["online", "incompatible", "not-installed"].includes(entry.state), + ), + `${fresh} on ${next}`, + 10 * MINUTE, + ); + + assert.equal(upgraded.state, "online", upgraded.detail); + assert.deepEqual(await promptsFor(ctx, fresh), []); + assert.equal( + await onRemote(fresh, `ls ${ROOT}/versions | sort -V | tr '\\n' ' '`), + `${version} ${next}`, + ); + + const newFreshServer = await freshServer(); + + assert.equal(newFreshServer.version, next); + assert.notEqual(newFreshServer.serverPid, oldFreshServer.serverPid); + assert.match( + await onRemote(fresh, `cat /proc/${newFreshServer.serverPid}/cmdline | tr '\\0' ' '`), + new RegExp(`/versions/${next.replaceAll(".", "\\.")}/`), + ); + await homeRow(ctx, title); + ctx.check( + `7. Desktop ${next} (packages/review/package.json bumped, Desktop restarted, none of its ${sshBefore.length} ssh processes left): ${fresh} installed ${next} beside ${version} without asking, its server restarted on ${next} (pid ${oldFreshServer.serverPid} → ${newFreshServer.serverPid}), and the step 1 review is still listed`, + ); + + const readded = Date.now(); + + await addHost(ctx, arm); + + const blocked = await until( + () => + watch + .history(arm) + .find( + (entry) => + entry.at > readded && + ["incompatible", "online", "not-installed"].includes(entry.state), + ), + `${arm} settled`, + 10 * MINUTE, + ); + + assert.equal(blocked.state, "incompatible", blocked.detail); + assert.match(blocked.detail, /started by a user is running on/); + assert.deepEqual(await promptsFor(ctx, arm), []); + await onRemote(arm, `kill -0 ${userServer.serverPid}`); + assert.equal( + ( + await onRemote( + arm, + `cat /proc/${userServer.serverPid}/cmdline | tr '\\0' ' '`, + ) + ).includes(`/versions/${version}/`), + true, + ); + ctx.check( + `8. ${arm}: "${blocked.detail}"; the user's server (pid ${userServer.serverPid}, ${version}) still runs`, + ); + + const section = await hostsSection(ctx); + const freshRow = hostRow(section, fresh); + + await until( + async () => + /Agents on wb-test-fresh: Claude Code/.test(await freshRow.innerText()), + "the agent offer for fresh", + 2 * MINUTE, + ); + await freshRow + .getByRole("button", { name: `Connect agents on ${fresh}` }) + .click(); + await until( + async () => + /Claude Code (is connected|was not connected)/.test( + await freshRow.innerText(), + ), + "Connect's result", + 5 * MINUTE, + ); + assert.match( + await freshRow.getByRole("status").innerText(), + /^Claude Code is connected on wb-test-fresh\.$/, + ); + assert.ok( + JSON.parse( + await onRemote(fresh, "cat ~/.claude/plugins/installed_plugins.json"), + ).plugins["whiteboard@devfast"], + "no whiteboard@devfast in installed_plugins.json", + ); + ctx.check( + `9. ${fresh}: Settings offered Claude Code (${CLAUDE_CODE}, ~/.claude present); Connect wrote whiteboard@devfast into ~/.claude/plugins/installed_plugins.json`, + ); + + await removeHost(ctx, fresh, { uninstall: true }); + assert.equal( + await section.getByRole("alert").count(), + 0, + await section + .getByRole("alert") + .innerText() + .catch(() => ""), + ); + assert.equal(await onRemote(fresh, nothing), ""); + assert.match( + await onRemote( + fresh, + `cat ~/.dev/review-api.db ~/.dev/review-api.db-wal 2>/dev/null | grep -ac '${title}' || true`, + ), + /^[1-9]/, + ); + ctx.check( + `10. ${fresh} removed with "Also remove Whiteboard": no ${ROOT}, no ${WHITEBOARD}; ~/.dev/review-api.db still holds "${title}"`, + ); + + const hand = alias("hand"); + const [unpack, link] = declined.installCommand.split("\n"); + const served = `${releases.url}whiteboard-remote-${next}-linux-x64.tar.xz`; + + await onRemote( + hand, + [ + "set -e", + unpack.replace(/curl -fsSL \S+/, `curl -fsSL ${served}`), + link, + ].join("\n"), + { timeout: 5 * MINUTE }, + ); + assert.equal(await onRemote(hand, "bash -lc 'whiteboard --version'"), next); + + const handAdded = Date.now(); + + await addHost(ctx, hand); + + const handSettled = await until( + async () => + (await promptsFor(ctx, hand)).length + ? { state: "asked to install" } + : watch + .history(hand) + .find( + (entry) => + entry.at > handAdded && + [ + "online", + "installing", + "not-installed", + "incompatible", + "unsupported", + ].includes(entry.state), + ), + `${hand} settled`, + 5 * MINUTE, + ); + + assert.equal(handSettled.state, "online", handSettled.detail); + assert.equal( + await onRemote(hand, `ls -d ${ROOT}/versions 2>/dev/null || true`), + "", + ); + assert.match( + await onRemote(hand, `readlink ${WHITEBOARD}`), + /\/\.dev\/whiteboard-remote\/whiteboard-remote\/whiteboard$/, + ); + + const handServer = JSON.parse( + await onRemote(hand, "bash -lc 'whiteboard server status --json'"), + ); + + assert.equal(handServer.version, next); + assert.match( + await onRemote( + hand, + `cat /proc/${handServer.serverPid}/cmdline | tr '\\0' ' '`, + ), + /\/\.dev\/whiteboard-remote\/whiteboard-remote\//, + ); + assert.deepEqual(await promptsFor(ctx, hand), []); + ctx.check( + `11. ${hand}: ${served} unpacked and linked by Settings' two lines; online without a question, its server runs from the unpacked tree, no versions/ was written and ${WHITEBOARD} is still the hand symlink`, + ); + + ctx.check( + `12. Install → online: fresh ${timings.fresh} ms, arm ${timings.arm} ms`, + ); + console.error(`[remote-install] timings ${JSON.stringify(timings)}`); +} + +async function preparedJourney(ctx) { + const watch = watcher(ctx); + + try { + await watchPage(ctx.page); + + const timings = {}; + + for (const name of prepared) { + const host = alias(name); + + assert.equal( + await onRemote(host, `ls -d ${ROOT} ${WHITEBOARD} 2>/dev/null || true`), + "", + `${host} has seen Whiteboard`, + ); + + const installed = await install(ctx, watch, host, { + timeout: 20 * MINUTE, + }); + + timings[name] = installed.ms; + + const reviewTitle = `${title} on ${name}`; + + await createRemoteReview(host, reviewTitle); + await homeRow(ctx, reviewTitle); + assert.equal(await listedAs(ctx, reviewTitle), `${host}: wbrepo`); + ctx.check( + `${host} (${await onRemote(host, "uname -m")}): ${installed.steps.join(" → ")}; online ${installed.ms} ms after Install; its review is in Home`, + ); + } + + console.error(`[remote-install] timings ${JSON.stringify(timings)}`); + } finally { + await watch.stop(); + } +} diff --git a/apps/review-desktop/scripts/e2e/journeys/remote-lsp.mjs b/apps/review-desktop/scripts/e2e/journeys/remote-lsp.mjs index a75b8538a..691cc9fd1 100644 --- a/apps/review-desktop/scripts/e2e/journeys/remote-lsp.mjs +++ b/apps/review-desktop/scripts/e2e/journeys/remote-lsp.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { execFile, spawn } from "node:child_process"; import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; @@ -267,7 +267,6 @@ export async function run(ctx) { else { await closeDesktop(ctx); await rm(proj, { recursive: true, force: true }); - await rm(`${runDir}/runtime-other`, { recursive: true, force: true }); } } } @@ -392,10 +391,7 @@ async function journey(ctx) { await openHome(ctx); - const rows = page - .locator("main.review-home") - .getByRole("region", { name: "Sessions", exact: true }) - .locator("tbody tr"); + const rows = page.locator("main.review-home [data-home-row]"); for (const text of [title.a, title.py, title.b]) { const row = rows.filter({ hasText: text }); @@ -457,7 +453,7 @@ async function journey(ctx) { const diffShown = Date.now(); - await showDiff(ctx, title[m.key], "b.ts"); + await showDiff(ctx, title[m.key], "b.ts", m.review); const point = await windowCall(ctx, "point", m.review, "b.ts", 3, "answer"); @@ -515,7 +511,7 @@ async function journey(ctx) { } // 4. A Python review on A: ty answers with a type. - await showDiff(ctx, title.py, "m.py"); + await showDiff(ctx, title.py, "m.py", reviewPy); const pyPoint = await windowCall(ctx, "point", reviewPy, "m.py", 5, "value"); const pyHover = await pointerHover(page, pyPoint, /\bint\b/, 120000); @@ -531,7 +527,7 @@ async function journey(ctx) { ); // 5. The laptop's own providers for its file are the same after the remotes connected. - await showDiff(ctx, title.laptop, "b.ts"); + await showDiff(ctx, title.laptop, "b.ts", laptop.reviewId); await answers(machines[0]); const countsAfter = await windowCall(ctx, "counts", laptop.reviewId, "b.ts"); @@ -556,7 +552,7 @@ async function journey(ctx) { // 6. B's tunnel ends for longer than its reconnection lasts; A and the laptop keep answering; B comes back without a reload. const [a, b] = [machines[1], machines[2]]; - await showDiff(ctx, title.b, "b.ts"); + await showDiff(ctx, title.b, "b.ts", reviewB); const failuresBefore = (await logLines(failedB)).length; const master = await masterPid(aliasB); @@ -637,21 +633,16 @@ async function journey(ctx) { // 8. B runs another commit: its reviews read normally, hovers are absent, and Settings says why. if (prepared === undefined) { - const other = `${runDir}/runtime-other`; - const { commit } = JSON.parse( await readFile(path.join(DEFAULT_REMOTE_RUNTIME, "product.json"), "utf8"), ); - await cp(DEFAULT_REMOTE_RUNTIME, other, { recursive: true }); - await writeFile( - path.join(other, "product.json"), - JSON.stringify({ - ...JSON.parse(await readFile(path.join(other, "product.json"), "utf8")), - commit: commit === "f".repeat(40) ? "e".repeat(40) : "f".repeat(40), - }), + await remote( + "install", + nameB, + "--commit", + commit === "f".repeat(40) ? "e".repeat(40) : "f".repeat(40), ); - await remote("install", nameB, "--runtime", other); await onRemote(aliasB, "whiteboard server stop"); const why = `language features need the same Whiteboard version on ${aliasB}`; @@ -674,16 +665,16 @@ async function journey(ctx) { await until( async () => (await hostRow(aliasB).innerText()).includes( - `Language features: unavailable — ${why}`, + `Language features: unavailable · ${why}`, ), "the Settings row to say why", ); const detail = (await hostRow(aliasB).innerText()).match( - /Language features: unavailable — .*/, + /Language features: unavailable · .*/, )[0]; - await showDiff(ctx, title.b, "b.ts"); + await showDiff(ctx, title.b, "b.ts", reviewB); assert.match(await lines(visibleCanvas(page)), /answer \+ 1/); const absent = await hover(b); @@ -831,22 +822,30 @@ export const visibleCanvas = (page) => export const tab = (page, text) => page.locator(".tabs-container .tab").filter({ hasText: text }); -export async function showDiff(ctx, title, file) { +export async function showDiff(ctx, title, file, review) { await tab(ctx.page, title).click(); - await ctx.page - .locator('[aria-label="Session views"] button[aria-label="Diff"]') - .filter({ visible: true }) - .click(); await ctx.until( async () => - ( - await ctx.page - .locator(".review-path-label") - .filter({ visible: true }) - .allInnerTexts() - ).some((text) => text.includes(file)), - `the Diff view of ${title} to show ${file}`, + /\bactive\b/.test( + (await tab(ctx.page, title).getAttribute("class")) ?? "", + ), + `the ${title} tab to be active`, ); + + const diff = ctx.page + .locator('[aria-label="Session views"] button[aria-label="Diff"]') + .filter({ visible: true }); + + // The shared canvas shows the previous review briefly after the tab turns active. + await ctx.until(async () => { + if ((await diff.getAttribute("aria-pressed")) !== "true") { + await diff.click(); + + return false; + } + + return windowCall(ctx, "shown", review, file); + }, `the Diff view of ${title} to show ${file}`); } export async function lines(scope) { @@ -1123,6 +1122,8 @@ async function inWindow(command, args) { if (!editor) return { error: `no visible head editor for ${reviewId}/${file}` }; + if (command === "shown") return { shown: true }; + const model = editor.getModel(); const position = { diff --git a/apps/review-desktop/scripts/e2e/remote-lsp-languages.mjs b/apps/review-desktop/scripts/e2e/remote-lsp-languages.mjs index 88aa114e9..5c0842e29 100644 --- a/apps/review-desktop/scripts/e2e/remote-lsp-languages.mjs +++ b/apps/review-desktop/scripts/e2e/remote-lsp-languages.mjs @@ -405,8 +405,6 @@ async function journey(ctx, id, language) { .waitFor({ timeout: 60000 }); await tab(page, title).waitFor(); - const fileName = path.basename(language.file); - const content = await readFile( path.join(fixtures, language.fixture, language.file), "utf8", @@ -418,7 +416,7 @@ async function journey(ctx, id, language) { const diffShown = Date.now(); - await showDiff(ctx, title, fileName); + await showDiff(ctx, title, language.file, reviewId); const point = await windowCall( ctx, diff --git a/apps/review-desktop/scripts/e2e/run.mjs b/apps/review-desktop/scripts/e2e/run.mjs index 13f8ce840..9df8df6b5 100644 --- a/apps/review-desktop/scripts/e2e/run.mjs +++ b/apps/review-desktop/scripts/e2e/run.mjs @@ -56,16 +56,21 @@ const summary = []; for (const journey of selected) { let ctx; + let prepared; try { + // `prepare` runs before Desktop launches, and adds to its environment. + prepared = await journey.prepare?.({ app: values.app }); ctx = await createHarness({ runtime, app: values.app, keep: values.keep, journey: journey.name, ...journey.options, + env: { ...journey.options?.env, ...prepared?.env }, }); } catch (caught) { + await prepared?.dispose(); // A journey whose Desktop never launches still owes the suite a summary. summary.push({ journey: journey.name, @@ -92,6 +97,7 @@ for (const journey of selected) { } finally { // A renderer page error fails the journey without aborting the suite. if (!(await ctx.close({ success: status !== "failed" }))) status = "failed"; + await prepared?.dispose(); } summary.push({ diff --git a/docs/remote-hosts.md b/docs/remote-hosts.md index 3010b7533..11b387ed3 100644 --- a/docs/remote-hosts.md +++ b/docs/remote-hosts.md @@ -21,29 +21,15 @@ config apply to Whiteboard's connections too. - Linux on x64 or ARM64, with glibc 2.34 or newer (Ubuntu 22.04, Debian 12, Fedora 35, RHEL 9 or later). -- Node 24. +- A writable home directory with 1 GB free. +- `tar`, `xz`, `curl` or `wget`, and `sha256sum` or `openssl`. +- HTTPS access to `install.dev.fast`. - An OpenSSH server that allows TCP forwarding. See [TCP forwarding](#tcp-forwarding). -- `whiteboard` on the `PATH` of your login shell there. Your laptop needs macOS or Linux with the OpenSSH client. A Windows laptop is not supported yet. -## Install Whiteboard on the remote - -On the remote, install the version that matches your Desktop: - -```sh -npm install -g @dev.fast/whiteboard@ -``` - -Settings shows the exact command, with the version, when a host needs it. -Check the install with `whiteboard version`. - -You do not need to start anything. Desktop starts the review server when it -connects. On a remote with no Desktop, `whiteboard api` starts one too, so an -agent there can write reviews before you connect. - ## Add a host Remote hosts are experimental. Turn them on in Whiteboard's `settings.json`: @@ -54,10 +40,12 @@ Remote hosts are experimental. Turn them on in Whiteboard's `settings.json`: Then open Settings. Under **Remote hosts**, type an alias from your SSH configuration (the field suggests them) and click **Add**. The row shows the -host's state. When it reads `online`, the host's reviews appear in Home. +host's state. The first time, Desktop asks to install Whiteboard there. When +the row reads `online`, the host's reviews appear in Home. **Remove** takes the host and its reviews out of Whiteboard. Nothing on the -remote is deleted. +remote is deleted unless you check +[**Also remove Whiteboard**](#remove-whiteboard-from-a-host). The hosts are kept in the setting `review.remote.hosts`, a list of aliases. Only you can change it: a repository or a remote never adds a host. @@ -66,17 +54,94 @@ If `ssh` asks for a password, a passphrase or a host key confirmation, Whiteboard shows the prompt in its window. Your answer goes to `ssh` and is never stored. +## How Desktop installs Whiteboard + +Desktop first checks the host and writes nothing. If the host lacks Desktop's +version, Desktop asks once, then installs it as your user. The host downloads +it from `install.dev.fast` and checks its pinned checksum before unpacking. + +You do not need to start anything. Desktop starts the review server when it +connects. On a remote with no Desktop, `whiteboard api` starts one too, so an +agent there can write reviews before you connect. + +### What Desktop writes on the remote + +| Path | What it is | +|---|---| +| `~/.dev/whiteboard-remote/versions//` | One Whiteboard version, about 250 MB, with its own Node. | +| `~/.dev/whiteboard-remote/install.lock/` | Present while an install runs. | +| `~/.local/bin/whiteboard` | A launcher for the newest version, if that path is free or Desktop's own. | + +With `DEV_REVIEW_HOME` set on the remote, `whiteboard-remote/` is under it +instead of `~/.dev`. The review server keeps reviews in `~/.dev`. + +### Updates + +When Desktop updates, it installs its new version without asking. It restarts +the review server it started. It keeps the previous version and any version +still running. It leaves a server you started yourself running. + +### The setting + +`review.remote.install` decides whether Desktop installs on a host that does +not have its version: + +| Value | What Desktop does | +|---|---| +| `ask` (the default) | Asks once per host. | +| `always` | Installs without asking. | +| `never` | Never installs, and uses the `whiteboard` on the host's `PATH`. | + +### Install by hand + +On the remote, with `` set to Desktop's version: + +```sh +mkdir -p ~/.dev/whiteboard-remote && rm -rf ~/.dev/whiteboard-remote/whiteboard-remote && curl -fsSL https://install.dev.fast/releases//remote/whiteboard-remote--linux-x64.tar.xz | tar -xJ -C ~/.dev/whiteboard-remote +mkdir -p ~/.local/bin && ln -sf ~/.dev/whiteboard-remote/whiteboard-remote/whiteboard ~/.local/bin/whiteboard +``` + +On ARM64, use `linux-arm64` in place of `linux-x64`. + +### Developing + +A development Desktop pins no archive. Set `DEV_FAST_REVIEW_REMOTE_RELEASES` to +a URL that serves the archives and their `.sha256` files, or install this +checkout's archive with `node apps/review-desktop/scripts/remote-dev-install.mjs `. +With `DEV_FAST_REVIEW_REMOTE_PUBLISH=1` and Cloudflare credentials, the script +publishes the archives and prints the URL to export. + +## Agents on the remote + +Once a host is `online`, Settings lists its unconnected agents: Claude Code, +Codex, OpenCode and Pi. **Connect** runs the agent's own install commands on +the host. + +## Remove Whiteboard from a host + +In Settings, click **Remove** on the host, check **Also remove Whiteboard from +``**, and click **Remove host**. Desktop then stops the servers +Whiteboard started and removes `~/.dev/whiteboard-remote/` and its +`~/.local/bin/whiteboard`. Your reviews stay, and Desktop forgets your install +answer. Nothing is removed while an install or a process you started runs from +the install. + +On the remote, `whiteboard remote uninstall --keep-reviews` does the same. +`--delete-reviews` also deletes the reviews. + ## States | State | Meaning | What to do | |---|---|---| | `connecting` | Whiteboard is opening the SSH connection and starting the review server. | Wait. | +| `installing` | Desktop is installing its version there. | Wait. | | `online` | Connected. The host's reviews are listed and open. | Nothing. | | `offline` | The connection is up, but the review server did not answer. Whiteboard checks every 10 seconds, so a host that hangs shows `offline` within about 15 seconds. | It returns by itself when the server answers. After three checks in a row without an answer, Whiteboard attaches again. A hung server then shows `unreachable`, and its detail names `whiteboard server stop`: run it on the host. | | `unreachable` | `ssh` could not connect, the connection ended, or the review server there did not start (the detail says why). | Whiteboard tries again by itself, waiting 1 to 60 seconds between tries; **Retry** tries now. Check that `ssh ` works in a terminal, or do what the detail says. | | `auth-failed` | The login was refused, a prompt was cancelled, or the host key did not match. | Fix the login, then click **Retry**. Whiteboard does not retry this by itself. | -| `not-installed` | `whiteboard` was not found on the remote. | Install it with the command that Settings shows, then click **Retry**. Whiteboard does not retry this by itself. | -| `incompatible` | The remote runs another version of Whiteboard. | Run the install command that Settings shows, then `whiteboard server stop` on the remote. Desktop starts the new server within about 10 seconds. | +| `not-installed` | Desktop's version is not on the host, and Desktop did not install it. | Click **Install**, or [install by hand](#install-by-hand) and click **Retry**. | +| `unsupported` | Whiteboard cannot run on this machine. Nothing was written there. | Use another machine. | +| `incompatible` | The remote runs another version of Whiteboard. | Stop your server with `whiteboard server stop` and click **Retry**, or update this Desktop. | | `duplicate` | Two or more hosts report the same server id, so Whiteboard uses none of them. This happens when one machine is added under two aliases, or a review store was copied to a second machine. | If the aliases are one machine, remove all but one. If they are two machines, remove the copy's host, run `whiteboard server stop` and then `whiteboard server reset-id` on the copy, and add it again. | While a host is not `online`, its reviews stay in Home, drawn as unavailable. From 416e57705b83a09d4dcc7471018f71ec40f01046 Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Wed, 14 Oct 2026 13:48:57 -0400 Subject: [PATCH 12/14] Compress dev tarballs at xz level 0 A dev tarball only crosses to a local container or a dev host, and xz at level 6 was most of its build time. Release builds keep level 6. --- .../scripts/build-remote-tarball.mjs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/review-desktop/scripts/build-remote-tarball.mjs b/apps/review-desktop/scripts/build-remote-tarball.mjs index 431634228..c7f1416c4 100644 --- a/apps/review-desktop/scripts/build-remote-tarball.mjs +++ b/apps/review-desktop/scripts/build-remote-tarball.mjs @@ -303,7 +303,7 @@ async function stagePackageData(stage, stdio) { await stageReviewDocs(stage); } -function packTar(file, stageRoot) { +function packTar(file, stageRoot, compression) { const gnu = execFileSync("tar", ["--version"], { encoding: "utf8" }).includes( "GNU", ); @@ -323,7 +323,13 @@ function packTar(file, stageRoot) { execFileSync( "tar", ["-cJf", file, ...flags, "-C", stageRoot, "whiteboard-remote"], - { env: { ...process.env, XZ_OPT: "-6 -T0", COPYFILE_DISABLE: "1" } }, + { + env: { + ...process.env, + XZ_OPT: `-${compression} -T0`, + COPYFILE_DISABLE: "1", + }, + }, ); } @@ -333,6 +339,7 @@ export async function buildRemoteTarball({ version, commit, cacheDir = DEFAULT_CACHE, + compression = 6, stdio = "inherit", }) { if (!REMOTE_TARGETS.includes(target)) @@ -389,7 +396,7 @@ export async function buildRemoteTarball({ ); await rm(file, { force: true }); - packTar(file, stageRoot); + packTar(file, stageRoot, compression); const { size } = await stat(file); @@ -458,6 +465,7 @@ export async function buildDevRemoteTarball({ commit: execFileSync("git", ["-C", monorepoRoot, "rev-parse", "HEAD"], { encoding: "utf8", }).trim(), + compression: 0, stdio, }); From 63442e35b8f8842d0f4b1ccc2f3834b6f79998ed Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Wed, 14 Oct 2026 13:18:37 -0400 Subject: [PATCH 13/14] Let remote extensions write their own storage through the review router The remote file system router refused every write, so extensions on a remote host could not write their own storage and ms-python failed to start. It now lets writes through under the storage and log directories the host gives its extension host, and keeps everything else read-only. --- .../reviewRemoteFileSystemRouter.test.ts | 44 ++++++++------- .../remote/reviewRemoteFileSystemRouter.ts | 56 +++++++++++++------ .../services/remote/reviewRemoteHost.ts | 2 +- 3 files changed, 64 insertions(+), 38 deletions(-) diff --git a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteFileSystemRouter.test.ts b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteFileSystemRouter.test.ts index 634a56be8..ec7f6174b 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteFileSystemRouter.test.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteFileSystemRouter.test.ts @@ -3,7 +3,7 @@ import test from "node:test"; import { Emitter } from "../../../base/common/event.js"; import { URI } from "../../../base/common/uri.js"; import { - FileSystemProviderCapabilities, + FilePermission, FileSystemProviderErrorCode, type IFileChange, type IFileSystemProviderWithFileReadWriteCapability, @@ -13,8 +13,9 @@ import { ReviewRemoteFileSystemRouter } from "./reviewRemoteFileSystemRouter.js" const A = "whiteboard+aaaa-1111"; const B = "whiteboard+bbbb-2222"; +const storage = (authority: string) => URI.parse(`vscode-remote://${authority}/data/User/workspaceStorage`); -function remote(name: string) { +function remote(name: string, written: string[] = []) { const changed = new Emitter(); const provider = { onDidChangeFile: changed.event, @@ -22,7 +23,8 @@ function remote(name: string) { stat: async () => ({ type: 1, ctime: 0, mtime: 0, size: name.length }), readdir: async () => [[name, 1]], watch: () => ({ dispose() { } }), - writeFile: async () => { throw new Error(`${name} was written`); }, + writeFile: async (resource: URI) => { written.push(`${name}:${resource.path}`); }, + mkdir: async (resource: URI) => { written.push(`${name}:${resource.path}`); }, } as unknown as IFileSystemProviderWithFileReadWriteCapability; return { provider, changed }; } @@ -39,8 +41,8 @@ async function code(promise: Promise) { test("each call goes to the host the authority names, whatever its case", async () => { const router = new ReviewRemoteFileSystemRouter(); - router.add(A, remote("a").provider); - router.add(B, remote("b").provider); + router.add(A, remote("a").provider, [storage(A)]); + router.add(B, remote("b").provider, [storage(B)]); assert.equal(text(await router.readFile(URI.parse(`vscode-remote://${A}/p/a.ts`))), "a:/p/a.ts"); assert.equal(text(await router.readFile(URI.parse(`vscode-remote://${B}/p/a.ts`))), "b:/p/a.ts"); assert.equal(text(await router.readFile(URI.parse(`vscode-remote://${A.toUpperCase()}/p/a.ts`))), "a:/p/a.ts"); @@ -50,25 +52,29 @@ test("each call goes to the host the authority names, whatever its case", async test("an authority that names no connected host is a missing file", async () => { const router = new ReviewRemoteFileSystemRouter(); - const added = router.add(A, remote("a").provider); + const added = router.add(A, remote("a").provider, [storage(A)]); assert.equal(await code(router.stat(URI.parse(`vscode-remote://${B}/p/a.ts`))), FileSystemProviderErrorCode.FileNotFound); added.dispose(); assert.equal(await code(router.readFile(URI.parse(`vscode-remote://${A}/p/a.ts`))), FileSystemProviderErrorCode.FileNotFound); router.dispose(); }); -test("it is read-only and refuses every change without reaching a host", async () => { +test("review files are read-only, but the host's storage is written on that host", async () => { const router = new ReviewRemoteFileSystemRouter(); - router.add(A, remote("a").provider); - const file = URI.parse(`vscode-remote://${A}/p/a.ts`); - assert.ok(router.capabilities & FileSystemProviderCapabilities.Readonly); - for (const change of [ - router.writeFile(), - router.mkdir(), - router.delete(), - router.rename(), - ]) assert.equal(await code(change), FileSystemProviderErrorCode.NoPermissions); - assert.equal(text(await router.readFile(file)), "a:/p/a.ts"); + const written: string[] = []; + router.add(A, remote("a", written).provider, [storage(A)]); + router.add(B, remote("b", written).provider, [storage(B)]); + const source = URI.parse(`vscode-remote://${A}/repo/a.py`); + const stored = URI.parse(`vscode-remote://${A}/data/User/workspaceStorage/ws/ms-python.python/pythonrc.py`); + const escape = URI.parse(`vscode-remote://${A}/data/User/workspaceStorage/../../../repo/a.py`); + const options = { create: true, overwrite: true, unlock: false, atomic: false } as const; + assert.equal(await code(router.writeFile(source, new Uint8Array(), options)), FileSystemProviderErrorCode.NoPermissions); + assert.equal(await code(router.writeFile(escape, new Uint8Array(), options)), FileSystemProviderErrorCode.NoPermissions); + assert.ok(((await router.stat(source)).permissions ?? 0) & FilePermission.Readonly); + assert.ok(!(((await router.stat(stored)).permissions ?? 0) & FilePermission.Readonly)); + await router.mkdir(URI.parse(`vscode-remote://${A}/data/User/workspaceStorage/ws`)); + await router.writeFile(stored, new Uint8Array(), options); + assert.deepEqual(written, ["a:/data/User/workspaceStorage/ws", "a:/data/User/workspaceStorage/ws/ms-python.python/pythonrc.py"]); router.dispose(); }); @@ -78,9 +84,9 @@ test("changes from a host are passed on until that host is removed, and a newer const second = remote("second"); const seen: string[] = []; router.onDidChangeFile((changes) => seen.push(...changes.map((change) => change.resource.path))); - const removeFirst = router.add(A, first.provider); + const removeFirst = router.add(A, first.provider, []); first.changed.fire([{ type: 0, resource: URI.parse(`vscode-remote://${A}/one`) }]); - router.add(A, second.provider); + router.add(A, second.provider, []); removeFirst.dispose(); first.changed.fire([{ type: 0, resource: URI.parse(`vscode-remote://${A}/two`) }]); second.changed.fire([{ type: 0, resource: URI.parse(`vscode-remote://${A}/three`) }]); diff --git a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteFileSystemRouter.ts b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteFileSystemRouter.ts index 4e9f41ea3..7f75cd0e1 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteFileSystemRouter.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteFileSystemRouter.ts @@ -5,38 +5,46 @@ import { Emitter, Event } from "../../../base/common/event.js"; import { Disposable, type IDisposable, toDisposable } from "../../../base/common/lifecycle.js"; +import { isEqualOrParent, normalizePath } from "../../../base/common/resources.js"; import type { URI } from "../../../base/common/uri.js"; import { createFileSystemProviderError, + FilePermission, type FileType, FileSystemProviderCapabilities, FileSystemProviderErrorCode, type IFileChange, + type IFileDeleteOptions, + type IFileOverwriteOptions, type IFileSystemProviderWithFileReadWriteCapability, + type IFileWriteOptions, type IStat, type IWatchOptions, } from "../../../platform/files/common/files.js"; +/** Review files on a host are read-only; only the storage the host's extension host was given is writable. */ export class ReviewRemoteFileSystemRouter extends Disposable implements IFileSystemProviderWithFileReadWriteCapability { readonly capabilities = - FileSystemProviderCapabilities.FileReadWrite | - FileSystemProviderCapabilities.PathCaseSensitive | - FileSystemProviderCapabilities.Readonly; + FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.PathCaseSensitive; readonly onDidChangeCapabilities = Event.None; private readonly changed = this._register(new Emitter()); readonly onDidChangeFile = this.changed.event; private readonly watchErrors = this._register(new Emitter()); readonly onDidWatchError = this.watchErrors.event; private readonly hosts = new Map(); + private readonly writable = new Map(); - add(authority: string, provider: IFileSystemProviderWithFileReadWriteCapability): IDisposable { + add(authority: string, provider: IFileSystemProviderWithFileReadWriteCapability, writable: readonly URI[]): IDisposable { this.hosts.set(authority, provider); + this.writable.set(authority, writable); const changes = provider.onDidChangeFile((e) => this.changed.fire(e)); const errors = provider.onDidWatchError?.((e) => this.watchErrors.fire(e)); return toDisposable(() => { changes.dispose(); errors?.dispose(); - if (this.hosts.get(authority) === provider) this.hosts.delete(authority); + if (this.hosts.get(authority) !== provider) return; + this.hosts.delete(authority); + this.writable.delete(authority); }); } @@ -46,8 +54,21 @@ export class ReviewRemoteFileSystemRouter extends Disposable implements IFileSys return host; } + private canWrite(resource: URI): boolean { + const path = normalizePath(resource); + return (this.writable.get(resource.authority.toLowerCase()) ?? []).some((root) => isEqualOrParent(path, root)); + } + + private writer(...resources: URI[]): IFileSystemProviderWithFileReadWriteCapability { + if (!resources.every((resource) => this.canWrite(resource))) { + throw createFileSystemProviderError("Remote review files are read-only.", FileSystemProviderErrorCode.NoPermissions); + } + return this.host(resources[0]); + } + async stat(resource: URI): Promise { - return this.host(resource).stat(resource); + const stat = await this.host(resource).stat(resource); + return this.canWrite(resource) ? stat : { ...stat, permissions: (stat.permissions ?? 0) | FilePermission.Readonly }; } async readdir(resource: URI): Promise<[string, FileType][]> { @@ -62,23 +83,22 @@ export class ReviewRemoteFileSystemRouter extends Disposable implements IFileSys return this.hosts.get(resource.authority.toLowerCase())?.watch(resource, opts) ?? Disposable.None; } - writeFile(): Promise { - return refuse(); + async writeFile(resource: URI, content: Uint8Array, opts: IFileWriteOptions): Promise { + return this.writer(resource).writeFile(resource, content, opts); } - mkdir(): Promise { - return refuse(); + async mkdir(resource: URI): Promise { + return this.writer(resource).mkdir(resource); } - delete(): Promise { - return refuse(); + async delete(resource: URI, opts: IFileDeleteOptions): Promise { + return this.writer(resource).delete(resource, opts); } - rename(): Promise { - return refuse(); + async rename(from: URI, to: URI, opts: IFileOverwriteOptions): Promise { + if (from.authority.toLowerCase() !== to.authority.toLowerCase()) { + throw createFileSystemProviderError("Files cannot move between remote hosts.", FileSystemProviderErrorCode.NoPermissions); + } + return this.writer(from, to).rename(from, to, opts); } } - -function refuse(): Promise { - return Promise.reject(createFileSystemProviderError("Remote review files are read-only.", FileSystemProviderErrorCode.NoPermissions)); -} diff --git a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteHost.ts b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteHost.ts index 374836ff2..e0097ba87 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteHost.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteHost.ts @@ -268,7 +268,7 @@ export class ReviewRemoteSession extends Disposable implements IReviewRemoteSess this.check(); const files = this._register(new DiskFileSystemProviderClient(management.client.getChannel(REMOTE_FILE_SYSTEM_CHANNEL_NAME), { pathCaseSensitive: true })); - this._register(this.router.add(authority, files)); + this._register(this.router.add(authority, files, [environment.globalStorageHome, environment.workspaceStorageHome, environment.logsPath])); const scope = this._register(this.instantiationService.createChild(this.instantiationService.invokeFunction((window) => reviewRemoteScope({ authority, From 4948d5ac20dfa363685bd3cb4c591a36e40e75aa Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Wed, 14 Oct 2026 13:45:29 -0400 Subject: [PATCH 14/14] Give the backlog stream test time for its first 900 KB state On a loaded CI runner the first state of the 900 KB document took longer than the 10 s wait, and the test failed before it checked anything. The first wait and the test budget grow; the backlog assertions are unchanged. --- packages/review/src/server/review-gateway-streams.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/review/src/server/review-gateway-streams.test.ts b/packages/review/src/server/review-gateway-streams.test.ts index e2372344f..d176c46b4 100644 --- a/packages/review/src/server/review-gateway-streams.test.ts +++ b/packages/review/src/server/review-gateway-streams.test.ts @@ -608,7 +608,7 @@ it("gives a reader that stops reading the latest state once, not a backlog", asy } }); - await vi.waitFor(() => expect(lines).toHaveLength(1), { timeout: 10_000 }); + await vi.waitFor(() => expect(lines).toHaveLength(1), { timeout: 30_000 }); response.pause(); const updates = 40; @@ -628,7 +628,7 @@ it("gives a reader that stops reading the latest state once, not a backlog", asy ); expect(steps.filter((value) => value === `Step ${updates}`)).toHaveLength(1); expect(steps.length).toBeLessThan(updates / 2); -}, 30_000); +}, 60_000); it("sends nothing for a review whose host is still connecting, then its value", async () => { const serverId = randomUUID();