Add managed desktop daemon runtime support
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
||||
const desktopRoot = path.join(repoRoot, "packages", "desktop");
|
||||
const resourcesRoot = path.join(desktopRoot, "src-tauri", "resources", "managed-runtime");
|
||||
const cacheRoot = path.join(desktopRoot, ".cache", "managed-runtime");
|
||||
const packageVersion = JSON.parse(
|
||||
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
|
||||
).version;
|
||||
|
||||
const workspaces = [
|
||||
{ name: "@getpaseo/relay", root: path.join(repoRoot, "packages", "relay") },
|
||||
{ name: "@getpaseo/server", root: path.join(repoRoot, "packages", "server") },
|
||||
{ name: "@getpaseo/cli", root: path.join(repoRoot, "packages", "cli") },
|
||||
];
|
||||
|
||||
async function rmSafe(target) {
|
||||
await fs.rm(target, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function ensureDir(target) {
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
}
|
||||
|
||||
async function copyDir(source, target) {
|
||||
await ensureDir(path.dirname(target));
|
||||
await fs.cp(source, target, { recursive: true, dereference: true, force: true });
|
||||
}
|
||||
|
||||
async function copyFile(source, target, mode) {
|
||||
await ensureDir(path.dirname(target));
|
||||
await fs.copyFile(source, target);
|
||||
if (mode != null) {
|
||||
await fs.chmod(target, mode);
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(target) {
|
||||
try {
|
||||
await fs.access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readToolVersions() {
|
||||
const raw = await fs.readFile(path.join(repoRoot, ".tool-versions"), "utf8");
|
||||
const entries = new Map();
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
const [tool, ...rest] = trimmed.split(/\s+/);
|
||||
if (!tool || rest.length === 0) {
|
||||
continue;
|
||||
}
|
||||
entries.set(tool, rest.join(" "));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function resolveNodeVersion(toolVersions) {
|
||||
const raw = toolVersions.get("nodejs");
|
||||
if (!raw) {
|
||||
throw new Error("Missing nodejs entry in .tool-versions.");
|
||||
}
|
||||
const version = raw.trim().replace(/^v/, "");
|
||||
if (!/^\d+\.\d+\.\d+$/.test(version)) {
|
||||
throw new Error(`Unsupported nodejs version in .tool-versions: ${raw}`);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
function resolveNodeArtifact(version) {
|
||||
const platformMap = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "win",
|
||||
};
|
||||
const archMap = {
|
||||
arm64: "arm64",
|
||||
x64: "x64",
|
||||
};
|
||||
const platform = platformMap[process.platform];
|
||||
const arch = archMap[process.arch];
|
||||
if (!platform || !arch) {
|
||||
throw new Error(`Managed runtime is not implemented for ${process.platform}-${process.arch}.`);
|
||||
}
|
||||
const baseName = `node-v${version}-${platform}-${arch}`;
|
||||
const extension = process.platform === "win32" ? "zip" : process.platform === "linux" ? "tar.xz" : "tar.gz";
|
||||
return {
|
||||
version,
|
||||
baseName,
|
||||
archiveName: `${baseName}.${extension}`,
|
||||
extension,
|
||||
downloadUrl: `https://nodejs.org/dist/v${version}/${baseName}.${extension}`,
|
||||
checksumsUrl: `https://nodejs.org/dist/v${version}/SHASUMS256.txt`,
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadFile(url, target) {
|
||||
await ensureDir(path.dirname(target));
|
||||
runCommand("curl", [
|
||||
"--fail",
|
||||
"--location",
|
||||
"--retry",
|
||||
"3",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--output",
|
||||
target,
|
||||
url,
|
||||
]);
|
||||
}
|
||||
|
||||
async function sha256File(target) {
|
||||
const hash = createHash("sha256");
|
||||
hash.update(await fs.readFile(target));
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
async function computeRuntimeContentHash(input) {
|
||||
const hash = createHash("sha256");
|
||||
hash.update(`packageVersion:${packageVersion}\n`);
|
||||
hash.update(`nodeVersion:${input.nodeVersion}\n`);
|
||||
hash.update(`platform:${process.platform}\n`);
|
||||
hash.update(`arch:${process.arch}\n`);
|
||||
for (const tarball of input.tarballs) {
|
||||
hash.update(`workspace:${tarball.name}\n`);
|
||||
hash.update(`filename:${path.basename(tarball.path)}\n`);
|
||||
hash.update(`sha256:${await sha256File(tarball.path)}\n`);
|
||||
}
|
||||
return hash.digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
async function ensureNodeArchive(nodeArtifact) {
|
||||
const versionCacheRoot = path.join(cacheRoot, `node-v${nodeArtifact.version}`);
|
||||
const archivePath = path.join(versionCacheRoot, nodeArtifact.archiveName);
|
||||
const checksumsPath = path.join(versionCacheRoot, "SHASUMS256.txt");
|
||||
if (!(await pathExists(archivePath))) {
|
||||
await downloadFile(nodeArtifact.downloadUrl, archivePath);
|
||||
}
|
||||
if (!(await pathExists(checksumsPath))) {
|
||||
await downloadFile(nodeArtifact.checksumsUrl, checksumsPath);
|
||||
}
|
||||
const checksums = await fs.readFile(checksumsPath, "utf8");
|
||||
const expectedLine = checksums
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.endsWith(` ${nodeArtifact.archiveName}`));
|
||||
if (!expectedLine) {
|
||||
throw new Error(`Missing checksum for ${nodeArtifact.archiveName} in SHASUMS256.txt`);
|
||||
}
|
||||
const expectedSha = expectedLine.split(/\s+/)[0];
|
||||
const actualSha = await sha256File(archivePath);
|
||||
if (actualSha !== expectedSha) {
|
||||
throw new Error(
|
||||
`Checksum mismatch for ${nodeArtifact.archiveName}: expected ${expectedSha}, got ${actualSha}`
|
||||
);
|
||||
}
|
||||
return archivePath;
|
||||
}
|
||||
|
||||
function runCommand(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
stdio: "pipe",
|
||||
encoding: "utf8",
|
||||
...options,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`Command failed: ${command} ${args.join(" ")}`,
|
||||
result.stdout?.trim(),
|
||||
result.stderr?.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function extractNodeDistribution(archivePath, nodeArtifact) {
|
||||
const extractionRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-managed-runtime-node-"));
|
||||
if (nodeArtifact.extension === "zip") {
|
||||
runCommand("powershell", [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
`Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${extractionRoot.replace(/'/g, "''")}' -Force`,
|
||||
]);
|
||||
} else {
|
||||
const tarFlag = nodeArtifact.extension === "tar.xz" ? "-xJf" : "-xzf";
|
||||
runCommand("tar", [tarFlag, archivePath, "-C", extractionRoot]);
|
||||
}
|
||||
return {
|
||||
extractionRoot,
|
||||
extractedRoot: path.join(extractionRoot, nodeArtifact.baseName),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureWorkspaceBuilds() {
|
||||
const requiredPaths = [
|
||||
path.join(repoRoot, "packages", "relay", "dist"),
|
||||
path.join(repoRoot, "packages", "server", "dist"),
|
||||
path.join(repoRoot, "packages", "cli", "dist"),
|
||||
];
|
||||
for (const requiredPath of requiredPaths) {
|
||||
if (!(await pathExists(requiredPath))) {
|
||||
throw new Error(
|
||||
`Managed runtime build is missing required path: ${requiredPath}. Run the daemon build first.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function packWorkspace(packageRoot, tarballRoot) {
|
||||
await ensureDir(tarballRoot);
|
||||
const result = runCommand("npm", ["pack", "--json", "--pack-destination", tarballRoot], {
|
||||
cwd: packageRoot,
|
||||
});
|
||||
const [{ filename }] = JSON.parse(result.stdout.trim());
|
||||
if (!filename) {
|
||||
throw new Error(`npm pack did not produce a filename for ${packageRoot}`);
|
||||
}
|
||||
return path.join(tarballRoot, filename);
|
||||
}
|
||||
|
||||
async function installPackedWorkspaces(runtimeRoot, bundledNodeRoot, tarballs) {
|
||||
const nodeExecutable = process.platform === "win32"
|
||||
? path.join(bundledNodeRoot, "node.exe")
|
||||
: path.join(bundledNodeRoot, "bin", "node");
|
||||
const npmCli = process.platform === "win32"
|
||||
? path.join(bundledNodeRoot, "node_modules", "npm", "bin", "npm-cli.js")
|
||||
: path.join(bundledNodeRoot, "lib", "node_modules", "npm", "bin", "npm-cli.js");
|
||||
const install = spawnSync(
|
||||
nodeExecutable,
|
||||
[
|
||||
npmCli,
|
||||
"install",
|
||||
"--omit=dev",
|
||||
"--no-package-lock",
|
||||
"--no-save",
|
||||
...tarballs,
|
||||
],
|
||||
{
|
||||
cwd: runtimeRoot,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_audit: "false",
|
||||
npm_config_fund: "false",
|
||||
},
|
||||
}
|
||||
);
|
||||
if (install.status !== 0) {
|
||||
throw new Error(
|
||||
`Managed runtime dependency install failed with exit code ${install.status ?? 1}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRuntimePackageJson(runtimeRoot) {
|
||||
await fs.writeFile(
|
||||
path.join(runtimeRoot, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "paseo-managed-runtime",
|
||||
private: true,
|
||||
version: packageVersion,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + "\n",
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureWorkspaceBuilds();
|
||||
|
||||
const toolVersions = await readToolVersions();
|
||||
const nodeVersion = resolveNodeVersion(toolVersions);
|
||||
const nodeArtifact = resolveNodeArtifact(nodeVersion);
|
||||
|
||||
await rmSafe(resourcesRoot);
|
||||
await ensureDir(resourcesRoot);
|
||||
|
||||
const archivePath = await ensureNodeArchive(nodeArtifact);
|
||||
const { extractionRoot, extractedRoot } = await extractNodeDistribution(archivePath, nodeArtifact);
|
||||
const tarballRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-managed-runtime-pack-"));
|
||||
|
||||
try {
|
||||
const tarballs = [];
|
||||
for (const workspace of workspaces) {
|
||||
tarballs.push({
|
||||
name: workspace.name,
|
||||
path: await packWorkspace(workspace.root, tarballRoot),
|
||||
});
|
||||
}
|
||||
const runtimeContentHash = await computeRuntimeContentHash({
|
||||
nodeVersion,
|
||||
tarballs,
|
||||
});
|
||||
const runtimeId =
|
||||
`${packageVersion}-node-${nodeVersion}-${process.platform}-${process.arch}-${runtimeContentHash}`;
|
||||
const runtimeRoot = path.join(resourcesRoot, runtimeId);
|
||||
|
||||
await ensureDir(runtimeRoot);
|
||||
await copyDir(extractedRoot, path.join(runtimeRoot, "node"));
|
||||
await writeRuntimePackageJson(runtimeRoot);
|
||||
await installPackedWorkspaces(
|
||||
runtimeRoot,
|
||||
path.join(runtimeRoot, "node"),
|
||||
tarballs.map((entry) => entry.path)
|
||||
);
|
||||
|
||||
const nodeRelativePath = process.platform === "win32"
|
||||
? path.join("node", "node.exe")
|
||||
: path.join("node", "bin", "node");
|
||||
const manifest = {
|
||||
runtimeId,
|
||||
runtimeVersion: packageVersion,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
createdAt: new Date().toISOString(),
|
||||
nodeRelativePath,
|
||||
cliEntrypointRelativePath: path.join(
|
||||
"node_modules",
|
||||
"@getpaseo",
|
||||
"cli",
|
||||
"dist",
|
||||
"index.js"
|
||||
),
|
||||
cliShimRelativePath: path.join("node_modules", "@getpaseo", "cli", "bin", "paseo"),
|
||||
serverRunnerRelativePath: path.join(
|
||||
"node_modules",
|
||||
"@getpaseo",
|
||||
"server",
|
||||
"dist",
|
||||
"scripts",
|
||||
"daemon-runner.js"
|
||||
),
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(runtimeRoot, "runtime-manifest.json"),
|
||||
JSON.stringify(manifest, null, 2) + "\n",
|
||||
"utf8"
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(resourcesRoot, "current-runtime.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
runtimeId,
|
||||
runtimeVersion: packageVersion,
|
||||
relativeRoot: runtimeId,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + "\n",
|
||||
"utf8"
|
||||
);
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
await fs.chmod(path.join(runtimeRoot, nodeRelativePath), 0o755);
|
||||
}
|
||||
} finally {
|
||||
await rmSafe(extractionRoot);
|
||||
await rmSafe(tarballRoot);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,746 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import WebSocket from "ws";
|
||||
import { createClientChannel } from "@getpaseo/relay/e2ee";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = path.resolve(new URL("../../..", import.meta.url).pathname);
|
||||
const desktopRoot = path.join(repoRoot, "packages", "desktop");
|
||||
const relayRoot = path.join(repoRoot, "packages", "relay");
|
||||
const desktopPackageJson = JSON.parse(
|
||||
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
|
||||
);
|
||||
const currentRuntimeVersion = desktopPackageJson.version;
|
||||
const currentRuntimePointer = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(desktopRoot, "src-tauri", "resources", "managed-runtime", "current-runtime.json"),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
const currentRuntimeId = currentRuntimePointer.runtimeId;
|
||||
|
||||
function buildRuntimeId(version) {
|
||||
return currentRuntimeId.replace(currentRuntimeVersion, version);
|
||||
}
|
||||
|
||||
function resolvePackagedBinary() {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(
|
||||
desktopRoot,
|
||||
"src-tauri",
|
||||
"target",
|
||||
"release",
|
||||
"bundle",
|
||||
"macos",
|
||||
"Paseo.app",
|
||||
"Contents",
|
||||
"MacOS",
|
||||
"Paseo"
|
||||
);
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
return path.join(
|
||||
desktopRoot,
|
||||
"src-tauri",
|
||||
"target",
|
||||
"release",
|
||||
"bundle",
|
||||
"appimage",
|
||||
`Paseo_${desktopPackageJson.version}_amd64.AppImage`
|
||||
);
|
||||
}
|
||||
throw new Error(`Managed desktop smoke is not implemented for ${process.platform} yet.`);
|
||||
}
|
||||
|
||||
async function pathExists(target) {
|
||||
try {
|
||||
await fs.access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function snapshotTree(root) {
|
||||
if (!(await pathExists(root))) {
|
||||
return [];
|
||||
}
|
||||
const entries = [];
|
||||
async function walk(current) {
|
||||
const stat = await fs.stat(current);
|
||||
const relative = path.relative(root, current) || ".";
|
||||
entries.push({
|
||||
relative,
|
||||
kind: stat.isDirectory() ? "dir" : "file",
|
||||
size: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
});
|
||||
if (!stat.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
const children = await fs.readdir(current);
|
||||
children.sort();
|
||||
for (const child of children) {
|
||||
await walk(path.join(current, child));
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
entries.sort((left, right) => left.relative.localeCompare(right.relative));
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function escapeForRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function derivePreviousVersion(version) {
|
||||
const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (!match) {
|
||||
return `${version}-previous`;
|
||||
}
|
||||
const [, major, minor, patch] = match;
|
||||
const numericPatch = Number.parseInt(patch, 10);
|
||||
if (numericPatch <= 0) {
|
||||
return `${major}.${minor}.0-previous`;
|
||||
}
|
||||
return `${major}.${minor}.${numericPatch - 1}`;
|
||||
}
|
||||
|
||||
function buildSeededTransportPath(testRoot) {
|
||||
if (process.platform === "win32") {
|
||||
return String.raw`\\.\pipe\paseo-managed-seeded-upgrade`;
|
||||
}
|
||||
return path.join(testRoot, "managed-home", "run", "paseo.sock");
|
||||
}
|
||||
|
||||
async function seedPreviousManagedRuntime(testRoot) {
|
||||
const previousRuntimeVersion = derivePreviousVersion(currentRuntimeVersion);
|
||||
const previousRuntimeId = buildRuntimeId(previousRuntimeVersion);
|
||||
const previousRuntimeRoot = path.join(testRoot, "runtime", previousRuntimeId);
|
||||
const stateFilePath = path.join(testRoot, "managed-state.json");
|
||||
const managedHome = path.join(testRoot, "managed-home");
|
||||
const transportType = process.platform === "win32" ? "pipe" : "socket";
|
||||
const transportPath = buildSeededTransportPath(testRoot);
|
||||
await fs.mkdir(previousRuntimeRoot, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(previousRuntimeRoot, "runtime-manifest.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
runtimeId: previousRuntimeId,
|
||||
runtimeVersion: previousRuntimeVersion,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
createdAt: "2000-01-01T00:00:00.000Z",
|
||||
nodeRelativePath: process.platform === "win32" ? path.join("node", "node.exe") : path.join("node", "node"),
|
||||
cliEntrypointRelativePath: path.join("node_modules", "@getpaseo", "cli", "dist", "index.js"),
|
||||
cliShimRelativePath: path.join("node_modules", "@getpaseo", "cli", "bin", "paseo"),
|
||||
serverRunnerRelativePath: path.join(
|
||||
"node_modules",
|
||||
"@getpaseo",
|
||||
"server",
|
||||
"dist",
|
||||
"scripts",
|
||||
"daemon-runner.js"
|
||||
),
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + "\n",
|
||||
"utf8"
|
||||
);
|
||||
await fs.writeFile(path.join(previousRuntimeRoot, "seed-marker.txt"), "version-n\n", "utf8");
|
||||
await fs.writeFile(
|
||||
stateFilePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
runtimeId: previousRuntimeId,
|
||||
runtimeRoot: previousRuntimeRoot,
|
||||
managedHome,
|
||||
transportType,
|
||||
transportPath,
|
||||
tcpEnabled: false,
|
||||
tcpListen: null,
|
||||
cliShimPath: null,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + "\n",
|
||||
"utf8"
|
||||
);
|
||||
return {
|
||||
previousRuntimeId,
|
||||
previousRuntimeVersion,
|
||||
previousRuntimeRoot,
|
||||
stateFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
async function runBinary(binaryPath, args, env) {
|
||||
const { stdout, stderr } = await execFileAsync(binaryPath, args, {
|
||||
env,
|
||||
cwd: repoRoot,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
const trimmed = stdout.trim();
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
json: trimmed ? JSON.parse(trimmed) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function runWorkspaceCli(args, env) {
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
process.execPath,
|
||||
[path.join(repoRoot, "packages", "cli", "dist", "index.js"), ...args],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
let json = null;
|
||||
if (stdout.trim()) {
|
||||
try {
|
||||
json = JSON.parse(stdout.trim());
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
}
|
||||
return { stdout, stderr, json };
|
||||
}
|
||||
|
||||
async function readDaemonStatus(home, env) {
|
||||
const result = await runWorkspaceCli(["daemon", "status", "--home", home, "--json"], env);
|
||||
return result.json ?? {};
|
||||
}
|
||||
|
||||
async function pidListeningOnPort(port) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("lsof", ["-ti", `tcp:${port}`], {
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const pid = Number.parseInt(stdout.trim().split("\n")[0] ?? "", 10);
|
||||
return Number.isFinite(pid) ? pid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getAvailablePort() {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to allocate an ephemeral test port.")));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildRelayWebSocketUrl({ endpoint, serverId, role }) {
|
||||
const url = new URL(`ws://${endpoint}/ws`);
|
||||
url.searchParams.set("serverId", serverId);
|
||||
url.searchParams.set("role", role);
|
||||
url.searchParams.set("v", "2");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function parseOfferUrlFromCommandOutput(stdout) {
|
||||
const match = stdout.match(/https?:\/\/\S+#offer=\S+/);
|
||||
if (!match) {
|
||||
throw new Error(`Failed to find pairing URL in output: ${stdout}`);
|
||||
}
|
||||
return match[0];
|
||||
}
|
||||
|
||||
function decodeOfferFromFragmentUrl(url) {
|
||||
const marker = "#offer=";
|
||||
const index = url.indexOf(marker);
|
||||
if (index === -1) {
|
||||
throw new Error(`Pairing URL is missing ${marker}: ${url}`);
|
||||
}
|
||||
const encoded = url.slice(index + marker.length);
|
||||
const raw = Buffer.from(encoded, "base64url").toString("utf8");
|
||||
const offer = JSON.parse(raw);
|
||||
if (offer?.v !== 2 || typeof offer?.serverId !== "string" || typeof offer?.daemonPublicKeyB64 !== "string") {
|
||||
throw new Error(`Unexpected relay offer payload: ${raw}`);
|
||||
}
|
||||
return offer;
|
||||
}
|
||||
|
||||
async function waitForRelayWebSocketReady(endpoint, timeoutMs) {
|
||||
await waitFor(async () => {
|
||||
const probeServerId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
const url = buildRelayWebSocketUrl({
|
||||
endpoint,
|
||||
serverId: probeServerId,
|
||||
role: "server",
|
||||
});
|
||||
const opened = await new Promise((resolve) => {
|
||||
const ws = new WebSocket(url);
|
||||
const timer = setTimeout(() => {
|
||||
ws.terminate();
|
||||
resolve(false);
|
||||
}, 5_000);
|
||||
ws.once("open", () => {
|
||||
clearTimeout(timer);
|
||||
ws.close(1000, "probe");
|
||||
resolve(true);
|
||||
});
|
||||
ws.once("error", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
assert.equal(opened, true);
|
||||
}, timeoutMs, "relay websocket endpoint to accept connections");
|
||||
}
|
||||
|
||||
async function connectViaRelay(endpoint, offer) {
|
||||
const stableClientId = `cid-managed-smoke-${Date.now().toString(36)}`;
|
||||
const ws = new WebSocket(
|
||||
buildRelayWebSocketUrl({
|
||||
endpoint,
|
||||
serverId: offer.serverId,
|
||||
role: "client",
|
||||
})
|
||||
);
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("Timed out waiting for managed relay pong."));
|
||||
}, 20_000);
|
||||
|
||||
const transport = {
|
||||
send: (data) => ws.send(data),
|
||||
close: (code, reason) => ws.close(code, reason),
|
||||
onmessage: null,
|
||||
onclose: null,
|
||||
onerror: null,
|
||||
};
|
||||
|
||||
ws.on("message", (data) => {
|
||||
transport.onmessage?.(typeof data === "string" ? data : data.toString());
|
||||
});
|
||||
ws.on("close", (code, reason) => {
|
||||
transport.onclose?.(code, reason.toString());
|
||||
});
|
||||
ws.on("error", (error) => {
|
||||
transport.onerror?.(error);
|
||||
});
|
||||
|
||||
ws.on("open", async () => {
|
||||
try {
|
||||
let pingSent = false;
|
||||
let channelRef = null;
|
||||
const channel = await createClientChannel(transport, offer.daemonPublicKeyB64, {
|
||||
onmessage: (data) => {
|
||||
try {
|
||||
const payload = typeof data === "string" ? JSON.parse(data) : data;
|
||||
if (payload?.type === "welcome") {
|
||||
if (!pingSent && channelRef) {
|
||||
pingSent = true;
|
||||
void channelRef.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (payload?.type === "pong") {
|
||||
clearTimeout(timeout);
|
||||
resolve(payload);
|
||||
ws.close();
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
onerror: (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
channelRef = channel;
|
||||
await channel.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
clientId: stableClientId,
|
||||
clientType: "cli",
|
||||
protocolVersion: 1,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function assertNoForbiddenPathsOrPorts(value, forbidden) {
|
||||
const text =
|
||||
typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||
for (const candidate of forbidden) {
|
||||
assert.ok(
|
||||
!text.includes(candidate),
|
||||
`Unexpected forbidden managed smoke reference: ${candidate}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensurePackagedArtifact(binaryPath) {
|
||||
try {
|
||||
await execFileAsync("npm", ["run", "build"], {
|
||||
cwd: desktopRoot,
|
||||
env: process.env,
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
});
|
||||
} catch (error) {
|
||||
const combined = `${error.stdout ?? ""}\n${error.stderr ?? ""}`;
|
||||
const signingBlocked =
|
||||
combined.includes("TAURI_SIGNING_PRIVATE_KEY") &&
|
||||
(await pathExists(binaryPath));
|
||||
if (!signingBlocked) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
"[managed-smoke] continuing after Tauri updater signing failure because the packaged app artifact was already produced"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(assertion, timeoutMs, label) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
return await assertion();
|
||||
} catch {
|
||||
await sleep(250);
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function logStep(label) {
|
||||
console.log(`\n[managed-smoke] ${label}`);
|
||||
}
|
||||
|
||||
const packagedBinary = resolvePackagedBinary();
|
||||
await ensurePackagedArtifact(packagedBinary);
|
||||
if (!(await pathExists(packagedBinary))) {
|
||||
throw new Error(`Packaged desktop artifact not found: ${packagedBinary}`);
|
||||
}
|
||||
|
||||
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-desktop-smoke-"));
|
||||
const testRoot = path.join(tmpRoot, "managed-test-root");
|
||||
const fakeHome = path.join(tmpRoot, "fake-home");
|
||||
const fakePaseoHome = path.join(fakeHome, ".paseo");
|
||||
const cliScratchHome = path.join(tmpRoot, "cli-scratch-home");
|
||||
const externalHome = path.join(tmpRoot, "external-daemon-home");
|
||||
const externalPort = await getAvailablePort();
|
||||
const externalEndpoint = `127.0.0.1:${externalPort}`;
|
||||
const relayPort = await getAvailablePort();
|
||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||
await fs.mkdir(testRoot, { recursive: true });
|
||||
await fs.mkdir(fakePaseoHome, { recursive: true });
|
||||
await fs.mkdir(cliScratchHome, { recursive: true });
|
||||
await fs.mkdir(externalHome, { recursive: true });
|
||||
await fs.writeFile(path.join(fakePaseoHome, "sentinel.txt"), "do not touch\n", "utf8");
|
||||
|
||||
const fakePaseoSnapshotBefore = await snapshotTree(fakePaseoHome);
|
||||
const seededUpgradeState = await seedPreviousManagedRuntime(testRoot);
|
||||
const managedEnv = {
|
||||
...process.env,
|
||||
HOME: fakeHome,
|
||||
PASEO_HOME: cliScratchHome,
|
||||
PASEO_DESKTOP_TEST_ROOT: testRoot,
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0",
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
PASEO_RELAY_ENABLED: "true",
|
||||
PASEO_RELAY_ENDPOINT: relayEndpoint,
|
||||
PASEO_RELAY_PUBLIC_ENDPOINT: relayEndpoint,
|
||||
PASEO_APP_BASE_URL: "https://app.paseo.test",
|
||||
PASEO_PRIMARY_LAN_IP: "192.168.1.12",
|
||||
CI: "true",
|
||||
};
|
||||
let externalPid = null;
|
||||
let startedTemporaryExternalDaemon = false;
|
||||
let relayProcess = null;
|
||||
const forbiddenManagedReferences = ["127.0.0.1:6767", fakePaseoHome];
|
||||
|
||||
try {
|
||||
logStep(`Starting isolated local relay on ${relayEndpoint}`);
|
||||
relayProcess = spawn(process.platform === "win32" ? "npx.cmd" : "npx", [
|
||||
"wrangler",
|
||||
"dev",
|
||||
"--local",
|
||||
"--ip",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
String(relayPort),
|
||||
"--live-reload=false",
|
||||
"--show-interactive-dev-session=false",
|
||||
], {
|
||||
cwd: relayRoot,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
relayProcess.stdout?.on("data", (chunk) => {
|
||||
process.stdout.write(`[managed-smoke relay] ${chunk}`);
|
||||
});
|
||||
relayProcess.stderr?.on("data", (chunk) => {
|
||||
process.stderr.write(`[managed-smoke relay] ${chunk}`);
|
||||
});
|
||||
await waitFor(async () => {
|
||||
await execFileAsync(process.execPath, ["-e", `require("node:net").connect(${relayPort}, "127.0.0.1").on("connect", function () { this.end(); process.exit(0); }).on("error", () => process.exit(1));`]);
|
||||
}, 30_000, "relay HTTP endpoint to start");
|
||||
await waitForRelayWebSocketReady(relayEndpoint, 60_000);
|
||||
|
||||
logStep(`Starting isolated external daemon on ${externalEndpoint}`);
|
||||
startedTemporaryExternalDaemon = true;
|
||||
await runWorkspaceCli(
|
||||
["start", "--home", externalHome, "--listen", externalEndpoint, "--no-relay"],
|
||||
managedEnv
|
||||
);
|
||||
const externalStatus = await waitFor(
|
||||
async () => {
|
||||
const status = await readDaemonStatus(externalHome, managedEnv);
|
||||
assert.equal(status.status, "running");
|
||||
assert.ok(typeof status.pid === "number" && status.pid > 0, "expected external daemon pid");
|
||||
await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv);
|
||||
return status;
|
||||
},
|
||||
15_000,
|
||||
"external daemon to start"
|
||||
);
|
||||
externalPid = externalStatus.pid;
|
||||
assert.ok(externalPid, "external daemon pid should be present");
|
||||
|
||||
logStep("Bootstrapping managed runtime from packaged desktop binary");
|
||||
const runtimeStatus = await runBinary(packagedBinary, ["--managed-headless", "runtime-status"], managedEnv);
|
||||
assert.equal(await pathExists(seededUpgradeState.previousRuntimeRoot), true);
|
||||
assert.equal(runtimeStatus.json.runtimeId, currentRuntimeId);
|
||||
assert.equal(runtimeStatus.json.runtimeVersion, currentRuntimeVersion);
|
||||
assert.match(runtimeStatus.json.managedHome, new RegExp(`^${escapeForRegExp(testRoot)}`));
|
||||
assert.equal(
|
||||
runtimeStatus.json.installedRuntimeRoot,
|
||||
path.join(testRoot, "runtime", currentRuntimeId),
|
||||
"managed runtime should install under the current bundled runtime id"
|
||||
);
|
||||
assert.notEqual(
|
||||
runtimeStatus.json.installedRuntimeRoot,
|
||||
seededUpgradeState.previousRuntimeRoot,
|
||||
"packaged app should adopt the bundled runtime instead of the seeded previous runtime"
|
||||
);
|
||||
assert.ok(runtimeStatus.json.transportType === "socket" || runtimeStatus.json.transportType === "pipe");
|
||||
assert.notEqual(runtimeStatus.json.transportPath, "127.0.0.1:6767");
|
||||
assertNoForbiddenPathsOrPorts(runtimeStatus.json, forbiddenManagedReferences);
|
||||
const stateAfterRuntimeInstall = JSON.parse(
|
||||
await fs.readFile(seededUpgradeState.stateFilePath, "utf8")
|
||||
);
|
||||
assert.equal(stateAfterRuntimeInstall.runtimeId, currentRuntimeId);
|
||||
assert.equal(stateAfterRuntimeInstall.runtimeRoot, runtimeStatus.json.installedRuntimeRoot);
|
||||
assert.equal(await pathExists(seededUpgradeState.previousRuntimeRoot), true);
|
||||
|
||||
const managedStart = await runBinary(packagedBinary, ["--managed-headless", "bootstrap"], managedEnv);
|
||||
assert.equal(managedStart.json.daemonRunning, true);
|
||||
assert.ok(managedStart.json.daemonPid, "managed daemon pid should exist");
|
||||
assert.ok(
|
||||
managedStart.json.transportType === "socket" || managedStart.json.transportType === "pipe",
|
||||
"managed daemon should default to private IPC transport"
|
||||
);
|
||||
assert.notEqual(managedStart.json.transportPath, "127.0.0.1:6767");
|
||||
assertNoForbiddenPathsOrPorts(managedStart.json, forbiddenManagedReferences);
|
||||
|
||||
const managedPid = managedStart.json.daemonPid;
|
||||
const stateFile = path.join(testRoot, "managed-state.json");
|
||||
assert.equal(await pathExists(stateFile), true, "managed state file should be written");
|
||||
|
||||
logStep("Verifying the managed daemon stays alive after the packaged command exits");
|
||||
await sleep(1_500);
|
||||
const persistedManagedStatus = await runBinary(
|
||||
packagedBinary,
|
||||
["--managed-headless", "daemon-status"],
|
||||
managedEnv
|
||||
);
|
||||
assert.equal(persistedManagedStatus.json.daemonPid, managedPid);
|
||||
assert.equal(persistedManagedStatus.json.daemonRunning, true);
|
||||
assert.equal(persistedManagedStatus.json.relayEnabled, true);
|
||||
assertNoForbiddenPathsOrPorts(persistedManagedStatus.json, forbiddenManagedReferences);
|
||||
|
||||
logStep("Installing CLI shim and verifying it targets the managed daemon");
|
||||
const cliInstall = await runBinary(
|
||||
packagedBinary,
|
||||
["--managed-headless", "install-cli-shim"],
|
||||
managedEnv
|
||||
);
|
||||
const cliShimPath = cliInstall.json.path;
|
||||
assert.ok(cliShimPath, "CLI shim path should be returned");
|
||||
assert.equal(await pathExists(cliShimPath), true, "CLI shim should exist");
|
||||
const cliVersion = await execFileAsync(cliShimPath, ["--version"], {
|
||||
env: managedEnv,
|
||||
cwd: repoRoot,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
assert.match(cliVersion.stdout.trim(), /^0\./);
|
||||
const shimStatus = await execFileAsync(cliShimPath, ["daemon", "status", "--json"], {
|
||||
env: managedEnv,
|
||||
cwd: repoRoot,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const shimDaemonStatus = JSON.parse(shimStatus.stdout.trim());
|
||||
assert.equal(shimDaemonStatus.pid, managedPid);
|
||||
assertNoForbiddenPathsOrPorts(shimDaemonStatus, forbiddenManagedReferences);
|
||||
|
||||
logStep("Verifying relay connectivity still works after the desktop command has exited");
|
||||
const relayPairing = await execFileAsync(
|
||||
cliShimPath,
|
||||
["daemon", "pair", "--home", managedStart.json.managedHome],
|
||||
{
|
||||
env: managedEnv,
|
||||
cwd: repoRoot,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
}
|
||||
);
|
||||
const relayOfferUrl = parseOfferUrlFromCommandOutput(relayPairing.stdout);
|
||||
const relayOffer = decodeOfferFromFragmentUrl(relayOfferUrl);
|
||||
assert.equal(relayOffer.relay?.endpoint, relayEndpoint);
|
||||
const relayPong = await connectViaRelay(relayEndpoint, relayOffer);
|
||||
assert.deepEqual(relayPong, { type: "pong" });
|
||||
|
||||
logStep("Reopening packaged desktop command path without spawning duplicate daemons");
|
||||
const managedRestartless = await runBinary(
|
||||
packagedBinary,
|
||||
["--managed-headless", "bootstrap"],
|
||||
managedEnv
|
||||
);
|
||||
assert.equal(managedRestartless.json.daemonPid, managedPid);
|
||||
|
||||
logStep("Verifying managed and external daemons coexist");
|
||||
const externalStatusAfter = await readDaemonStatus(externalHome, managedEnv);
|
||||
const externalPidAfter = externalStatusAfter.pid ?? null;
|
||||
assert.equal(externalStatusAfter.status, "running");
|
||||
assert.equal(externalPidAfter, externalPid);
|
||||
await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv);
|
||||
const managedStatus = await runBinary(
|
||||
packagedBinary,
|
||||
["--managed-headless", "daemon-status"],
|
||||
managedEnv
|
||||
);
|
||||
assert.ok(managedStatus.json.serverId, "managed daemon should expose a server id");
|
||||
assertNoForbiddenPathsOrPorts(managedStatus.json, forbiddenManagedReferences);
|
||||
|
||||
logStep("Enabling managed TCP exposure on an explicit non-default port");
|
||||
const tcpEnabled = await runBinary(
|
||||
packagedBinary,
|
||||
[
|
||||
"--managed-headless",
|
||||
"update-tcp",
|
||||
"--enabled",
|
||||
"true",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"7771",
|
||||
],
|
||||
managedEnv
|
||||
);
|
||||
assert.equal(tcpEnabled.json.tcpEnabled, true);
|
||||
assert.equal(tcpEnabled.json.transportType, "tcp");
|
||||
assert.equal(tcpEnabled.json.transportPath, "127.0.0.1:7771");
|
||||
assert.notEqual(tcpEnabled.json.transportPath, "127.0.0.1:6767");
|
||||
assertNoForbiddenPathsOrPorts(tcpEnabled.json, [fakePaseoHome]);
|
||||
|
||||
logStep("Disabling managed TCP exposure and returning to private transport");
|
||||
const tcpDisabled = await runBinary(
|
||||
packagedBinary,
|
||||
[
|
||||
"--managed-headless",
|
||||
"update-tcp",
|
||||
"--enabled",
|
||||
"false",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"7771",
|
||||
],
|
||||
managedEnv
|
||||
);
|
||||
assert.equal(tcpDisabled.json.tcpEnabled, false);
|
||||
assert.notEqual(tcpDisabled.json.transportType, "tcp");
|
||||
assertNoForbiddenPathsOrPorts(tcpDisabled.json, forbiddenManagedReferences);
|
||||
|
||||
logStep("Capturing diagnostics and verifying the fake ~/.paseo stayed untouched");
|
||||
const fakePaseoSnapshotAfter = await snapshotTree(fakePaseoHome);
|
||||
assert.deepEqual(fakePaseoSnapshotAfter, fakePaseoSnapshotBefore);
|
||||
await fs.writeFile(
|
||||
path.join(testRoot, "managed-daemon-smoke-diagnostics.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
runtimeStatus: runtimeStatus.json,
|
||||
managedStart: managedStart.json,
|
||||
seededUpgradeState,
|
||||
stateAfterRuntimeInstall,
|
||||
persistedManagedStatus: persistedManagedStatus.json,
|
||||
managedRestartless: managedRestartless.json,
|
||||
cliInstall: cliInstall.json,
|
||||
shimDaemonStatus,
|
||||
relayEndpoint,
|
||||
relayOfferUrl,
|
||||
relayPong,
|
||||
externalEndpoint,
|
||||
externalPid,
|
||||
externalPidAfter,
|
||||
managedStatus: managedStatus.json,
|
||||
tcpEnabled: tcpEnabled.json,
|
||||
tcpDisabled: tcpDisabled.json,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + "\n",
|
||||
"utf8"
|
||||
);
|
||||
|
||||
console.log(`\n[managed-smoke] PASS (${testRoot})`);
|
||||
} finally {
|
||||
try {
|
||||
await runBinary(packagedBinary, ["--managed-headless", "stop-daemon"], managedEnv);
|
||||
} catch {}
|
||||
try {
|
||||
await runBinary(packagedBinary, ["--managed-headless", "uninstall-cli-shim"], managedEnv);
|
||||
} catch {}
|
||||
try {
|
||||
if (startedTemporaryExternalDaemon) {
|
||||
await runWorkspaceCli(["daemon", "stop", "--home", externalHome, "--json"]);
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
relayProcess?.kill("SIGTERM");
|
||||
} catch {}
|
||||
}
|
||||
Reference in New Issue
Block a user