mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
implement six-skill gstack 2 runtime
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
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";
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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 {
|
||||
return 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));
|
||||
}
|
||||
|
||||
function managedHomeError(message, code) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function isoNow(now) {
|
||||
return (now ? now() : new Date()).toISOString();
|
||||
}
|
||||
Reference in New Issue
Block a user