Files
gstack/runtime/managed-home.js
T

334 lines
15 KiB
JavaScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { assertPathInside, resolveRuntimePaths } from "./paths.js";
import { atomicWriteFile, atomicWriteJson, readJson, withLock } from "./storage.js";
import { errorWithCode as managedHomeError } from "./errors.js";
import { currentIsoTimestamp as isoNow } from "./time.js";
export const MANAGED_HOME_SCHEMA_VERSION = 1;
export const MANAGED_HOME_SENTINEL = ".gstack-managed-home.json";
export const RUNTIME_TRANSACTION_FILE = ".gstack-runtime-transaction.json";
/**
* Runtime install/upgrade/uninstall use a sibling lock so a purge cannot
* delete the lock that is protecting it. All destructive runtime lifecycle
* operations must use this lock, not a command-specific lock under home.
*/
export function runtimeLifecycleLockPath(home) {
const resolved = assertSafeManagedHomePath(home);
return `${resolved}.runtime-lifecycle.lock`;
}
export function assertSafeManagedHomePath(home, options = {}) {
if (typeof home !== "string" || home.length === 0 || home.includes("\0")) {
throw managedHomeError("A non-empty managed home path is required", "MANAGED_HOME_UNSAFE");
}
const resolved = path.resolve(home);
const root = path.parse(resolved).root;
const userHome = path.resolve(options.homeDir ?? os.homedir());
const cwd = path.resolve(options.cwd ?? process.cwd());
if (resolved === root || resolved === userHome || path.dirname(resolved) === root) {
throw managedHomeError(`Refusing unsafe managed home: ${resolved}`, "MANAGED_HOME_UNSAFE");
}
if (isSameOrAncestor(resolved, cwd)) {
throw managedHomeError(
`Refusing managed home that contains the current working directory: ${resolved}`,
"MANAGED_HOME_UNSAFE",
);
}
return resolved;
}
export async function ensureManagedHome(home, options = {}) {
const resolved = assertSafeManagedHomePath(home, options);
const existing = await fs.lstat(resolved).catch((error) => {
if (error?.code === "ENOENT") return null;
throw error;
});
if (existing?.isSymbolicLink() || (existing && !existing.isDirectory())) {
throw managedHomeError(`Managed home must be a real directory, not a link or file: ${resolved}`, "MANAGED_HOME_UNSAFE");
}
if (!existing) await fs.mkdir(resolved, { recursive: true, mode: 0o700 });
const sentinelPath = path.join(resolved, MANAGED_HOME_SENTINEL);
const sentinelStat = await fs.lstat(sentinelPath).catch((error) => {
if (error?.code === "ENOENT") return null;
throw error;
});
if (sentinelStat?.isSymbolicLink() || (sentinelStat && !sentinelStat.isFile())) {
throw managedHomeError(`Managed home sentinel is not a regular file: ${sentinelPath}`, "MANAGED_HOME_INVALID");
}
if (!sentinelStat) {
const entries = await fs.readdir(resolved);
const legacy = entries.length > 0
? await inspectRecognizedLegacyHome(resolved, entries)
: null;
if (entries.length > 0 && !legacy) {
throw managedHomeError(
`Refusing to claim a non-empty directory as managed home: ${resolved}`,
"MANAGED_HOME_UNOWNED",
);
}
const sentinel = {
schemaVersion: MANAGED_HOME_SCHEMA_VERSION,
kind: "gstack-managed-home",
home: resolved,
ownerId: randomUUID(),
createdAt: isoNow(options.now),
...(legacy ? {
adoptedLegacy: true,
preexistingTopLevel: [...entries].sort(),
} : {}),
};
await createOwnershipSentinel(sentinelPath, sentinel);
const claimedEntries = (await fs.readdir(resolved)).sort();
const expectedEntries = [...entries, MANAGED_HOME_SENTINEL].sort();
if (JSON.stringify(claimedEntries) !== JSON.stringify(expectedEntries)) {
await removeSentinelIfOwned(sentinelPath, sentinel.ownerId);
throw managedHomeError(
`Managed home changed while ownership was being claimed: ${resolved}`,
"MANAGED_HOME_UNOWNED",
);
}
return { home: resolved, sentinel, created: true };
}
const sentinel = await readAndValidateSentinel(sentinelPath, resolved);
return { home: resolved, sentinel, created: false };
}
/** Create/verify one direct runtime-owned directory without following links. */
export async function ensureManagedRuntimeDirectory(home, directory) {
const resolvedHome = path.resolve(home);
const resolvedDirectory = path.resolve(directory);
if (path.dirname(resolvedDirectory) !== resolvedHome) {
throw managedHomeError("Managed runtime directory must be a direct child of GSTACK_HOME", "MANAGED_HOME_SUBDIRECTORY_UNSAFE");
}
try {
await fs.mkdir(resolvedDirectory, { mode: 0o700 });
} catch (error) {
if (error?.code !== "EEXIST") throw error;
}
const stat = await fs.lstat(resolvedDirectory).catch(() => null);
if (!stat?.isDirectory() || stat.isSymbolicLink()) {
throw managedHomeError(`Managed runtime directory is missing or unsafe: ${resolvedDirectory}`, "MANAGED_HOME_SUBDIRECTORY_UNSAFE");
}
const [physicalHome, physicalDirectory] = await Promise.all([
fs.realpath(resolvedHome),
fs.realpath(resolvedDirectory),
]);
if (path.dirname(physicalDirectory) !== physicalHome) {
throw managedHomeError(`Managed runtime directory escaped GSTACK_HOME: ${resolvedDirectory}`, "MANAGED_HOME_SUBDIRECTORY_UNSAFE");
}
return resolvedDirectory;
}
async function inspectRecognizedLegacyHome(home, entries) {
// Record all pre-existing top-level entries in the sentinel so purge can
// never remove them, even if their names later overlap the managed runtime
// allowlist. Reject links before inspecting either legacy fingerprint.
for (const entry of entries) {
const stat = await fs.lstat(path.join(home, entry));
if (stat.isSymbolicLink()) return null;
}
// A legacy config with a known GStack key is the original adoption proof.
if (entries.includes("config.yaml")) {
const configPath = path.join(home, "config.yaml");
const configStat = await fs.lstat(configPath);
if (configStat.isFile() && configStat.size <= 1024 * 1024) {
const text = await fs.readFile(configPath, "utf8");
const knownKey = /^(?:proactive|routing_declined|telemetry|auto_upgrade|update_check|skill_prefix|checkpoint_mode|checkpoint_push|explain_level|codex_reviews|gstack_contributor|skip_eng_review|workspace_root|cross_project_learnings|artifacts_sync_mode|plan_tune_hooks|redact_repo_visibility|redact_prepush_hook|brain_trust_policy(?:@[a-f0-9]+)?):\s*/m;
if (knownKey.test(text)) return { kind: "legacy-config", configPath };
}
}
// gstack-artifacts-init historically ran before the first config write, so
// an existing artifacts repo can have no config.yaml and no ownership
// sentinel. Require the complete, content-bearing GStack fingerprint: a
// bare .git directory (or one marker file) must never make an arbitrary
// directory adoptable.
const artifactFiles = [
".gitignore",
".brain-allowlist",
".brain-privacy-map.json",
".gitattributes",
];
if (!entries.includes(".git") || !artifactFiles.every((entry) => entries.includes(entry))) return null;
const gitStat = await fs.lstat(path.join(home, ".git"));
if (!gitStat.isDirectory()) return null;
const stats = await Promise.all(artifactFiles.map((entry) => fs.lstat(path.join(home, entry))));
if (stats.some((stat) => !stat.isFile() || stat.size > 1024 * 1024)) return null;
const [gitignore, allowlist, privacyText, attributes] = await Promise.all(
artifactFiles.map((entry) => fs.readFile(path.join(home, entry), "utf8")),
);
let privacyMap;
try {
privacyMap = JSON.parse(privacyText);
} catch {
// Invalid legacy metadata is not sufficient proof that this home is ours.
privacyMap = null;
}
const hasCanonicalPrivacyEntry = Array.isArray(privacyMap) && privacyMap.some((entry) =>
entry?.pattern === "projects/*/learnings.jsonl" && entry?.class === "artifact",
);
const recognized = gitignore.includes("gstack-artifacts sync") &&
gitignore.includes(".brain-allowlist") &&
allowlist.split(/\r?\n/).includes("projects/*/learnings.jsonl") &&
allowlist.split(/\r?\n/).includes("retros/*.md") &&
attributes.split(/\r?\n/).includes("*.jsonl merge=jsonl-append") &&
hasCanonicalPrivacyEntry;
return recognized ? { kind: "legacy-artifacts-repo" } : null;
}
async function createOwnershipSentinel(sentinelPath, sentinel) {
let handle;
try {
handle = await fs.open(sentinelPath, "wx", 0o600);
await handle.writeFile(`${JSON.stringify(sentinel, null, 2)}\n`, "utf8");
await handle.sync();
} catch (error) {
if (error?.code === "EEXIST") {
throw managedHomeError(`Managed home ownership changed concurrently: ${sentinelPath}`, "MANAGED_HOME_UNOWNED");
}
if (handle) await fs.rm(sentinelPath, { force: true }).catch(() => {});
throw error;
} finally {
await handle?.close().catch(() => {});
}
}
async function removeSentinelIfOwned(sentinelPath, ownerId) {
const sentinel = await readJson(sentinelPath, null).catch(() => null);
if (sentinel?.ownerId === ownerId) await fs.rm(sentinelPath, { force: true });
}
export async function assertManagedHome(home, options = {}) {
const resolved = assertSafeManagedHomePath(home, options);
const stat = await fs.lstat(resolved).catch((error) => {
if (error?.code === "ENOENT") return null;
throw error;
});
if (!stat?.isDirectory() || stat.isSymbolicLink()) {
throw managedHomeError(`Managed home does not exist or is not a real directory: ${resolved}`, "MANAGED_HOME_UNOWNED");
}
const sentinelPath = path.join(resolved, MANAGED_HOME_SENTINEL);
const sentinelStat = await fs.lstat(sentinelPath).catch((error) => {
if (error?.code === "ENOENT") return null;
throw error;
});
if (!sentinelStat?.isFile() || sentinelStat.isSymbolicLink()) {
throw managedHomeError(
`Refusing runtime mutation because the ownership sentinel is missing or invalid: ${sentinelPath}`,
"MANAGED_HOME_UNOWNED",
);
}
const sentinel = await readAndValidateSentinel(sentinelPath, resolved);
return { home: resolved, sentinel };
}
export async function withRuntimeLifecycleLock(home, callback, options = {}) {
const resolved = assertSafeManagedHomePath(home, options);
return withLock(`${resolved}.runtime-lifecycle.lock`, () => callback(resolved), options.lockOptions);
}
/** Restore a launcher/manifest/pointer snapshot left by a killed installer. */
export async function recoverRuntimeTransactionUnlocked(home) {
const resolved = path.resolve(home);
const journalPath = path.join(resolved, RUNTIME_TRANSACTION_FILE);
const journal = await readJson(journalPath, null);
if (!journal) return { recovered: false };
const journalHomeMatches = typeof journal?.home === "string" && await pathsReferToSameLocation(journal.home, resolved);
const valid = journal.schemaVersion === 1 &&
journal.kind === "gstack-runtime-install-transaction" &&
journal.status === "prepared" &&
journalHomeMatches &&
Array.isArray(journal.files) &&
typeof journal.previousPointerExists === "boolean";
if (!valid) {
throw managedHomeError(`Runtime transaction journal is invalid: ${journalPath}`, "RUNTIME_TRANSACTION_INVALID");
}
for (const file of journal.files) {
const relative = validateTransactionPath(file?.path);
const absolute = assertPathInside(resolved, path.join(resolved, relative));
if (file.existed === false) {
const stat = await fs.lstat(absolute).catch((error) => error?.code === "ENOENT" ? null : Promise.reject(error));
if (stat?.isDirectory() && !stat.isSymbolicLink()) {
throw managedHomeError(`Refusing to remove transaction path directory: ${relative}`, "RUNTIME_TRANSACTION_INVALID");
}
await fs.rm(absolute, { force: true });
continue;
}
if (file.existed !== true || !Number.isInteger(file.mode) || file.mode < 0 || file.mode > 0o777 ||
typeof file.dataBase64 !== "string" || file.dataBase64.length > 16 * 1024 * 1024 || !validBase64(file.dataBase64)) {
throw managedHomeError(`Runtime transaction snapshot is invalid: ${relative}`, "RUNTIME_TRANSACTION_INVALID");
}
await atomicWriteFile(absolute, Buffer.from(file.dataBase64, "base64"), { mode: file.mode });
}
const pointerPath = resolveRuntimePaths({ home: resolved }).versionPointer;
if (journal.previousPointerExists) {
if (!journal.previousPointer || journal.previousPointer.schemaVersion !== 2) {
throw managedHomeError("Runtime transaction pointer snapshot is invalid", "RUNTIME_TRANSACTION_INVALID");
}
await atomicWriteJson(pointerPath, journal.previousPointer, { mode: 0o600 });
} else {
await fs.rm(pointerPath, { force: true });
}
await fs.rm(journalPath, { force: true });
await fs.rmdir(path.join(resolved, "bin")).catch((error) => {
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error?.code)) throw error;
});
return { recovered: true, version: journal.version ?? null };
}
async function readAndValidateSentinel(sentinelPath, expectedHome) {
const sentinel = await readJson(sentinelPath, null);
const homeMatches = typeof sentinel?.home === "string" && await pathsReferToSameLocation(sentinel.home, expectedHome);
const valid = sentinel?.schemaVersion === MANAGED_HOME_SCHEMA_VERSION &&
sentinel?.kind === "gstack-managed-home" &&
homeMatches &&
typeof sentinel?.ownerId === "string" &&
/^[0-9a-f-]{16,}$/i.test(sentinel.ownerId) &&
(!sentinel.adoptedLegacy || (
Array.isArray(sentinel.preexistingTopLevel) &&
sentinel.preexistingTopLevel.every((entry) => typeof entry === "string" && entry.length > 0 && entry !== MANAGED_HOME_SENTINEL && path.basename(entry) === entry)
));
if (!valid) {
throw managedHomeError(`Managed home sentinel is invalid or belongs to another path: ${sentinelPath}`, "MANAGED_HOME_INVALID");
}
return sentinel;
}
async function pathsReferToSameLocation(left, right) {
if (path.resolve(left) === path.resolve(right)) return true;
const [physicalLeft, physicalRight] = await Promise.all([
fs.realpath(left).catch(() => path.resolve(left)),
fs.realpath(right).catch(() => path.resolve(right)),
]);
return physicalLeft === physicalRight;
}
function validateTransactionPath(value) {
if (typeof value !== "string" || value.includes("\0") || path.isAbsolute(value)) {
throw managedHomeError("Invalid runtime transaction path", "RUNTIME_TRANSACTION_INVALID");
}
const normalized = value.replaceAll("\\", "/");
if (normalized === "runtime-install.json" || /^bin\/[A-Za-z0-9._-]+$/.test(normalized)) return normalized;
throw managedHomeError(`Invalid runtime transaction path: ${value}`, "RUNTIME_TRANSACTION_INVALID");
}
function validBase64(value) {
return value.length % 4 === 0 && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
}
function isSameOrAncestor(candidate, target) {
const relative = path.relative(candidate, target);
return relative === "" || relative === "." || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
}