implement six-skill gstack 2 runtime

This commit is contained in:
Sinabina
2026-07-17 11:08:14 -07:00
parent ce37bd36a9
commit b6572ebbb7
455 changed files with 108945 additions and 2622 deletions
+162
View File
@@ -0,0 +1,162 @@
import fs from "node:fs/promises";
import path from "node:path";
import { assertPathInside, resolveRuntimePaths } from "./paths.js";
import { pathExists } from "./storage.js";
import { recoverPendingUpgradeUnlocked } from "./upgrade.js";
import { assertManagedHome, withRuntimeLifecycleLock } from "./managed-home.js";
const HOME_ATOMIC_TARGETS = Object.freeze([
".gstack-managed-home.json",
"config.json",
"migration.json",
"runtime-install.json",
"secrets.json",
]);
const UUID_SUFFIX = "[0-9a-f-]{8,}";
const TMP_ATOMIC_PATTERN = new RegExp(`^\\.[A-Za-z0-9._-]+\\.tmp-\\d+-${UUID_SUFFIX}$`, "i");
const INSTALL_SCRATCH_PATTERN = new RegExp(`^(?:install|uninstall)-${UUID_SUFFIX}$`, "i");
const STALE_LOCK_SCRATCH_PATTERN = new RegExp(`^[A-Za-z0-9._-]+\\.lock\\.stale-\\d+-${UUID_SUFFIX}$`, "i");
const VERSION_STAGE_PATTERN = new RegExp(`^\\.stage-[0-9A-Za-z][0-9A-Za-z._-]{0,79}-${UUID_SUFFIX}$`, "i");
export async function cleanupRuntime(home, options = {}) {
const paths = resolveRuntimePaths({ home });
const olderThanMs = options.olderThanMs ?? 24 * 60 * 60 * 1000;
const now = options.nowMs ?? Date.now();
const dryRun = Boolean(options.dryRun);
const removed = [];
const skipped = [];
if (!(await pathExists(paths.home))) return { removed, skipped, bytesReclaimed: 0, dryRun };
const homeStat = await fs.lstat(paths.home);
if (!homeStat.isDirectory() || homeStat.isSymbolicLink()) {
const error = new Error(`Refusing to clean an unsafe gstack home: ${paths.home}`);
error.code = "CLEANUP_HOME_UNSAFE";
throw error;
}
return withRuntimeLifecycleLock(paths.home, async () => {
await assertManagedHome(paths.home, options);
return cleanupRuntimeUnlocked(paths, { olderThanMs, now, dryRun, removed, skipped });
}, { lockOptions: options.lockOptions });
}
async function cleanupRuntimeUnlocked(paths, options) {
const { olderThanMs, now, dryRun, removed, skipped } = options;
const pendingRecovery = dryRun ? null : await recoverPendingUpgradeUnlocked(paths);
let bytesReclaimed = 0;
/**
* Cleanup is intentionally shallow. In particular, never recurse through
* projects, plans, or active immutable versions: those trees can contain
* user-authored files whose names happen to look like runtime temporaries.
*/
const cleanDirectory = async (directory, classify) => {
const directoryStat = await fs.lstat(directory).catch((error) => {
if (error?.code === "ENOENT") return null;
throw error;
});
if (!directoryStat) return;
if (directoryStat.isSymbolicLink()) {
skipped.push({ path: directory, reason: "symlink-directory" });
return;
}
if (!directoryStat.isDirectory()) {
skipped.push({ path: directory, reason: "unexpected-directory-type" });
return;
}
let entries;
try {
entries = await fs.readdir(directory, { withFileTypes: true });
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
for (const entry of entries) {
const candidate = assertPathInside(paths.home, path.join(directory, entry.name));
const stat = await fs.lstat(candidate).catch((error) => {
if (error?.code === "ENOENT") return null;
throw error;
});
if (!stat) continue;
if (stat.isSymbolicLink()) {
skipped.push({ path: candidate, reason: "symlink" });
continue;
}
const age = now - stat.mtimeMs;
if (age < olderThanMs) continue;
const reason = await classify(entry.name, stat, candidate);
if (reason) {
const size = stat.isDirectory() ? await directorySize(candidate) : stat.size;
removed.push({ path: candidate, reason, bytes: size });
bytesReclaimed += size;
if (!dryRun) await fs.rm(candidate, { recursive: true, force: true });
}
}
};
await cleanDirectory(paths.home, (name, stat) =>
stat.isFile() && isAtomicSidecar(name, HOME_ATOMIC_TARGETS) ? "stale-temporary" : null);
await cleanDirectory(paths.tmp, (name, stat) =>
(stat.isFile() && TMP_ATOMIC_PATTERN.test(name)) ||
(stat.isDirectory() && INSTALL_SCRATCH_PATTERN.test(name))
? "stale-install-scratch"
: null);
await cleanDirectory(paths.locks, async (name, stat, candidate) => {
if (stat.isDirectory() && STALE_LOCK_SCRATCH_PATTERN.test(name)) return "stale-lock-scratch";
if (!stat.isDirectory() || !/^[A-Za-z0-9._-]+\.lock$/.test(name)) return null;
return await lockOwnerIsAlive(candidate) ? null : "stale-lock";
});
await cleanDirectory(paths.versions, (name, stat) => {
if (stat.isDirectory() && VERSION_STAGE_PATTERN.test(name)) return "stale-version-stage";
return stat.isFile() && isAtomicSidecar(name, ["current.json"]) ? "stale-temporary" : null;
});
return { removed, skipped, bytesReclaimed, dryRun, pendingRecovery };
}
function isAtomicSidecar(name, targets) {
return targets.some((target) => {
const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`^\\.${escaped}\\.tmp-\\d+-${UUID_SUFFIX}$`, "i").test(name) ||
new RegExp(`^${escaped}\\.replace-\\d+-${UUID_SUFFIX}$`, "i").test(name);
});
}
async function lockOwnerIsAlive(lockDirectory) {
try {
const owner = JSON.parse(await fs.readFile(path.join(lockDirectory, "owner.json"), "utf8"));
if (!Number.isInteger(owner.pid) || owner.pid <= 0) return false;
process.kill(owner.pid, 0);
return true;
} catch (error) {
return error?.code === "EPERM";
}
}
async function directorySize(directory) {
let total = 0;
const rootStat = await fs.lstat(directory).catch((error) => {
if (error?.code === "ENOENT") return null;
throw error;
});
if (!rootStat || rootStat.isSymbolicLink() || !rootStat.isDirectory()) return 0;
let entries;
try {
entries = await fs.readdir(directory, { withFileTypes: true });
} catch (error) {
if (error?.code === "ENOENT") return 0;
throw error;
}
for (const entry of entries) {
const child = path.join(directory, entry.name);
const stat = await fs.lstat(child).catch((error) => {
if (error?.code === "ENOENT") return null;
throw error;
});
if (!stat || stat.isSymbolicLink()) continue;
if (stat.isDirectory()) total += await directorySize(child);
else if (stat.isFile()) total += stat.size;
}
return total;
}
+665
View File
@@ -0,0 +1,665 @@
import readline from "node:readline/promises";
import fs from "node:fs/promises";
import path from "node:path";
import { spawn } from "node:child_process";
import { stdin as processStdin, stdout as processStdout, stderr as processStderr } from "node:process";
import { assertPathInside, resolveGstackHome, resolveRuntimePaths, shellQuote } from "./paths.js";
import { readJson } from "./storage.js";
import { setupRuntime } from "./setup.js";
import {
configGet,
configSet,
configSetNetworkChoice,
parseConfigValue,
secretSet,
} from "./config.js";
import { discoverProjectIdentity } from "./identity.js";
import {
beginRun,
completeRun,
inspectProject,
inspectRun,
markEffectApplied,
markEffectNotApplied,
resumeRun,
runExternalEffect,
updateRunWorkflow,
} from "./state.js";
import { runDoctor, formatDoctor } from "./doctor.js";
import { cleanupRuntime } from "./cleanup.js";
import {
ContextClient,
contextStatus,
readContextKey,
redactSensitiveText,
validateContextKey,
} from "./context.js";
import { rollbackUpgrade } from "./upgrade.js";
import { installManagedRuntime, uninstallManagedRuntime } from "./install.js";
import { assertManagedHome, withRuntimeLifecycleLock } from "./managed-home.js";
const RUNTIME_VERSION = "2.0.0";
export async function main(argv = process.argv.slice(2), options = {}) {
const env = options.env ?? process.env;
const cwd = options.cwd ?? process.cwd();
const stdin = options.stdin ?? processStdin;
const stdout = options.stdout ?? processStdout;
const stderr = options.stderr ?? processStderr;
const home = resolveGstackHome({ env, cwd, homeDir: options.homeDir });
const [command, ...args] = argv;
if (!command || ["help", "--help", "-h"].includes(command)) {
write(stdout, usage());
return 0;
}
if (["--version", "version", "-v"].includes(command)) {
write(stdout, `gstack runtime ${RUNTIME_VERSION}\n`);
return 0;
}
try {
switch (command) {
case "setup":
return await setupCommand({ args, home, cwd, stdout });
case "doctor":
return await doctorCommand({ args, home, cwd, stdout });
case "paths":
return await pathsCommand({ args, home, stdout });
case "runtime":
return await runtimeCommand({ args, home, stdout });
case "config":
return await configCommand({ args, home, cwd, stdout });
case "state":
return await stateCommand({ args, home, cwd, env, stdout, stderr });
case "context":
return await contextCommand({ args, home, cwd, env, stdin, stdout, stderr });
case "cleanup":
return await cleanupCommand({ args, home, stdout });
case "upgrade":
return await upgradeCommand({ args, home, stdout, installOptions: options.installOptions });
case "uninstall":
return await uninstallCommand({ args, home, stdout });
default:
throw cliError(`Unknown command: ${command}`, "USAGE");
}
} catch (error) {
const json = args.includes("--json");
const safeMessage = redactSecrets(error?.message ?? String(error));
if (json) {
write(stderr, `${JSON.stringify({ ok: false, error: error?.code ?? "ERROR", message: safeMessage })}\n`);
} else {
write(stderr, `gstack: ${safeMessage}\n`);
}
return exitCodeFor(error);
}
}
async function runtimeCommand({ args, home, stdout }) {
const [action, relative, ...rest] = args;
if (action !== "path" || !relative || rest.length > 0) {
throw cliError("Usage: gstack runtime path <bundle-relative-path>", "USAGE");
}
if (relative.includes("\0") || path.isAbsolute(relative) || relative.split(/[\\/]+/).some((part) => part === ".." || part === "")) {
throw cliError("Runtime bundle path must be a safe relative path", "USAGE");
}
const paths = resolveRuntimePaths({ home });
const pointer = await readJson(paths.versionPointer, null);
const version = pointer?.current;
if (!version) throw cliError("No active managed runtime; run `gstack upgrade --source <package> --version <version>`", "RUNTIME_NOT_INSTALLED");
if (typeof version !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(version)) {
throw cliError("Managed runtime pointer contains an invalid version", "RUNTIME_POINTER_INVALID");
}
const versionRoot = assertPathInside(paths.versions, path.join(paths.versions, version));
const target = assertPathInside(versionRoot, path.join(versionRoot, relative));
const stat = await fs.lstat(target).catch((error) => {
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return null;
throw error;
});
if (!stat || stat.isSymbolicLink()) throw cliError(`Managed runtime asset is unavailable: ${relative}`, "RUNTIME_ASSET_MISSING");
write(stdout, `${target}\n`);
return 0;
}
async function pathsCommand({ args, home, stdout }) {
rejectUnknown(args, ["--json", "--shell"]);
if (args.includes("--json") && args.includes("--shell")) {
throw cliError("Choose either --json or --shell", "USAGE");
}
const paths = resolveRuntimePaths({ home });
const result = {
GSTACK_STATE_ROOT: paths.home,
PLAN_ROOT: paths.plans,
TMP_ROOT: paths.tmp,
};
if (args.includes("--shell")) {
for (const [key, value] of Object.entries(result)) write(stdout, `${key}=${shellQuote(value)}\n`);
} else {
write(stdout, `${JSON.stringify(result, null, 2)}\n`);
}
return 0;
}
async function setupCommand({ args, home, cwd, stdout }) {
rejectUnknown(args, []);
const result = await setupRuntime({ home, cwd });
write(stdout, `gstack is ready\nhome: ${result.paths.home}\nproject: ${result.identity.projectId}\nnetwork: off\nContext.dev key setup: https://www.context.dev/auth.md\n`);
return 0;
}
async function doctorCommand({ args, home, cwd, stdout }) {
rejectUnknown(args, ["--json"]);
const report = await runDoctor({ home, cwd });
write(stdout, args.includes("--json") ? `${JSON.stringify(report, null, 2)}\n` : formatDoctor(report));
return report.ok ? 0 : 1;
}
async function configCommand({ args, home, cwd, stdout }) {
const [action, ...tail] = args;
if (action === "get") {
const key = tail.find((arg) => !arg.startsWith("--"));
rejectUnknown(tail.filter((arg) => arg !== key), ["--json"]);
const result = await configGet(home, key);
if (result === undefined) throw cliError(`Config key not found: ${key}`, "CONFIG_KEY_NOT_FOUND");
write(stdout, `${typeof result === "string" ? result : JSON.stringify(result, null, 2)}\n`);
return 0;
}
if (action === "set") {
const [key, value, ...rest] = tail;
if (!key || value === undefined || rest.length) throw cliError("Usage: gstack config set <key> <value>", "USAGE");
await setupRuntime({ home, cwd });
const result = await withOwnedRuntimeMutation(home, () => configSet(home, key, parseConfigValue(value)));
write(stdout, `${key} = ${typeof result === "string" ? result : JSON.stringify(result)}\n`);
return 0;
}
throw cliError("Usage: gstack config get [key] | gstack config set <key> <value>", "USAGE");
}
async function stateCommand({ args, home, cwd, env, stdout, stderr }) {
const [action, ...rest] = args;
const identity = await discoverProjectIdentity(cwd);
if (action === "inspect") {
const parsed = parseStateArguments(rest, { flags: ["--json"] });
if (parsed.positionals.length > 1) throw cliError("Usage: gstack state inspect [run-id] [--json]", "USAGE");
const runId = parsed.positionals[0];
const result = runId
? await inspectRun(home, identity.projectId, runId)
: await inspectProject(home, identity);
write(stdout, `${JSON.stringify(runId ? {
projectId: identity.projectId,
run: result.run,
reconstruction: result.reconstruction,
} : result.state, null, 2)}\n`);
return 0;
}
if (action === "resume") {
const parsed = parseStateArguments(rest, { flags: ["--json"] });
if (parsed.positionals.length > 1) throw cliError("Usage: gstack state resume [run-id] [--json]", "USAGE");
const runId = parsed.positionals[0];
const result = await withOwnedRuntimeMutation(home, () => resumeRun(home, identity.projectId, runId));
const output = { projectId: identity.projectId, run: result.run, reconstruction: result.reconstruction };
write(stdout, `${JSON.stringify(output, null, 2)}\n`);
return 0;
}
if (action === "begin") {
const parsed = parseStateArguments(rest, {
flags: ["--json"],
values: ["--run-id", "--goal", "--plan", "--stage", "--depth", "--mutation", "--modules"],
});
if (parsed.positionals.length !== 1) {
throw cliError("Usage: gstack state begin <workflow> [metadata options] [--json]", "USAGE");
}
const [workflow] = parsed.positionals;
const options = {
runId: parsed.values.get("--run-id"),
originalGoal: parsed.values.get("--goal"),
currentPlanPointer: parsed.values.get("--plan"),
currentWorkflowStage: parsed.values.get("--stage"),
selectedDepth: parsed.values.get("--depth"),
mutationAuthority: parsed.values.get("--mutation"),
activeModules: parsed.values.has("--modules") ? parseModuleList(parsed.values.get("--modules")) : undefined,
};
await setupRuntime({ home, cwd });
const result = await withOwnedRuntimeMutation(home, () => beginRun(home, identity.projectId, workflow, options));
const output = { projectId: identity.projectId, run: result.run, reconstruction: result.reconstruction };
write(stdout, parsed.flags.has("--json") ? `${JSON.stringify(output, null, 2)}\n` : `${result.run.id}\n`);
return 0;
}
if (action === "update") {
const parsed = parseStateArguments(rest, {
flags: ["--json", "--clear-plan", "--pop-detour"],
values: [
"--plan", "--stage", "--depth", "--mutation", "--modules", "--push-detour",
"--evidence-freshness", "--evidence-source", "--evidence-reference", "--evidence-captured-at",
"--add-approval", "--approval-summary", "--resolve-approval",
],
});
if (parsed.positionals.length !== 1) {
throw cliError("Usage: gstack state update <run-id> [workflow transition options] [--json]", "USAGE");
}
if (parsed.flags.has("--clear-plan") && parsed.values.has("--plan")) {
throw cliError("Choose either --plan or --clear-plan", "USAGE");
}
const transition = {};
if (parsed.values.has("--plan")) transition.currentPlanPointer = parsed.values.get("--plan");
if (parsed.flags.has("--clear-plan")) transition.currentPlanPointer = null;
if (parsed.values.has("--stage")) transition.currentWorkflowStage = parsed.values.get("--stage");
if (parsed.values.has("--depth")) transition.selectedDepth = parsed.values.get("--depth");
if (parsed.values.has("--mutation")) transition.mutationAuthority = parsed.values.get("--mutation");
if (parsed.values.has("--modules")) transition.activeModules = parseModuleList(parsed.values.get("--modules"));
if (parsed.values.has("--push-detour")) transition.pushDetour = parsed.values.get("--push-detour");
if (parsed.flags.has("--pop-detour")) transition.popDetour = true;
if (parsed.values.has("--evidence-freshness")) {
transition.evidenceFreshness = parsed.values.get("--evidence-freshness");
}
const evidenceFields = ["--evidence-source", "--evidence-reference", "--evidence-captured-at"];
const hasEvidence = evidenceFields.some((flag) => parsed.values.has(flag));
if (hasEvidence) {
if (!parsed.values.has("--evidence-source") || !parsed.values.has("--evidence-reference")) {
throw cliError("Evidence provenance requires --evidence-source and --evidence-reference", "USAGE");
}
transition.addEvidenceProvenance = {
source: parsed.values.get("--evidence-source"),
reference: parsed.values.get("--evidence-reference"),
capturedAt: parsed.values.get("--evidence-captured-at"),
};
if (transition.addEvidenceProvenance.capturedAt === undefined) {
delete transition.addEvidenceProvenance.capturedAt;
}
}
if (parsed.values.has("--approval-summary") && !parsed.values.has("--add-approval")) {
throw cliError("--approval-summary requires --add-approval", "USAGE");
}
if (parsed.values.has("--add-approval")) {
const summary = parsed.values.get("--approval-summary");
if (!summary) throw cliError("--add-approval requires --approval-summary", "USAGE");
transition.addApprovalGate = { id: parsed.values.get("--add-approval"), summary };
}
if (parsed.values.has("--resolve-approval")) {
transition.resolveApprovalGate = parsed.values.get("--resolve-approval");
}
const [runId] = parsed.positionals;
const result = await withOwnedRuntimeMutation(home, () =>
updateRunWorkflow(home, identity.projectId, runId, transition));
write(stdout, `${JSON.stringify({
projectId: identity.projectId,
run: result.run,
reconstruction: result.reconstruction,
}, null, 2)}\n`);
return 0;
}
if (action === "effect") {
const delimiter = rest.indexOf("--");
if (delimiter !== 2 || rest.length < 4) {
throw cliError("Usage: gstack state effect <run-id> <effect-key> -- <executable> [args...]", "USAGE");
}
const [runId, effectKey] = rest;
const command = rest.slice(delimiter + 1);
const result = await withOwnedRuntimeMutation(home, () => runExternalEffect(home, identity.projectId, runId, effectKey, async ({ idempotencyKey }) =>
runExternalCommand(command, {
cwd,
env: { ...env, GSTACK_IDEMPOTENCY_KEY: idempotencyKey },
stdout,
stderr,
})));
if (result.status === "uncertain") {
throw cliError(
`External effect ${effectKey} was already claimed. Inspect the external system, then reconcile explicitly; it was not repeated.`,
"EXTERNAL_EFFECT_UNCERTAIN",
);
}
write(stdout, `${JSON.stringify({ status: result.status, effectKey, idempotencyKey: result.idempotencyKey ?? null, result: result.result })}\n`);
return 0;
}
if (action === "reconcile-not-applied") {
const [runId, effectKey, confirmation, ...tail] = rest;
if (!runId || !effectKey || confirmation !== "--confirm-not-applied" || tail.length) {
throw cliError("Usage: gstack state reconcile-not-applied <run-id> <effect-key> --confirm-not-applied", "USAGE");
}
const result = await withOwnedRuntimeMutation(home, () => markEffectNotApplied(home, identity.projectId, runId, effectKey));
write(stdout, `${JSON.stringify(result.result)}\n`);
return 0;
}
if (action === "reconcile-applied") {
const [runId, effectKey, confirmation, evidenceFlag, evidence, ...tail] = rest;
if (!runId || !effectKey || confirmation !== "--confirm-applied" || evidenceFlag !== "--evidence" || !evidence || tail.length) {
throw cliError("Usage: gstack state reconcile-applied <run-id> <effect-key> --confirm-applied --evidence <reference>", "USAGE");
}
const result = await withOwnedRuntimeMutation(home, () => markEffectApplied(home, identity.projectId, runId, effectKey, evidence));
write(stdout, `${JSON.stringify(result.result)}\n`);
return 0;
}
if (action === "complete") {
const [runId, ...tail] = rest;
if (!runId || tail.length) throw cliError("Usage: gstack state complete <run-id>", "USAGE");
const result = await withOwnedRuntimeMutation(home, () => completeRun(home, identity.projectId, runId));
write(stdout, `${JSON.stringify({ projectId: identity.projectId, run: result.run })}\n`);
return 0;
}
throw cliError("Usage: gstack state inspect|begin|update|effect|resume|reconcile-applied|reconcile-not-applied|complete", "USAGE");
}
async function runExternalCommand(command, { cwd, env, stdout, stderr }) {
const [executable, ...args] = command;
return new Promise((resolve, reject) => {
const child = spawn(executable, args, {
cwd,
env,
shell: false,
stdio: ["inherit", "pipe", "pipe"],
});
child.stdout?.on("data", (chunk) => write(stdout, chunk));
child.stderr?.on("data", (chunk) => write(stderr, chunk));
child.once("error", reject);
child.once("close", (code, signal) => {
if (code === 0) {
resolve({ exitCode: 0, executable: path.basename(executable) });
return;
}
const error = cliError(
`External command ${path.basename(executable)} ${signal ? `ended by ${signal}` : `exited ${code}`}`,
"EXTERNAL_COMMAND_FAILED",
);
reject(error);
});
});
}
async function withOwnedRuntimeMutation(home, callback) {
return withRuntimeLifecycleLock(home, async () => {
await assertManagedHome(home);
return callback();
});
}
async function contextCommand({ args, home, cwd, env, stdin, stdout, stderr }) {
const [action, ...rest] = args;
if (action === "status") {
rejectUnknown(rest, ["--json"]);
const status = await contextStatus(home, env);
if (rest.includes("--json")) write(stdout, `${JSON.stringify(status, null, 2)}\n`);
else {
write(stdout, `Context.dev: ${status.contextReady ? "ready" : "not ready"}\nkey: ${status.configured ? `configured (${status.keySource})` : "missing"}\nweb context: ${status.selection ?? "not selected"}\nconsent: ${status.consent ? "yes" : "no"}\n`);
}
return status.ready ? 0 : 1;
}
if (action === "options") {
rejectUnknown(rest, []);
write(stdout, "GStack needs public web context.\n\nA) Set up Context.dev free (recommended)\nB) Use this host's built-in public web search, if available\nC) Use GStack's local browser\nD) Continue without web research\n\nNo URL or credential is sent until Context.dev is explicitly selected and consented.\n");
return 0;
}
if (action === "select") {
const [choice, ...tail] = rest;
rejectUnknown(tail, []);
const modes = { host: "host", browser: "local-browser", "local-browser": "local-browser", none: "off", off: "off" };
const mode = modes[choice];
if (!mode) throw cliError("Usage: gstack context select host|local-browser|none", "USAGE");
await setupRuntime({ home, cwd });
await withOwnedRuntimeMutation(home, () => configSetNetworkChoice(home, {
mode,
consent: false,
selection: mode === "off" ? "none" : mode,
}));
write(stdout, `Web context mode set to ${mode}; Context.dev network export remains off.\n`);
return 0;
}
if (action === "setup") {
if (rest.some((arg) => /key|token|secret/i.test(arg) || /^ctxt_secret_/i.test(arg))) {
throw cliError("API keys must be supplied through hidden stdin or CONTEXT_DEV_API_KEY, never argv", "KEY_ON_COMMAND_LINE");
}
rejectUnknown(rest, ["--consent"]);
await setupRuntime({ home, cwd });
let consent = rest.includes("--consent");
if (!consent) {
if (!stdin.isTTY) {
throw cliError("Explicit consent is required; rerun with --consent when piping a key", "CONSENT_REQUIRED");
}
const answer = await askLine(stdin, stderr,
"Enable Context.dev network requests? This may consume API credits. Type yes to continue: ");
consent = answer.trim().toLowerCase() === "yes";
}
if (!consent) throw cliError("Context.dev setup cancelled; network remains off", "CONSENT_REQUIRED");
let key;
try {
key = (await readContextKey({ home, env })).key;
} catch (error) {
if (error?.code !== "CONTEXT_KEY_MISSING") throw error;
key = stdin.isTTY
? await readHidden(stdin, stderr, "Context.dev API key: ")
: (await readStream(stdin)).trim();
}
validateContextKey(key);
await withOwnedRuntimeMutation(home, async () => {
await secretSet(home, "context.apiKey", key);
await configSetNetworkChoice(home, {
mode: "context",
consent: true,
selection: "context",
});
});
write(stdout, "Context.dev configured. The key is stored privately; network mode is context.\nKey source: https://www.context.dev/auth.md\n");
return 0;
}
if (action === "smoke") {
const parsed = parseFlags(rest, new Set(["--url", "--json"]));
const url = parsed.values.get("--url") ?? "https://www.context.dev";
const client = new ContextClient({ home, env });
const response = await client.scrapeMarkdown(url, { useMainContentOnly: true, maxAgeMs: 86_400_000 });
const result = {
ok: true,
endpoint: "/web/scrape/markdown",
url,
creditsRemaining: response.key_metadata?.credits_remaining ?? null,
};
write(stdout, parsed.flags.has("--json") ? `${JSON.stringify(result, null, 2)}\n` :
`Context.dev smoke test passed for ${url}${result.creditsRemaining == null ? "" : ` (${result.creditsRemaining} credits remaining)`}\n`);
return 0;
}
throw cliError("Usage: gstack context status|options|select|setup|smoke", "USAGE");
}
async function cleanupCommand({ args, home, stdout }) {
const parsed = parseFlags(args, new Set(["--dry-run", "--older-than-hours", "--json"]));
const hoursRaw = parsed.values.get("--older-than-hours");
const hours = hoursRaw == null ? 24 : Number(hoursRaw);
if (!Number.isFinite(hours) || hours < 0) throw cliError("--older-than-hours must be a non-negative number", "USAGE");
const result = await cleanupRuntime(home, {
dryRun: parsed.flags.has("--dry-run"),
olderThanMs: hours * 60 * 60 * 1000,
});
if (parsed.flags.has("--json")) write(stdout, `${JSON.stringify(result, null, 2)}\n`);
else write(stdout, `${result.dryRun ? "Would remove" : "Removed"} ${result.removed.length} stale item(s), ${result.bytesReclaimed} byte(s)\n`);
return 0;
}
async function upgradeCommand({ args, home, stdout, installOptions = {} }) {
const parsed = parseFlags(args, new Set(["--source", "--version", "--rollback", "--json"]));
if (parsed.flags.has("--rollback")) {
if (parsed.values.has("--source") || parsed.values.has("--version")) throw cliError("--rollback cannot be combined with staging options", "USAGE");
const pointer = await rollbackUpgrade(home);
write(stdout, parsed.flags.has("--json") ? `${JSON.stringify(pointer, null, 2)}\n` : `Rolled back to ${pointer.current}\n`);
return 0;
}
const sourceDir = parsed.values.get("--source");
const version = parsed.values.get("--version");
if (!sourceDir || !version) {
throw cliError("Usage: gstack upgrade --source <complete-gstack-package> --version <version> | --rollback", "USAGE");
}
const result = await installManagedRuntime({
home,
sourceDir,
version,
...installOptions,
buildMissing: false,
rejectSourceRootLink: true,
requirePackageIdentity: true,
});
write(stdout, parsed.flags.has("--json") ? `${JSON.stringify(result, null, 2)}\n` : `Activated ${result.pointer.current}\n`);
return 0;
}
async function uninstallCommand({ args, home, stdout }) {
rejectUnknown(args, ["--purge", "--yes", "--json"]);
const purge = args.includes("--purge");
if (purge && !args.includes("--yes")) {
throw cliError("Purging config, secrets, and project state requires both --purge and --yes", "CONFIRMATION_REQUIRED");
}
const result = await uninstallManagedRuntime(home, { purge });
write(stdout, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` :
purge ? `Purged gstack state at ${home}\n` : "Removed managed runtime versions; config and project state were preserved.\n");
return 0;
}
function parseStateArguments(args, options = {}) {
const valueFlags = new Set(options.values ?? []);
const booleanFlags = new Set(options.flags ?? []);
const values = new Map();
const flags = new Set();
const positionals = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--")) {
positionals.push(arg);
continue;
}
if (valueFlags.has(arg)) {
if (values.has(arg)) throw cliError(`Duplicate option: ${arg}`, "USAGE");
const value = args[++index];
if (value == null || value.startsWith("--")) throw cliError(`${arg} requires a value`, "USAGE");
values.set(arg, value);
continue;
}
if (booleanFlags.has(arg)) {
if (flags.has(arg)) throw cliError(`Duplicate option: ${arg}`, "USAGE");
flags.add(arg);
continue;
}
throw cliError(`Unknown option: ${arg}`, "USAGE");
}
return { flags, values, positionals };
}
function parseModuleList(value) {
if (value === "") return [];
const modules = value.split(",").map((entry) => entry.trim());
if (modules.some((entry) => !entry)) throw cliError("--modules must be a comma-separated list", "USAGE");
return modules;
}
function parseFlags(args, allowed) {
const flags = new Set();
const values = new Map();
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!allowed.has(arg)) throw cliError(`Unknown option: ${arg}`, "USAGE");
if (["--source", "--version", "--url", "--older-than-hours"].includes(arg)) {
const value = args[++index];
if (value == null || value.startsWith("--")) throw cliError(`${arg} requires a value`, "USAGE");
values.set(arg, value);
} else flags.add(arg);
}
return { flags, values };
}
function rejectUnknown(args, allowed) {
for (const arg of args) if (!allowed.includes(arg)) throw cliError(`Unknown option: ${arg}`, "USAGE");
}
async function askLine(input, output, prompt) {
const interface_ = readline.createInterface({ input, output, terminal: true });
try {
return await interface_.question(prompt);
} finally {
interface_.close();
}
}
async function readHidden(input, output, prompt) {
if (!input.isTTY || typeof input.setRawMode !== "function") return (await readStream(input)).trim();
write(output, prompt);
input.setRawMode(true);
input.resume();
return new Promise((resolve, reject) => {
let value = "";
const cleanup = () => {
input.off("data", onData);
input.setRawMode(false);
input.pause();
write(output, "\n");
};
const onData = (chunk) => {
const text = chunk.toString("utf8");
for (const character of text) {
if (character === "\u0003") {
cleanup();
reject(cliError("Context.dev setup cancelled", "CANCELLED"));
return;
}
if (character === "\r" || character === "\n") {
cleanup();
resolve(value.trim());
return;
}
if (character === "\u007f" || character === "\b") value = value.slice(0, -1);
else value += character;
}
};
input.on("data", onData);
});
}
async function readStream(stream) {
let value = "";
for await (const chunk of stream) value += chunk.toString("utf8");
return value;
}
function write(stream, value) {
stream.write(value);
}
function cliError(message, code) {
const error = new Error(message);
error.code = code;
return error;
}
function exitCodeFor(error) {
if (error?.code === "USAGE") return 2;
if (["CONTEXT_KEY_MISSING", "CONTEXT_KEY_INVALID", "CONTEXT_EMAIL_UNVERIFIED", "CONTEXT_CREDITS_EXHAUSTED", "CONTEXT_RATE_LIMITED", "CONTEXT_TIMEOUT", "CONTEXT_BLOCKED", "CONTEXT_BAD_RESPONSE"].includes(error?.code)) return 3;
return 1;
}
function redactSecrets(message) {
return redactSensitiveText(message);
}
function usage() {
return `gstack ${RUNTIME_VERSION}\n\n` +
"Usage:\n" +
" gstack setup\n" +
" gstack doctor [--json]\n" +
" gstack paths [--json|--shell]\n" +
" gstack runtime path <bundle-relative-path>\n" +
" gstack config get [key]\n" +
" gstack config set <key> <value>\n" +
" gstack state inspect [run-id]\n" +
" gstack state begin <workflow> [--run-id <id>] [--goal <goal>] [--plan <pointer>] [--stage <stage>] [--depth quick|standard|deep] [--mutation <authority>] [--modules <a,b>]\n" +
" gstack state update <run-id> [--plan <pointer>|--clear-plan] [--stage <stage>] [--depth quick|standard|deep] [--mutation <authority>] [--modules <a,b>] [--push-detour <goal>|--pop-detour]\n" +
" [--evidence-freshness unknown|fresh|stale] [--evidence-source <source> --evidence-reference <reference> [--evidence-captured-at <ISO>]]\n" +
" [--add-approval <id> --approval-summary <summary>|--resolve-approval <id>]\n" +
" gstack state effect <run-id> <effect-key> -- <executable> [args...]\n" +
" gstack state resume [run-id]\n" +
" gstack state reconcile-applied <run-id> <effect-key> --confirm-applied --evidence <reference>\n" +
" gstack state reconcile-not-applied <run-id> <effect-key> --confirm-not-applied\n" +
" gstack state complete <run-id>\n" +
" gstack context status\n" +
" gstack context options\n" +
" gstack context select host|local-browser|none\n" +
" gstack context setup [--consent] # key from hidden stdin or env\n" +
" gstack context smoke [--url <public-url>]\n" +
" gstack cleanup [--dry-run] [--older-than-hours N]\n" +
" gstack upgrade --source <complete-gstack-package> --version <version> | --rollback\n" +
" gstack uninstall [--purge --yes]\n";
}
+216
View File
@@ -0,0 +1,216 @@
import fs from "node:fs/promises";
import path from "node:path";
import { atomicWriteJson, readJson, withLock } from "./storage.js";
import { resolveRuntimePaths } from "./paths.js";
export const DEFAULT_CONFIG = Object.freeze({
schemaVersion: 2,
network: Object.freeze({ mode: "off", consent: false, selection: null }),
context: Object.freeze({ baseUrl: "https://api.context.dev/v1" }),
cleanup: Object.freeze({ retentionDays: 30 }),
});
const FORBIDDEN_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
const COHERENT_NETWORK_CHOICES = new Set([
"context:true:context",
"host:false:host",
"local-browser:false:local-browser",
"off:false:none",
]);
export async function ensureConfig(home) {
const paths = resolveRuntimePaths({ home });
await fs.mkdir(home, { recursive: true, mode: 0o700 });
return withLock(path.join(paths.locks, "config.lock"), async () => {
let config = await readJson(paths.config, null);
if (!config) {
config = mergeDefaults(await readLegacyConfig(home));
validateConfig(config);
await atomicWriteJson(paths.config, config, { mode: 0o644 });
}
let secrets = await readJson(paths.secrets, null);
if (!secrets) {
secrets = { schemaVersion: 2, context: {} };
await atomicWriteJson(paths.secrets, secrets, { mode: 0o600 });
} else {
await fs.chmod(paths.secrets, 0o600);
}
return { config, secrets };
});
}
/** Read-only migration input. config.json remains the sole write authority. */
export async function readLegacyConfig(home) {
const legacyPath = path.join(home, "config.yaml");
const content = await fs.readFile(legacyPath, "utf8").catch((error) => {
if (error?.code === "ENOENT") return "";
throw error;
});
const result = {};
for (const line of content.split(/\r?\n/)) {
const match = line.match(/^([A-Za-z0-9_]+(?:@[a-f0-9]+)?):\s*(.*?)\s*(?:#.*)?$/);
if (!match) continue;
const raw = unquoteLegacyScalar(match[2]);
result[match[1]] = parseConfigValue(raw);
}
return result;
}
function unquoteLegacyScalar(value) {
if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")))) return value.slice(1, -1);
return value;
}
export async function loadConfig(home) {
const paths = resolveRuntimePaths({ home });
const stored = await readJson(paths.config, null);
return stored ? mergeDefaults(stored) : cloneDefaultConfig();
}
export async function loadSecrets(home, options = {}) {
const paths = resolveRuntimePaths({ home });
try {
const stat = await fs.stat(paths.secrets);
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) {
if (options.repairPermissions) await fs.chmod(paths.secrets, 0o600);
else {
const error = new Error(`Secrets file permissions must be 0600: ${paths.secrets}`);
error.code = "INSECURE_SECRETS";
throw error;
}
}
return await readJson(paths.secrets, { schemaVersion: 2, context: {} });
} catch (error) {
if (error?.code === "ENOENT") return { schemaVersion: 2, context: {} };
throw error;
}
}
export async function configGet(home, key) {
const config = await loadConfig(home);
if (!key) return config;
return getPath(config, key);
}
export async function configSet(home, key, value) {
if (!key) throw new TypeError("A config key is required");
if (looksLikeSecretKey(key)) {
const error = new Error("Secrets cannot be stored in config.json; use `gstack context setup`");
error.code = "SECRET_IN_CONFIG";
throw error;
}
return updateConfig(home, (config) => {
setPath(config, key, value);
return getPath(config, key);
});
}
/** Persist the complete network choice in one locked atomic replacement. */
export async function configSetNetworkChoice(home, choice) {
const keys = Object.keys(choice ?? {}).sort();
if (keys.join(",") !== "consent,mode,selection") {
throw new TypeError("A network choice requires mode, consent, and selection");
}
const signature = `${choice.mode}:${choice.consent}:${choice.selection}`;
if (!COHERENT_NETWORK_CHOICES.has(signature)) {
throw new TypeError("Network mode, consent, and selection must describe one coherent choice");
}
return updateConfig(home, (config) => {
config.network = { ...config.network, ...choice };
return { ...config.network };
});
}
async function updateConfig(home, mutate) {
const paths = resolveRuntimePaths({ home });
return withLock(path.join(paths.locks, "config.lock"), async () => {
const config = mergeDefaults(await readJson(paths.config, cloneDefaultConfig()));
const result = mutate(config);
validateConfig(config);
await atomicWriteJson(paths.config, config, { mode: 0o644 });
return result;
});
}
function looksLikeSecretKey(key) {
const normalized = String(key).replace(/([a-z0-9])([A-Z])/g, "$1.$2");
return normalized.split(/[.\-_]/).some((segment) =>
/^(key|apikey|api.?key|secret|token|jwt|session|cookie|access.?token|refresh.?token|password|passwd|credential|credentials|authorization|bearer)$/i.test(segment),
) || /api[._-]?key|access[._-]?token|refresh[._-]?token/i.test(String(key));
}
export async function secretSet(home, key, value) {
if (typeof value !== "string" || value.length === 0) throw new TypeError("Secret value is required");
const paths = resolveRuntimePaths({ home });
return withLock(path.join(paths.locks, "config.lock"), async () => {
const secrets = await readJson(paths.secrets, { schemaVersion: 2, context: {} });
setPath(secrets, key, value);
await atomicWriteJson(paths.secrets, secrets, { mode: 0o600 });
});
}
export function parseConfigValue(raw) {
if (typeof raw !== "string") return raw;
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
export function getPath(object, dotted) {
return splitKey(dotted).reduce((value, segment) => value?.[segment], object);
}
export function setPath(object, dotted, value) {
const parts = splitKey(dotted);
let cursor = object;
for (const segment of parts.slice(0, -1)) {
if (!cursor[segment] || typeof cursor[segment] !== "object" || Array.isArray(cursor[segment])) {
cursor[segment] = {};
}
cursor = cursor[segment];
}
cursor[parts.at(-1)] = value;
}
function splitKey(dotted) {
if (typeof dotted !== "string" || !dotted) throw new TypeError("Config key is required");
const parts = dotted.split(".");
if (parts.some((part) => !part || FORBIDDEN_SEGMENTS.has(part))) throw new TypeError("Invalid config key");
return parts;
}
function validateConfig(config) {
if (config.network?.mode != null && !["off", "context", "host", "local-browser"].includes(config.network.mode)) {
throw new TypeError("network.mode must be `off`, `context`, `host`, or `local-browser`");
}
if (config.network?.consent != null && typeof config.network.consent !== "boolean") {
throw new TypeError("network.consent must be a boolean");
}
if (config.network?.selection != null && !["context", "host", "local-browser", "none"].includes(config.network.selection)) {
throw new TypeError("network.selection must be `context`, `host`, `local-browser`, `none`, or null");
}
if (config.context?.baseUrl != null) {
const url = new URL(config.context.baseUrl);
if (url.origin !== "https://api.context.dev" || !["/v1", "/v1/"].includes(url.pathname) ||
url.search || url.hash || url.username || url.password) {
throw new TypeError("context.baseUrl must be the official credential-free Context.dev v1 HTTPS endpoint");
}
}
}
function cloneDefaultConfig() {
return JSON.parse(JSON.stringify(DEFAULT_CONFIG));
}
function mergeDefaults(stored) {
return {
...cloneDefaultConfig(),
...stored,
network: { ...DEFAULT_CONFIG.network, ...(stored.network ?? {}) },
context: { ...DEFAULT_CONFIG.context, ...(stored.context ?? {}) },
cleanup: { ...DEFAULT_CONFIG.cleanup, ...(stored.cleanup ?? {}) },
};
}
+830
View File
@@ -0,0 +1,830 @@
import net from "node:net";
import dns from "node:dns/promises";
import { loadConfig, loadSecrets } from "./config.js";
export const CONTEXT_FAILURES = Object.freeze([
"CONTEXT_KEY_MISSING",
"CONTEXT_KEY_INVALID",
"CONTEXT_EMAIL_UNVERIFIED",
"CONTEXT_CREDITS_EXHAUSTED",
"CONTEXT_RATE_LIMITED",
"CONTEXT_TIMEOUT",
"CONTEXT_BLOCKED",
"CONTEXT_BAD_RESPONSE",
]);
const CONTEXT_FAILURE_SET = new Set(CONTEXT_FAILURES);
const OFFICIAL_BASE_URL = "https://api.context.dev/v1";
const PREFIXED_CREDENTIAL = /(?:^|[^A-Za-z0-9])(?:AIza[0-9A-Za-z_-]{20,}|AKIA[0-9A-Z]{16}|(?:ctxt|github_pat|gh[pousr]|sk|pk|rk|xox[aboprs])[-_][A-Za-z0-9._~-]{10,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})(?:$|[^A-Za-z0-9])/i;
// `/` is a public URL/path separator, not part of one opaque candidate. Each
// path segment is still scanned independently, avoiding false positives where
// a long mixed-case documentation path looked like one credential.
const OPAQUE_TOKEN_CANDIDATE = /[A-Za-z0-9._~+=-]{32,}/g;
const MAX_CREDENTIAL_DECODE_PASSES = 8;
const MIN_OPAQUE_TOKEN_ENTROPY = 4.25;
const BOOLEAN_OPTION = "boolean";
const INTEGER_OPTION = "integer";
const STRING_OPTION = "string";
const STRING_ARRAY_OPTION = "string-array";
const PDF_OPTIONS = Object.freeze({
shouldParse: BOOLEAN_OPTION,
start: INTEGER_OPTION,
end: INTEGER_OPTION,
ocr: BOOLEAN_OPTION,
});
const VIEWPORT_OPTIONS = Object.freeze({
width: INTEGER_OPTION,
height: INTEGER_OPTION,
});
// Context.dev also documents free-form outbound headers and request tags. They
// are deliberately excluded: either can carry authentication or unrelated
// private text. Only the public extraction controls below may cross the boundary.
const PUBLIC_OPTION_SCHEMAS = Object.freeze({
scrapeMarkdown: Object.freeze({
includeLinks: BOOLEAN_OPTION,
includeImages: BOOLEAN_OPTION,
shortenBase64Images: BOOLEAN_OPTION,
useMainContentOnly: BOOLEAN_OPTION,
pdf: Object.freeze({ object: PDF_OPTIONS }),
includeFrames: BOOLEAN_OPTION,
includeSelectors: STRING_ARRAY_OPTION,
excludeSelectors: STRING_ARRAY_OPTION,
maxAgeMs: INTEGER_OPTION,
waitForMs: INTEGER_OPTION,
settleAnimations: BOOLEAN_OPTION,
country: STRING_OPTION,
timeoutMS: INTEGER_OPTION,
}),
scrapeHtml: Object.freeze({
pdf: Object.freeze({ object: PDF_OPTIONS }),
includeFrames: BOOLEAN_OPTION,
useMainContentOnly: BOOLEAN_OPTION,
includeSelectors: STRING_ARRAY_OPTION,
excludeSelectors: STRING_ARRAY_OPTION,
maxAgeMs: INTEGER_OPTION,
waitForMs: INTEGER_OPTION,
settleAnimations: BOOLEAN_OPTION,
country: STRING_OPTION,
timeoutMS: INTEGER_OPTION,
}),
crawl: Object.freeze({
maxPages: INTEGER_OPTION,
maxDepth: INTEGER_OPTION,
urlRegex: STRING_OPTION,
includeLinks: BOOLEAN_OPTION,
includeImages: BOOLEAN_OPTION,
shortenBase64Images: BOOLEAN_OPTION,
useMainContentOnly: BOOLEAN_OPTION,
followSubdomains: BOOLEAN_OPTION,
pdf: Object.freeze({ object: PDF_OPTIONS }),
includeFrames: BOOLEAN_OPTION,
includeSelectors: STRING_ARRAY_OPTION,
excludeSelectors: STRING_ARRAY_OPTION,
maxAgeMs: INTEGER_OPTION,
waitForMs: INTEGER_OPTION,
settleAnimations: BOOLEAN_OPTION,
stopAfterMs: INTEGER_OPTION,
country: STRING_OPTION,
timeoutMS: INTEGER_OPTION,
}),
sitemap: Object.freeze({
maxLinks: INTEGER_OPTION,
sitemapUrl: STRING_OPTION,
urlRegex: STRING_OPTION,
timeoutMS: INTEGER_OPTION,
}),
screenshot: Object.freeze({
domain: STRING_OPTION,
directUrl: STRING_OPTION,
fullScreenshot: BOOLEAN_OPTION,
page: Object.freeze({ enum: Object.freeze(["login", "signup", "blog", "careers", "pricing", "terms", "privacy", "contact"]) }),
waitForMs: INTEGER_OPTION,
viewport: Object.freeze({ object: VIEWPORT_OPTIONS }),
handleCookiePopup: BOOLEAN_OPTION,
colorScheme: Object.freeze({ enum: Object.freeze(["light", "dark"]) }),
scrollOffset: INTEGER_OPTION,
maxAgeMs: INTEGER_OPTION,
country: STRING_OPTION,
timeoutMS: INTEGER_OPTION,
}),
});
export class ContextError extends Error {
constructor(code, message, options = {}) {
if (!CONTEXT_FAILURE_SET.has(code)) throw new TypeError(`Unknown Context.dev failure code: ${code}`);
const secrets = options.secrets ?? [];
const safeCause = sanitizeErrorCause(options.cause, secrets);
super(redactSensitiveText(message, secrets), safeCause ? { cause: safeCause } : undefined);
this.name = "ContextError";
this.code = code;
this.status = options.status;
this.retryAfter = options.retryAfter;
this.details = redactSensitiveValue(options.details, secrets);
this.unsupported = Boolean(options.unsupported);
}
toJSON() {
return {
name: this.name,
code: this.code,
message: this.message,
...(this.status == null ? {} : { status: this.status }),
...(this.retryAfter == null ? {} : { retryAfter: this.retryAfter }),
...(this.unsupported ? { unsupported: true } : {}),
};
}
}
export async function readContextKey(options = {}) {
const env = options.env ?? process.env;
const fromEnv = env.CONTEXT_DEV_API_KEY || env.CONTEXT_API_KEY;
if (fromEnv?.trim()) return { key: fromEnv.trim(), source: "environment" };
const home = options.home;
if (!home) throw new ContextError("CONTEXT_KEY_MISSING", "Context.dev API key is not configured");
const secrets = await loadSecrets(home);
const key = secrets?.context?.apiKey;
if (!key) throw new ContextError("CONTEXT_KEY_MISSING", "Context.dev API key is not configured");
return { key: String(key).trim(), source: "secrets.json" };
}
export function validateContextKey(key) {
if (typeof key !== "string" || !key) {
throw new ContextError("CONTEXT_KEY_MISSING", "Context.dev API key is missing");
}
// Do not bake in a provider prefix: Context.dev may rotate key formats.
// Whitespace is never valid in a bearer token; a small length floor catches
// accidental empty/placeholder values without rejecting a future prefix.
if (key.length < 12 || /\s/.test(key)) {
throw new ContextError("CONTEXT_KEY_INVALID", "Context.dev API key has an invalid format");
}
return key;
}
/**
* Lexically validate a URL before any DNS lookup or HTTP request occurs.
*/
export function assertPublicUrl(input) {
let url;
try {
url = input instanceof URL ? new URL(input.href) : new URL(String(input));
} catch (cause) {
throw new ContextError("CONTEXT_BLOCKED", "Target must be an absolute public HTTP(S) URL", { cause });
}
if (!["http:", "https:"].includes(url.protocol)) {
throw new ContextError("CONTEXT_BLOCKED", "Only HTTP and HTTPS target URLs are allowed");
}
if (url.username || url.password) {
throw new ContextError("CONTEXT_BLOCKED", "Target URLs must not contain credentials");
}
assertNoCredentialMaterial(decodeUrlComponent(url.pathname), "URL path");
for (const [key, value] of url.searchParams) {
if (isSensitiveFieldName(key) || containsCredentialLabel(key)) {
throw new ContextError("CONTEXT_BLOCKED", `Target URL contains credential-like query parameter: ${key}`);
}
assertNoCredentialMaterial(key, "URL query parameter name");
assertNoCredentialMaterial(value, "URL query value");
}
const fragment = decodeUrlFragment(url.hash.slice(1));
assertNoCredentialMaterial(fragment, "URL fragment");
for (const part of fragment.split(/[?&;]/)) {
const separator = part.indexOf("=");
if (separator === -1) continue;
const key = part.slice(0, separator).trim();
if (isSensitiveFieldName(key) || containsCredentialLabel(key)) {
throw new ContextError("CONTEXT_BLOCKED", `Target URL contains credential-like fragment parameter: ${key}`);
}
assertNoCredentialMaterial(part.slice(separator + 1), "URL fragment value");
}
assertPublicHostname(url.hostname);
return url;
}
export function assertPublicHostname(input) {
const hostname = String(input).replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
if (!hostname || hostname.includes("\0")) {
throw new ContextError("CONTEXT_BLOCKED", "Target hostname is missing or invalid");
}
const ipVersion = net.isIP(hostname);
if (ipVersion) {
if (!isPublicIp(hostname)) throw new ContextError("CONTEXT_BLOCKED", "Target IP address is not public");
return hostname;
}
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
throw new ContextError("CONTEXT_BLOCKED", "Localhost targets are not allowed");
}
const forbiddenSuffixes = [
".local", ".internal", ".intranet", ".lan", ".home", ".home.arpa",
".localdomain", ".corp", ".private", ".test", ".invalid", ".example",
];
if (!hostname.includes(".") || forbiddenSuffixes.some((suffix) => hostname.endsWith(suffix))) {
throw new ContextError("CONTEXT_BLOCKED", "Private or non-public hostnames are not allowed");
}
if (/^(metadata|instance-data)(\.|$)/.test(hostname) || hostname === "metadata.google.internal") {
throw new ContextError("CONTEXT_BLOCKED", "Cloud metadata hostnames are not allowed");
}
return hostname;
}
export function isPublicIp(input) {
const address = String(input).replace(/^\[|\]$/g, "");
const version = net.isIP(address);
if (version === 4) return isPublicIpv4(address);
if (version === 6) return isPublicIpv6(address);
return false;
}
export async function assertPublicUrlResolved(input, options = {}) {
const url = assertPublicUrl(input);
const hostname = url.hostname.replace(/^\[|\]$/g, "");
if (net.isIP(hostname)) return url;
const lookup = options.lookup ?? dns.lookup;
let records;
try {
records = await lookup(hostname, { all: true, verbatim: true });
} catch (cause) {
throw new ContextError("CONTEXT_BLOCKED", "Target hostname could not be resolved publicly", { cause });
}
const list = Array.isArray(records) ? records : [records];
if (!list.length || list.some((record) => !isPublicIp(record?.address ?? record))) {
throw new ContextError("CONTEXT_BLOCKED", "Target hostname resolves to a non-public address");
}
return url;
}
export function mapContextFailure(status, payload = {}, cause, headers, options = {}) {
const secrets = options.secrets ?? [];
const safeCause = sanitizeErrorCause(cause, secrets);
if (cause?.name === "AbortError" || cause?.code === "ABORT_ERR" || cause?.code === "ETIMEDOUT") {
return new ContextError("CONTEXT_TIMEOUT", "Context.dev request timed out", { status, cause: safeCause, secrets });
}
const apiCode = String(payload?.error_code ?? payload?.code ?? "").toUpperCase();
const rawMessage = String(payload?.message ?? payload?.error ?? "Context.dev request failed");
const searchable = `${apiCode} ${rawMessage}`.toLowerCase();
const message = redactSensitiveText(rawMessage, secrets);
const details = payload && typeof payload === "object" ? redactSensitiveValue(payload, secrets) : undefined;
if (status === 429 || apiCode === "RATE_LIMITED") {
const retryAfter = getHeader(headers, "retry-after");
return new ContextError("CONTEXT_RATE_LIMITED", message, { status, retryAfter, cause: safeCause, details, secrets });
}
if (status === 408 || apiCode === "REQUEST_TIMEOUT" || apiCode === "TIMEOUT_EXCEEDS_MAXIMUM") {
return new ContextError("CONTEXT_TIMEOUT", message, { status, cause: safeCause, details, secrets });
}
if (apiCode === "USAGE_EXCEEDED" || /credits?\s*(exhausted|exceeded|remaining\s*[:=]?\s*0)|usage\s*(limit|exceeded)|quota/.test(searchable)) {
return new ContextError("CONTEXT_CREDITS_EXHAUSTED", message, { status, cause: safeCause, details, secrets });
}
if (/email.*(unverified|not verified|verify)|verify.*email/.test(searchable)) {
return new ContextError("CONTEXT_EMAIL_UNVERIFIED", message, { status, cause: safeCause, details, secrets });
}
if (apiCode === "WEBSITE_ACCESS_ERROR" || apiCode === "EXTERNAL_PROVIDER_ERROR" ||
/blocked|private address|localhost|link.local|website access|hostile waf/.test(searchable)) {
return new ContextError("CONTEXT_BLOCKED", message, { status, cause: safeCause, details, secrets });
}
if (status === 401 || ["UNAUTHORIZED", "DISABLED", "INSUFFICIENT_PERMISSIONS", "FORBIDDEN"].includes(apiCode) || status === 403) {
return new ContextError("CONTEXT_KEY_INVALID", message, { status, cause: safeCause, details, secrets });
}
return new ContextError("CONTEXT_BAD_RESPONSE", message, { status, cause: safeCause, details, secrets });
}
export class ContextClient {
constructor(options = {}) {
this.home = options.home;
this.env = options.env ?? process.env;
this.fetch = options.fetch ?? globalThis.fetch;
this.lookup = options.lookup ?? dns.lookup;
this.resolveDns = options.resolveDns ?? true;
this.config = options.config;
this.key = options.key;
this.timeoutMs = options.timeoutMs ?? 90_000;
this.baseUrl = options.baseUrl;
}
async scrapeMarkdown(url, options = {}) {
const publicOptions = assertPublicRequestOptions("scrapeMarkdown", options);
const target = String(url);
await this.#gateTarget(target);
return this.#request("GET", "/web/scrape/markdown", { query: { ...publicOptions, url: target } });
}
async scrapeHtml(url, options = {}) {
const publicOptions = assertPublicRequestOptions("scrapeHtml", options);
const target = String(url);
await this.#gateTarget(target);
return this.#request("GET", "/web/scrape/html", { query: { ...publicOptions, url: target } });
}
async crawl(url, options = {}) {
const publicOptions = assertPublicRequestOptions("crawl", options);
const target = String(url);
await this.#gateTarget(target);
return this.#request("POST", "/web/crawl", { body: { ...publicOptions, url: target } });
}
async sitemap(domain, options = {}) {
const publicOptions = assertPublicRequestOptions("sitemap", options);
const normalized = normalizeDomain(domain);
await this.#gateTarget(`https://${normalized}`);
if (publicOptions.sitemapUrl) await this.#gateTarget(publicOptions.sitemapUrl);
return this.#request("GET", "/web/scrape/sitemap", { query: { ...publicOptions, domain: normalized } });
}
async screenshot(target, options = {}) {
let query;
if (target && typeof target === "object" && !(target instanceof URL)) {
query = { ...target, ...options };
} else {
const serialized = String(target);
query = /^https?:\/\//i.test(serialized)
? { directUrl: serialized, ...options }
: { domain: serialized, ...options };
}
query = assertPublicRequestOptions("screenshot", query);
if (query.directUrl && query.domain) {
throw new ContextError("CONTEXT_BAD_RESPONSE", "Screenshot accepts either domain or directUrl, not both");
}
if (query.directUrl) await this.#gateTarget(query.directUrl);
else {
query.domain = normalizeDomain(query.domain);
await this.#gateTarget(`https://${query.domain}`);
}
return this.#request("GET", "/screenshot", { query });
}
async search() {
throw new ContextError(
"CONTEXT_BAD_RESPONSE",
"Context.dev Search API is deprecated and is intentionally unsupported",
{ unsupported: true },
);
}
async #gateTarget(input) {
// The lexical gate is intentionally first and performs no I/O. DNS is only
// reached after #networkSettings confirms explicit persisted consent.
const url = assertPublicUrl(input);
const settings = await this.#networkSettings();
if (!settings.enabled) {
throw new ContextError("CONTEXT_BLOCKED", "Network access is off; run `gstack context setup --consent`");
}
if (this.resolveDns) {
try {
await withTimeout(assertPublicUrlResolved(url, { lookup: this.lookup }), this.timeoutMs);
} catch (cause) {
if (cause instanceof ContextError) throw cause;
throw mapContextFailure(undefined, {}, cause);
}
}
return url;
}
async #networkSettings() {
const config = this.config ?? await loadConfig(this.home);
return {
enabled: hasContextNetworkConsent(config),
config,
};
}
async #apiKey() {
if (this.key) return validateContextKey(String(this.key));
return validateContextKey((await readContextKey({ home: this.home, env: this.env })).key);
}
async #request(method, endpoint, options = {}) {
if (typeof this.fetch !== "function") {
throw new ContextError("CONTEXT_BAD_RESPONSE", "This Node runtime does not provide fetch()");
}
const { config, enabled } = await this.#networkSettings();
if (!enabled) {
throw new ContextError("CONTEXT_BLOCKED", "Network access is off; explicit Context.dev consent is required");
}
const configuredBase = this.baseUrl ?? config?.context?.baseUrl ?? OFFICIAL_BASE_URL;
const baseUrl = validateBaseUrl(configuredBase);
const key = await this.#apiKey();
if (!key) throw new ContextError("CONTEXT_KEY_MISSING", "Context.dev API key is not configured");
const requestUrl = new URL(`${baseUrl.replace(/\/$/, "")}${endpoint}`);
addQuery(requestUrl.searchParams, options.query ?? {});
const controller = new AbortController();
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
const timeout = setTimeout(() => controller.abort(), timeoutMs);
timeout.unref?.();
let response;
try {
try {
response = await raceWithAbort(this.fetch(requestUrl, {
method,
headers: {
Accept: "application/json",
Authorization: `Bearer ${key}`,
...(options.body ? { "Content-Type": "application/json" } : {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
signal: controller.signal,
redirect: "error",
}), controller.signal);
} catch (cause) {
if (cause instanceof ContextError) throw cause;
throw mapContextFailure(undefined, {}, cause, undefined, { secrets: [key] });
}
let text;
try {
text = await raceWithAbort(response.text(), controller.signal);
} catch (cause) {
throw mapContextFailure(response.status, {}, cause, response.headers, { secrets: [key] });
}
let payload;
try {
payload = text ? JSON.parse(text) : null;
} catch (cause) {
throw new ContextError("CONTEXT_BAD_RESPONSE", "Context.dev returned malformed JSON", {
status: response.status,
cause,
secrets: [key],
});
}
if (!response.ok) {
throw mapContextFailure(response.status, payload, undefined, response.headers, { secrets: [key] });
}
if (!payload || typeof payload !== "object") {
throw new ContextError("CONTEXT_BAD_RESPONSE", "Context.dev returned an empty or invalid response", {
status: response.status,
secrets: [key],
});
}
return payload;
} finally {
clearTimeout(timeout);
}
}
}
export function assertPublicRequestOptions(endpoint, options) {
const schema = PUBLIC_OPTION_SCHEMAS[endpoint];
if (!schema) throw new TypeError(`Unknown Context.dev option schema: ${endpoint}`);
return copyOptionObject(options, schema, endpoint);
}
function copyOptionObject(value, schema, trail) {
if (!isPlainObject(value)) {
throw new ContextError("CONTEXT_BLOCKED", `Context.dev ${trail} options must be a plain public-data object`);
}
const copy = {};
for (const [key, child] of Object.entries(value)) {
if (!Object.hasOwn(schema, key)) {
throw new ContextError("CONTEXT_BLOCKED", `Context.dev request option is not allowlisted: ${trail}.${key}`);
}
copy[key] = copyOptionValue(child, schema[key], `${trail}.${key}`);
}
return copy;
}
function copyOptionValue(value, schema, trail) {
if (value == null) return value;
if (schema === BOOLEAN_OPTION) {
if (typeof value === "boolean" || value === "true" || value === "false") return value;
} else if (schema === INTEGER_OPTION) {
if (Number.isSafeInteger(value)) return value;
} else if (schema === STRING_OPTION) {
if (typeof value === "string" && !value.includes("\0")) {
assertNoCredentialMaterial(value, trail);
return value;
}
} else if (schema === STRING_ARRAY_OPTION) {
if (Array.isArray(value) && value.every((item) => typeof item === "string" && !item.includes("\0"))) {
for (const item of value) assertNoCredentialMaterial(item, trail);
return [...value];
}
} else if (schema?.object) {
return copyOptionObject(value, schema.object, trail);
} else if (schema?.enum) {
if (schema.enum.includes(value)) return value;
}
throw new ContextError("CONTEXT_BLOCKED", `Context.dev request option has a disallowed shape: ${trail}`);
}
function isPlainObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function hasContextNetworkConsent(config) {
return config?.network?.selection === "context" &&
config?.network?.mode === "context" &&
config?.network?.consent === true;
}
export async function contextStatus(home, env = process.env) {
const config = await loadConfig(home);
let keySource = null;
try {
keySource = (await readContextKey({ home, env })).source;
} catch (error) {
if (error?.code !== "CONTEXT_KEY_MISSING") throw error;
}
const contextReady = Boolean(keySource) && hasContextNetworkConsent(config);
return {
configured: Boolean(keySource),
keySource,
networkMode: config.network.mode,
selection: config.network.selection,
consent: config.network.consent === true,
contextReady,
ready: config.network.selection === "context"
? contextReady
: ["host", "local-browser", "none"].includes(config.network.selection),
needsChoice: config.network.selection == null,
};
}
function normalizeDomain(input) {
const raw = String(input ?? "").trim();
if (!raw) throw new ContextError("CONTEXT_BLOCKED", "A public domain is required");
const url = assertPublicUrl(raw.includes("://") ? raw : `https://${raw}`);
if (url.pathname !== "/" || url.search || url.hash || url.port) {
throw new ContextError("CONTEXT_BLOCKED", "Expected a bare public domain");
}
return url.hostname.replace(/^\[|\]$/g, "");
}
function decodeUrlFragment(fragment) {
try {
return decodeURIComponent(fragment);
} catch {
return fragment;
}
}
function validateBaseUrl(input) {
let url;
try {
url = new URL(String(input));
} catch (cause) {
throw new ContextError("CONTEXT_BAD_RESPONSE", "Invalid Context.dev API base URL", { cause });
}
if (url.origin !== "https://api.context.dev" || url.username || url.password ||
!["/v1", "/v1/"].includes(url.pathname) || url.search || url.hash) {
throw new ContextError("CONTEXT_BAD_RESPONSE", "Refusing to send a Context.dev key to a non-official API host");
}
return OFFICIAL_BASE_URL;
}
export function redactSensitiveText(message, knownSecrets = []) {
let safe = String(message ?? "");
const exactSecrets = new Set();
for (const secret of knownSecrets) {
const raw = String(secret ?? "");
if (raw.length < 8) continue;
exactSecrets.add(raw);
try {
exactSecrets.add(encodeURIComponent(raw));
} catch {}
}
for (const secret of exactSecrets) safe = safe.split(secret).join("[REDACTED]");
safe = safe.replace(/(\bAuthorization\s*[:=]\s*)[^\r\n,}]+/gi, "$1[REDACTED]");
safe = safe.replace(/(\b(?:Bearer|Basic)\s+)[A-Za-z0-9._~+/=-]{8,}/gi, "$1[REDACTED]");
safe = safe.replace(/((?:[?&#]|\b)(?:access_?token|api_?key|auth(?:orization)?|client_?secret|credential|id_?token|jwt|oauth_?token|password|refresh_?token|secret|session|signature|token)=)[^&#\s,}]+/gi, "$1[REDACTED]");
safe = safe.replace(/(\b(?:access[_-]?token|api[_-]?key|auth(?:orization)?|client[_-]?secret|credential|id[_-]?token|jwt|oauth[_-]?token|password|refresh[_-]?token|secret|session|signature|token)\s*[:=]\s*["']?)[^\s,"'&}]+/gi, "$1[REDACTED]");
safe = safe.replace(/[A-Za-z0-9._~+/=-]{32,}/g, (candidate) =>
looksOpaqueCredential(candidate) ? "[REDACTED]" : candidate);
return safe;
}
function redactSensitiveValue(value, knownSecrets = [], seen = new WeakSet()) {
if (typeof value === "string") return redactSensitiveText(value, knownSecrets);
if (!value || typeof value !== "object") return value;
if (seen.has(value)) return "[REDACTED CYCLE]";
seen.add(value);
if (Array.isArray(value)) return value.map((item) => redactSensitiveValue(item, knownSecrets, seen));
const copy = {};
for (const [key, child] of Object.entries(value)) {
copy[key] = isSensitiveFieldName(key)
? "[REDACTED]"
: redactSensitiveValue(child, knownSecrets, seen);
}
return copy;
}
function sanitizeErrorCause(cause, knownSecrets = []) {
if (!cause) return undefined;
const safe = new Error(redactSensitiveText(cause?.message ?? String(cause), knownSecrets));
safe.name = String(cause?.name ?? "Error");
if (cause?.code != null) safe.code = cause.code;
return safe;
}
function assertNoCredentialMaterial(value, location) {
let candidate = String(value ?? "");
for (let pass = 0; pass < MAX_CREDENTIAL_DECODE_PASSES; pass += 1) {
if (containsCredentialMaterial(candidate)) {
throw new ContextError("CONTEXT_BLOCKED", `Context.dev ${location} contains secret-shaped data`);
}
const decoded = decodeUrlComponent(candidate);
if (decoded === candidate) return;
candidate = decoded;
}
// A value that remains encoded after the inspection cap could hide a token
// at arbitrary depth. Fail closed rather than forwarding residual encoding.
throw new ContextError("CONTEXT_BLOCKED", `Context.dev ${location} uses excessive nested URL encoding`);
}
function containsCredentialMaterial(value) {
const text = String(value ?? "");
if (!text) return false;
if (PREFIXED_CREDENTIAL.test(text)) return true;
for (const candidate of text.match(OPAQUE_TOKEN_CANDIDATE) ?? []) {
if (looksOpaqueCredential(candidate)) return true;
}
return false;
}
function looksOpaqueCredential(candidate) {
const token = candidate.replace(/[.,;:!?]+$/, "");
if (token.length < 32 || /^[a-f0-9]{32,}$/i.test(token) || isUuid(token) || isReadablePublicSlug(token)) return false;
const categories = [/[a-z]/, /[A-Z]/, /\d/, /[._~+/=-]/]
.reduce((count, pattern) => count + Number(pattern.test(token)), 0);
return categories >= 3 && shannonEntropy(token) >= MIN_OPAQUE_TOKEN_ENTROPY;
}
function isUuid(value) {
return /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(value);
}
function isReadablePublicSlug(value) {
const parts = value.split("-");
if (parts.length < 3) return false;
let wordParts = 0;
for (const part of parts) {
if (/^[A-Za-z]{1,24}$/.test(part)) {
wordParts += 1;
continue;
}
if (/^\d{1,8}$/.test(part)) continue;
if (/^[A-Za-z]{2,20}\d{1,4}$/.test(part)) {
wordParts += 1;
continue;
}
return false;
}
return wordParts >= 2;
}
function shannonEntropy(value) {
const counts = new Map();
for (const character of value) counts.set(character, (counts.get(character) ?? 0) + 1);
let entropy = 0;
for (const count of counts.values()) {
const probability = count / value.length;
entropy -= probability * Math.log2(probability);
}
return entropy;
}
function isSensitiveFieldName(key) {
const normalized = String(key).replace(/[^A-Za-z0-9]/g, "").toLowerCase();
if (["authorization", "clientsecret", "code", "cookie", "credential", "idtoken", "jwt", "key", "password", "refreshtoken", "secret", "session", "setcookie", "signature", "token"].includes(normalized)) {
return true;
}
return /(?:access|api|auth|client|oauth|refresh|private|security|session|xamz|xgoog)(?:credential|key|password|secret|signature|token)$/.test(normalized) ||
/(?:credential|password|secret|signature|token)$/.test(normalized);
}
function containsCredentialLabel(value) {
let candidate = String(value ?? "");
for (let pass = 0; pass < MAX_CREDENTIAL_DECODE_PASSES; pass += 1) {
const parts = candidate.split(/[\s/?#&;=:[\](){},]+/).filter(Boolean);
if (parts.some((part) => isSensitiveFieldName(part))) return true;
const decoded = decodeUrlComponent(candidate);
if (decoded === candidate) return false;
candidate = decoded;
}
// Residual nested encoding after the shared cap is ambiguous and may conceal
// a credential label. Match the value scanner's fail-closed behavior.
return true;
}
function raceWithAbort(promise, signal) {
if (signal.aborted) return Promise.reject(abortError());
return new Promise((resolve, reject) => {
const onAbort = () => reject(abortError());
signal.addEventListener("abort", onAbort, { once: true });
Promise.resolve(promise).then(resolve, reject).finally(() => {
signal.removeEventListener("abort", onAbort);
});
});
}
async function withTimeout(promise, timeoutMs) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
timeout.unref?.();
try {
return await raceWithAbort(promise, controller.signal);
} finally {
clearTimeout(timeout);
}
}
function abortError() {
const error = new Error("The operation was aborted");
error.name = "AbortError";
error.code = "ABORT_ERR";
return error;
}
function decodeUrlComponent(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function addQuery(searchParams, values, prefix = "") {
for (const [key, value] of Object.entries(values)) {
if (value == null) continue;
const name = prefix ? `${prefix}[${key}]` : key;
if (Array.isArray(value)) {
for (const item of value) searchParams.append(name, String(item));
} else if (typeof value === "object") {
addQuery(searchParams, value, name);
} else {
searchParams.append(name, String(value));
}
}
}
function getHeader(headers, name) {
if (!headers) return undefined;
if (typeof headers.get === "function") return headers.get(name) ?? undefined;
return headers[name] ?? headers[name.toLowerCase()];
}
function isPublicIpv4(address) {
const parts = address.split(".").map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
const value = (((parts[0] * 256 + parts[1]) * 256 + parts[2]) * 256 + parts[3]) >>> 0;
const blocked = [
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24],
["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24],
["224.0.0.0", 4], ["240.0.0.0", 4],
];
return !blocked.some(([base, bits]) => inIpv4Cidr(value, ipv4Number(base), bits));
}
function ipv4Number(address) {
return address.split(".").map(Number).reduce((value, part) => value * 256 + part, 0) >>> 0;
}
function inIpv4Cidr(value, base, bits) {
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
return (value & mask) === (base & mask);
}
function isPublicIpv6(address) {
let value;
try {
value = ipv6BigInt(address);
} catch {
return false;
}
if ((value >> 32n) === 0xffffn) {
const ipv4 = Number(value & 0xffffffffn);
return isPublicIpv4(`${ipv4 >>> 24}.${(ipv4 >>> 16) & 255}.${(ipv4 >>> 8) & 255}.${ipv4 & 255}`);
}
const ranges = [
["::", 128], ["::1", 128], ["64:ff9b:1::", 48], ["100::", 64], ["2001:2::", 48],
["2001:10::", 28], ["2001:db8::", 32], ["2002::", 16],
["fc00::", 7], ["fec0::", 10], ["fe80::", 10], ["ff00::", 8],
];
return !ranges.some(([base, bits]) => inIpv6Cidr(value, ipv6BigInt(base), bits));
}
function ipv6BigInt(address) {
let source = address.toLowerCase().split("%")[0];
if (source.includes(".")) {
const lastColon = source.lastIndexOf(":");
const ipv4 = source.slice(lastColon + 1).split(".").map(Number);
if (ipv4.length !== 4 || ipv4.some((part) => part < 0 || part > 255)) throw new Error("bad IPv6");
source = `${source.slice(0, lastColon)}:${((ipv4[0] << 8) | ipv4[1]).toString(16)}:${((ipv4[2] << 8) | ipv4[3]).toString(16)}`;
}
const halves = source.split("::");
if (halves.length > 2) throw new Error("bad IPv6");
const left = halves[0] ? halves[0].split(":") : [];
const right = halves[1] ? halves[1].split(":") : [];
const missing = 8 - left.length - right.length;
if ((halves.length === 1 && missing !== 0) || missing < 0) throw new Error("bad IPv6");
const groups = [...left, ...Array(missing).fill("0"), ...right];
if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group))) throw new Error("bad IPv6");
return groups.reduce((total, group) => (total << 16n) + BigInt(`0x${group}`), 0n);
}
function inIpv6Cidr(value, base, bits) {
const shift = BigInt(128 - bits);
return (value >> shift) === (base >> shift);
}
+159
View File
@@ -0,0 +1,159 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { spawn as nodeSpawn } from "node:child_process";
import { resolveRuntimePaths } from "./paths.js";
import { readJson, pathExists } from "./storage.js";
import { discoverProjectIdentity } from "./identity.js";
import { RUNTIME_SCHEMA_VERSION, RUNTIME_MIGRATION_ID } from "./migrations.js";
import { assertManagedHome } from "./managed-home.js";
import { recoverPendingUpgrade } from "./upgrade.js";
export async function runDoctor(options = {}) {
const paths = resolveRuntimePaths(options);
const checks = [];
const add = (id, status, message, details) => checks.push({ id, status, message, ...(details ? { details } : {}) });
const now = options.now ? options.now() : new Date();
const node = await inspectLauncherNode(options.nodeCommand ?? process.env.GSTACK_NODE ?? "node");
add("runtime", node.ok ? "pass" : "fail", node.message, node.details);
if (!(await pathExists(paths.home))) {
add("home", "fail", `State home does not exist: ${paths.home}`, { remedy: "Run `gstack setup`." });
} else {
try {
await fs.access(paths.home, fsConstants.R_OK | fsConstants.W_OK);
add("home", "pass", `State home is readable and writable: ${paths.home}`);
} catch (error) {
add("home", "fail", `State home is not readable and writable: ${paths.home}`, { code: error.code });
}
try {
await assertManagedHome(paths.home, options);
add("ownership", "pass", "Managed home ownership sentinel is valid");
} catch (error) {
add("ownership", "fail", `Managed home ownership cannot be verified: ${error.message}`, {
code: error.code,
remedy: "Run `gstack setup` with the intended GSTACK_HOME.",
});
}
}
try {
const config = await readJson(paths.config);
add("config", config?.schemaVersion <= RUNTIME_SCHEMA_VERSION ? "pass" : "fail",
`Config schema ${config?.schemaVersion ?? "unknown"}`);
const enabled = config?.network?.mode === "context" && config?.network?.consent === true;
add("network", enabled ? "pass" : "warn",
enabled ? "Context.dev network mode has explicit consent" : "Network access is off (safe default)");
} catch (error) {
add("config", "fail", `Config cannot be read: ${error.message}`);
}
try {
const stat = await fs.stat(paths.secrets);
const privateMode = process.platform === "win32" || (stat.mode & 0o077) === 0;
await readJson(paths.secrets);
add("secrets", privateMode ? "pass" : "fail",
privateMode ? "Secrets file is private" : "Secrets file permissions are broader than 0600",
process.platform === "win32" ? undefined : { mode: `0${(stat.mode & 0o777).toString(8)}` });
} catch (error) {
add("secrets", "fail", `Secrets file cannot be read: ${error.message}`);
}
try {
const migration = await readJson(paths.migrations);
const supported = migration.schemaVersion === RUNTIME_SCHEMA_VERSION &&
migration.applied?.some((entry) => entry.id === RUNTIME_MIGRATION_ID);
add("migration", supported ? "pass" : "fail",
supported ? `Forward-only schema ${migration.schemaVersion} is current` : "Migration marker is absent or unsupported");
} catch (error) {
add("migration", "fail", `Migration marker cannot be read: ${error.message}`);
}
try {
const identity = await discoverProjectIdentity(options.cwd ?? process.cwd());
const stateFile = path.join(paths.projects, identity.projectId, "state.json");
if (await pathExists(stateFile)) {
const state = await readJson(stateFile);
const valid = state.schemaVersion <= RUNTIME_SCHEMA_VERSION && state.project?.id === identity.projectId;
add("project", valid ? "pass" : "fail",
valid ? `Project state found for ${identity.projectId}` : "Project state identity/schema does not match");
} else {
add("project", "warn", `No state initialized for ${identity.projectId}`, { remedy: "Run `gstack setup`." });
}
add("git", identity.isGit ? "pass" : "warn",
identity.isGit ? `Git worktree ${identity.worktreeId}` : "Current directory is not a Git worktree");
} catch (error) {
add("project", "fail", `Project identity failed: ${error.message}`);
}
try {
const recovery = await recoverPendingUpgrade(paths.home, options);
const pointer = recovery.pointer;
if (!pointer) add("upgrade", "pass", "No managed version pointer (package-managed install)");
else if (recovery.recovered) {
add("upgrade", pointer.current ? "warn" : "fail", pointer.current
? `Recovered interrupted upgrade to last-known-good version: ${pointer.current}`
: "Interrupted upgrade had no valid last-known-good version");
} else add("upgrade", "pass", pointer.current ? `Active managed version: ${pointer.current}` : "No active managed version");
} catch (error) {
add("upgrade", "fail", `Version pointer cannot be read: ${error.message}`);
}
return {
ok: !checks.some((check) => check.status === "fail"),
home: paths.home,
checkedAt: now.toISOString(),
checks,
};
}
async function inspectLauncherNode(command) {
try {
const result = await captureCommand(command, ["--version"]);
const raw = `${result.stdout}${result.stderr}`.trim();
const major = Number(raw.match(/v?(\d+)\./)?.[1]);
if (!Number.isInteger(major) || major < 18) {
return { ok: false, message: `Node 18+ is required by launchers; ${command} reported ${raw || "an unknown version"}` };
}
return { ok: true, message: `Launcher Node ${raw.replace(/^v/, "")}`, details: { command } };
} catch (error) {
return {
ok: false,
message: `Node 18+ launcher runtime is unavailable: ${error.message}`,
details: { command, code: error.code },
};
}
}
function captureCommand(command, args) {
return new Promise((resolve, reject) => {
const child = nodeSpawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
shell: false,
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => { stdout += chunk; });
child.stderr.on("data", (chunk) => { stderr += chunk; });
child.once("error", reject);
child.once("exit", (code) => {
if (code === 0) resolve({ stdout, stderr });
else {
const error = new Error(`${command} --version exited with ${code}`);
error.code = "NODE_UNAVAILABLE";
reject(error);
}
});
});
}
export function formatDoctor(report) {
const symbol = { pass: "OK", warn: "WARN", fail: "FAIL" };
const lines = [`gstack doctor: ${report.ok ? "healthy" : "needs attention"}`, `home: ${report.home}`];
for (const check of report.checks) lines.push(`${symbol[check.status]} ${check.id}: ${check.message}`);
return `${lines.join("\n")}\n`;
}
+108
View File
@@ -0,0 +1,108 @@
import fs from "node:fs/promises";
import path from "node:path";
import { createHash } from "node:crypto";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
const execFile = promisify(execFileCallback);
export function stableId(namespace, value, length = 20) {
const digest = createHash("sha256")
.update(`gstack:${namespace}:v2\0`, "utf8")
.update(String(value), "utf8")
.digest("hex")
.slice(0, length);
return `${namespace}_${digest}`;
}
export async function discoverProjectIdentity(cwd = process.cwd(), options = {}) {
const absoluteCwd = await canonicalPath(cwd);
const git = options.git ?? runGit;
try {
const [worktreeRootRaw, commonDirRaw, gitDirRaw] = await Promise.all([
git(["rev-parse", "--show-toplevel"], absoluteCwd),
git(["rev-parse", "--git-common-dir"], absoluteCwd),
git(["rev-parse", "--git-dir"], absoluteCwd),
]);
const worktreeRoot = await canonicalPath(resolveGitPath(worktreeRootRaw, absoluteCwd));
const commonDir = await canonicalPath(resolveGitPath(commonDirRaw, worktreeRoot));
const gitDir = await canonicalPath(resolveGitPath(gitDirRaw, worktreeRoot));
return identityFromPaths({ worktreeRoot, commonDir, gitDir, isGit: true });
} catch (error) {
if (options.requireGit) throw error;
if (!isNotGitRepository(error)) throw error;
return identityFromPaths({
worktreeRoot: absoluteCwd,
commonDir: absoluteCwd,
gitDir: absoluteCwd,
isGit: false,
});
}
}
export function identityFromPaths({ worktreeRoot, commonDir, gitDir, isGit = true }) {
const resolvedRoot = path.resolve(worktreeRoot);
const resolvedCommon = path.resolve(commonDir);
const resolvedGitDir = path.resolve(gitDir);
const normalizedRoot = normalizeIdentityPath(resolvedRoot);
const normalizedCommon = normalizeIdentityPath(resolvedCommon);
const repoId = stableId("repo", normalizedCommon);
// Linked worktrees have a durable git-dir slot under the common repository.
// The Git slot is stable when a linked checkout moves and unique within the
// common repository. Non-Git folders have no slot, so their canonical path
// remains the identity boundary.
const gitSlot = path.relative(resolvedCommon, resolvedGitDir) || ".";
const worktreeId = stableId(
"worktree",
isGit ? `${repoId}\0${normalizeIdentityPath(gitSlot)}` : `${repoId}\0${normalizedRoot}`,
);
const projectId = stableId("project", `${repoId}\0${worktreeId}`, 24);
return Object.freeze({
projectId,
repoId,
worktreeId,
worktreeRoot: resolvedRoot,
repoCommonDir: resolvedCommon,
gitDir: resolvedGitDir,
isGit,
});
}
function isNotGitRepository(error) {
if (error?.code === "NOT_GIT") return true;
const detail = `${error?.stderr ?? ""}\n${error?.message ?? ""}`;
return (error?.code === 128 || error?.exitCode === 128) && /not a git repository/i.test(detail);
}
async function runGit(args, cwd) {
const { stdout } = await execFile("git", args, {
cwd,
encoding: "utf8",
timeout: 5_000,
maxBuffer: 1024 * 1024,
windowsHide: true,
});
return stdout.replace(/[\r\n]+$/, "");
}
function resolveGitPath(value, cwd) {
if (!value) throw new Error("git returned an empty path");
return path.isAbsolute(value) ? value : path.resolve(cwd, value);
}
async function canonicalPath(value) {
const absolute = path.resolve(value);
try {
return await fs.realpath(absolute);
} catch (error) {
if (error?.code === "ENOENT") return absolute;
throw error;
}
}
function normalizeIdentityPath(value) {
let normalized = path.normalize(String(value)).replaceAll(path.sep, "/");
if (process.platform === "win32") normalized = normalized.toLowerCase();
return normalized;
}
+13
View File
@@ -0,0 +1,13 @@
export * from "./paths.js";
export * from "./managed-home.js";
export * from "./storage.js";
export * from "./config.js";
export * from "./identity.js";
export * from "./migrations.js";
export * from "./state.js";
export * from "./setup.js";
export * from "./context.js";
export * from "./doctor.js";
export * from "./cleanup.js";
export * from "./upgrade.js";
export * from "./install.js";
+1266
View File
File diff suppressed because it is too large Load Diff
+314
View File
@@ -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();
}
+39
View File
@@ -0,0 +1,39 @@
import path from "node:path";
import { atomicWriteJson, readJson, withLock } from "./storage.js";
import { resolveRuntimePaths } from "./paths.js";
export const RUNTIME_SCHEMA_VERSION = 2;
export const RUNTIME_MIGRATION_ID = "2.0.0-host-neutral-runtime";
export async function ensureMigrations(home, options = {}) {
const paths = resolveRuntimePaths({ home });
return withLock(path.join(paths.locks, "migration.lock"), async () => {
const existing = await readJson(paths.migrations, null);
if (existing?.schemaVersion > RUNTIME_SCHEMA_VERSION) {
const error = new Error(
`State schema ${existing.schemaVersion} is newer than this runtime supports (${RUNTIME_SCHEMA_VERSION})`,
);
error.code = "MIGRATION_NEWER_THAN_RUNTIME";
throw error;
}
if (existing?.schemaVersion === RUNTIME_SCHEMA_VERSION &&
existing.applied?.some((entry) => entry.id === RUNTIME_MIGRATION_ID)) {
return existing;
}
const now = (options.now ?? (() => new Date()))().toISOString();
const marker = {
format: "gstack-forward-migrations",
schemaVersion: RUNTIME_SCHEMA_VERSION,
direction: "forward-only",
applied: [
...(Array.isArray(existing?.applied) ? existing.applied : []),
{ id: RUNTIME_MIGRATION_ID, appliedAt: now },
].filter((entry, index, all) => all.findIndex((other) => other.id === entry.id) === index),
updatedAt: now,
};
await atomicWriteJson(paths.migrations, marker, { mode: 0o600 });
return marker;
});
}
+86
View File
@@ -0,0 +1,86 @@
import os from "node:os";
import path from "node:path";
/**
* Resolve the one and only gstack state root.
*
* This deliberately does not consult host-specific variables such as
* CLAUDE_PLUGIN_DATA. GSTACK_HOME wins; otherwise state lives in ~/.gstack.
* Values are handled as paths, never evaluated by a shell.
*/
export function resolveGstackHome(options = {}) {
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? os.homedir();
const cwd = options.cwd ?? process.cwd();
let configured = env.GSTACK_HOME;
if (configured != null && configured.includes("\0")) {
throw new TypeError("GSTACK_HOME must not contain a NUL byte");
}
if (configured == null || configured === "") {
if (!homeDir) throw new Error("Unable to resolve a home directory for ~/.gstack");
configured = path.join(homeDir, ".gstack");
} else if (configured === "~" || configured.startsWith(`~${path.sep}`)) {
if (!homeDir) throw new Error("Unable to expand ~ in GSTACK_HOME");
configured = path.join(homeDir, configured.slice(2));
}
return path.normalize(path.resolve(cwd, configured));
}
export function resolveRuntimePaths(options = {}) {
const home = options.home ?? resolveGstackHome(options);
return Object.freeze({
home,
config: path.join(home, "config.json"),
secrets: path.join(home, "secrets.json"),
migrations: path.join(home, "migration.json"),
projects: path.join(home, "projects"),
locks: path.join(home, "locks"),
tmp: path.join(home, "tmp"),
plans: path.join(home, "plans"),
versions: path.join(home, "versions"),
versionPointer: path.join(home, "versions", "current.json"),
});
}
/** POSIX-shell literal used only by the legacy gstack-paths adapter. */
export function shellQuote(value) {
return `'${String(value).replaceAll("'", `'\\''`)}'`;
}
export function projectPaths(home, projectId) {
assertSafeId(projectId, "project id");
const root = path.join(home, "projects", projectId);
return Object.freeze({
root,
state: path.join(root, "state.json"),
timeline: path.join(root, "timeline.jsonl"),
decisions: path.join(root, "decisions.jsonl"),
evidence: path.join(root, "evidence"),
artifacts: path.join(root, "artifacts"),
reviews: path.join(root, "reviews"),
checkpoints: path.join(root, "checkpoints"),
lock: path.join(root, ".state.lock"),
});
}
export function assertSafeId(value, label = "id") {
if (typeof value !== "string" || !/^[a-z0-9][a-z0-9_-]{0,127}$/i.test(value)) {
throw new TypeError(`Invalid ${label}`);
}
return value;
}
/** Return candidate only when it is strictly inside root. */
export function assertPathInside(root, candidate) {
const base = path.resolve(root);
const target = path.resolve(candidate);
const relative = path.relative(base, target);
if (relative === "" || relative === ".") return target;
if (relative.startsWith(".." + path.sep) || relative === ".." || path.isAbsolute(relative)) {
throw new Error(`Path escapes gstack home: ${target}`);
}
return target;
}
+29
View File
@@ -0,0 +1,29 @@
import fs from "node:fs/promises";
import { resolveRuntimePaths } from "./paths.js";
import { ensureConfig } from "./config.js";
import { ensureMigrations } from "./migrations.js";
import { discoverProjectIdentity } from "./identity.js";
import { initializeProject } from "./state.js";
import { ensureManagedHome, recoverRuntimeTransactionUnlocked, withRuntimeLifecycleLock } from "./managed-home.js";
export async function setupRuntime(options = {}) {
const paths = resolveRuntimePaths(options);
return withRuntimeLifecycleLock(paths.home, async () => {
await ensureManagedHome(paths.home, options);
await recoverRuntimeTransactionUnlocked(paths.home);
await fs.chmod(paths.home, 0o700).catch((error) => {
if (process.platform !== "win32") throw error;
});
await Promise.all([
fs.mkdir(paths.projects, { recursive: true, mode: 0o700 }),
fs.mkdir(paths.locks, { recursive: true, mode: 0o700 }),
fs.mkdir(paths.tmp, { recursive: true, mode: 0o700 }),
fs.mkdir(paths.versions, { recursive: true, mode: 0o700 }),
]);
const { config } = await ensureConfig(paths.home);
const migration = await ensureMigrations(paths.home, options);
const identity = await discoverProjectIdentity(options.cwd ?? process.cwd(), options);
const project = await initializeProject(paths.home, identity, options);
return { paths, config, migration, identity, project: project.state };
}, options);
}
+970
View File
@@ -0,0 +1,970 @@
import fs from "node:fs/promises";
import { createHash, randomUUID } from "node:crypto";
import { appendJsonLine, atomicWriteJson, readJson, withLock } from "./storage.js";
import { projectPaths } from "./paths.js";
import { discoverProjectIdentity } from "./identity.js";
import { RUNTIME_SCHEMA_VERSION } from "./migrations.js";
const PROJECT_DIRECTORIES = ["evidence", "artifacts", "reviews", "checkpoints"];
export const WORKFLOW_STATE_SCHEMA_VERSION = 1;
const WORKFLOW_DEPTHS = new Set(["quick", "standard", "deep"]);
const EVIDENCE_FRESHNESS = new Set(["unknown", "fresh", "stale"]);
const RUN_STATUSES = new Set(["running", "completed"]);
const EFFECT_STATUSES = new Set(["ready", "in_progress", "uncertain", "completed"]);
const MUTATION_AUTHORITIES = new Set([
"source-defined",
"report-only",
"plan-only",
"design-doc-only",
"spec-only",
"spec-and-issue",
"design-artifacts",
"fix-safe",
"fix-safe-after-root-cause",
"investigate-only",
"code-generation",
"commit-push-pr",
"merge-deploy",
"deploy",
"docs-only",
"profile-only",
"configuration",
"installation",
"safety-policy",
"state-only",
"state-dependent",
"approval-required",
"none",
]);
const EXTERNAL_EFFECT_AUTHORITIES = new Set([
// source-defined keeps state written by the pre-metadata GStack 2 runtime
// resumable; new dispatchers persist their exact authority.
"source-defined",
"spec-and-issue",
"commit-push-pr",
"merge-deploy",
"deploy",
"state-dependent",
"configuration",
"installation",
]);
const WORKFLOW_KEYS = new Set([
"schemaVersion",
"currentPlanPointer",
"originalGoal",
"detourStack",
"currentWorkflowStage",
"selectedDepth",
"mutationAuthority",
"activeModules",
"evidenceFreshness",
"evidenceProvenance",
"pendingApprovalGates",
]);
const WORKFLOW_TRANSITION_KEYS = new Set([
"currentPlanPointer",
"currentWorkflowStage",
"selectedDepth",
"mutationAuthority",
"activeModules",
"pushDetour",
"popDetour",
"evidenceFreshness",
"addEvidenceProvenance",
"addApprovalGate",
"resolveApprovalGate",
]);
export async function initializeProject(home, identity, options = {}) {
const paths = projectPaths(home, identity.projectId);
await fs.mkdir(paths.root, { recursive: true, mode: 0o700 });
return withLock(paths.lock, async () => {
for (const name of PROJECT_DIRECTORIES) {
await fs.mkdir(paths[name], { recursive: true, mode: 0o700 });
}
const now = isoNow(options.now);
let state = await readJson(paths.state, null);
if (!state) {
state = {
schemaVersion: RUNTIME_SCHEMA_VERSION,
revision: 0,
project: {
id: identity.projectId,
repoId: identity.repoId,
worktreeId: identity.worktreeId,
worktreeRoot: identity.worktreeRoot,
repoCommonDir: identity.repoCommonDir,
isGit: identity.isGit,
},
activeRunId: null,
currentPlan: null,
runs: Object.create(null),
createdAt: now,
updatedAt: now,
};
await atomicWriteJson(paths.state, state, { mode: 0o600 });
} else {
assertSupportedState(state, paths.state);
// A registered worktree can move. Its ID intentionally changes when it
// does; within an existing project, keep display paths fresh.
state.project = { ...state.project, ...identity, id: identity.projectId };
await atomicWriteJson(paths.state, state, { mode: 0o600 });
}
await ensureJsonl(paths.timeline);
await ensureJsonl(paths.decisions);
return { paths, state };
});
}
export async function currentProject(home, cwd = process.cwd(), options = {}) {
const identity = await discoverProjectIdentity(cwd, options);
return initializeProject(home, identity, options);
}
export async function inspectProject(home, identityOrId) {
const id = typeof identityOrId === "string" ? identityOrId : identityOrId.projectId;
const paths = projectPaths(home, id);
const state = await readJson(paths.state, null);
if (!state) {
const error = new Error(`No state found for project ${id}`);
error.code = "STATE_NOT_FOUND";
throw error;
}
assertSupportedState(state, paths.state);
return { paths, state };
}
/**
* Return the complete durable reconstruction needed to continue one run.
* This function is intentionally read-only: callers must use resumeRun before
* changing a non-active run.
*/
export async function inspectRun(home, projectId, runId) {
validateRunId(runId);
const { paths, state } = await inspectProject(home, projectId);
const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null;
if (!run) throw codedError("RUN_NOT_FOUND", `Run not found: ${runId}`);
return {
paths,
state,
run,
reconstruction: workflowReconstruction(state, run),
};
}
export async function updateProjectState(home, projectId, mutator, options = {}) {
const paths = projectPaths(home, projectId);
return withLock(paths.lock, async () => {
const state = await readJson(paths.state);
assertSupportedState(state, paths.state);
const result = await mutator(state);
assertSupportedState(state, paths.state);
state.revision = Number(state.revision ?? 0) + 1;
state.updatedAt = isoNow(options.now);
await atomicWriteJson(paths.state, state, { mode: 0o600 });
return { state, result };
}, options.lock);
}
export async function beginRun(home, projectId, command, options = {}) {
const runId = options.runId ?? `run_${Date.now().toString(36)}_${randomUUID().slice(0, 12)}`;
validateRunId(runId);
const paths = projectPaths(home, projectId);
const now = isoNow(options.now);
const { state, result } = await updateWithEvent(paths, async (state) => {
if (Object.hasOwn(state.runs, runId)) {
const error = new Error(`Run already exists: ${runId}`);
error.code = "RUN_EXISTS";
throw error;
}
const workflow = createWorkflowState(command, options, now);
state.runs[runId] = {
id: runId,
command: String(command ?? "unknown"),
status: "running",
workflow,
effects: Object.create(null),
startedAt: now,
updatedAt: now,
resumeCount: 0,
};
state.activeRunId = runId;
state.currentPlan = currentPlanProjection(runId, workflow, now);
return {
result: state.runs[runId],
event: { type: "run.started", runId, command: state.runs[runId].command, at: now },
};
}, options);
return { state, run: result, reconstruction: workflowReconstruction(state, result) };
}
export async function resumeRun(home, projectId, runId, options = {}) {
const paths = projectPaths(home, projectId);
const now = isoNow(options.now);
const { state, result } = await updateWithEvent(paths, async (state) => {
const selected = runId ?? state.activeRunId ?? newestIncompleteRun(state);
if (selected) validateRunId(selected);
const run = selected && Object.hasOwn(state.runs, selected) ? state.runs[selected] : null;
if (!run) {
const error = new Error(selected ? `Run not found: ${selected}` : "No resumable run found");
error.code = "RUN_NOT_FOUND";
throw error;
}
if (run.status === "completed") {
const error = new Error(`Run is already complete: ${run.id}`);
error.code = "RUN_COMPLETED";
throw error;
}
for (const effect of Object.values(run.effects ?? {})) {
if (effect.status === "in_progress") {
effect.status = "uncertain";
effect.uncertainAt = now;
effect.reason = "runtime stopped after effect was claimed; reconcile before retrying";
}
}
run.status = "running";
run.resumeCount = Number(run.resumeCount ?? 0) + 1;
run.resumedAt = now;
run.updatedAt = now;
state.activeRunId = run.id;
state.currentPlan = currentPlanProjection(run.id, run.workflow, now);
return {
result: run,
event: { type: "run.resumed", runId: run.id, resumeCount: run.resumeCount, at: now },
};
}, options);
return { state, run: result, reconstruction: workflowReconstruction(state, result) };
}
export async function completeRun(home, projectId, runId, options = {}) {
validateRunId(runId);
const paths = projectPaths(home, projectId);
const now = isoNow(options.now);
const { state, result } = await updateWithEvent(paths, async (state) => {
const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null;
if (!run) throw codedError("RUN_NOT_FOUND", `Run not found: ${runId}`);
const unresolved = Object.values(run.effects ?? {}).filter((effect) =>
["ready", "in_progress", "uncertain"].includes(effect.status));
if (unresolved.length && !options.allowUncertain) {
throw codedError("EFFECTS_UNCERTAIN", "Run has unresolved external effects");
}
if (run.workflow.pendingApprovalGates.length) {
throw codedError("APPROVAL_GATES_PENDING", "Run has pending approval gates");
}
run.status = "completed";
run.completedAt = now;
run.updatedAt = now;
if (state.activeRunId === runId) {
state.activeRunId = null;
state.currentPlan = null;
}
return {
result: run,
event: { type: "run.completed", runId, at: now },
};
}, options);
return { state, run: result, reconstruction: workflowReconstruction(state, result) };
}
/**
* Apply an explicit workflow transition under the same project lock used for
* external-effect claims. The original goal is deliberately not patchable.
*/
export async function updateRunWorkflow(home, projectId, runId, transition, options = {}) {
validateRunId(runId);
validateWorkflowTransition(transition);
const paths = projectPaths(home, projectId);
const now = isoNow(options.now);
const { state, result } = await updateWithEvent(paths, async (state) => {
const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null;
if (!run) throw codedError("RUN_NOT_FOUND", `Run not found: ${runId}`);
if (run.status === "completed") throw codedError("RUN_COMPLETED", `Run is already complete: ${runId}`);
if (state.activeRunId !== runId) {
throw codedError("RUN_NOT_ACTIVE", `Run is not active; resume it before updating: ${runId}`);
}
const changes = applyWorkflowTransition(run.workflow, transition, now);
run.updatedAt = now;
state.currentPlan = currentPlanProjection(runId, run.workflow, now);
return {
result: run,
event: { type: "run.workflow_updated", runId, changes, at: now },
};
}, options);
return {
state,
run: result,
reconstruction: workflowReconstruction(state, result),
};
}
/**
* Execute an external side effect at most once from gstack's perspective.
*
* A durable claim is written before execute() is called. If the process dies
* after the external system accepts the action, resume marks that claim
* uncertain and will not call execute() again. The stable idempotencyKey should
* also be passed to APIs that support native idempotency.
*/
export async function runExternalEffect(home, projectId, runId, effectKey, execute, options = {}) {
validateRunId(runId);
validateEffectKey(effectKey);
const paths = projectPaths(home, projectId);
const now = isoNow(options.now);
const claimed = await updateWithEvent(paths, async (state) => {
const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null;
if (!run) throw codedError("RUN_NOT_FOUND", `Run not found: ${runId}`);
if (run.status === "completed") throw codedError("RUN_COMPLETED", `Run is already complete: ${runId}`);
if (state.activeRunId !== runId) {
throw codedError("RUN_NOT_ACTIVE", `Run is not active; resume it before an external effect: ${runId}`);
}
if (run.workflow.pendingApprovalGates.length) {
throw codedError("APPROVAL_REQUIRED", "Resolve pending approval gates before external effects");
}
if (!EXTERNAL_EFFECT_AUTHORITIES.has(run.workflow.mutationAuthority)) {
throw codedError(
"MUTATION_NOT_AUTHORIZED",
`Mutation authority ${run.workflow.mutationAuthority} does not permit external effects`,
);
}
run.effects = normalizeRecord(run.effects, validateEffectKey, "effects");
const existing = Object.hasOwn(run.effects, effectKey) ? run.effects[effectKey] : null;
if (existing?.status === "completed") {
return { result: { action: "completed", effect: existing } };
}
if (existing && ["in_progress", "uncertain"].includes(existing.status)) {
existing.status = "uncertain";
existing.uncertainAt ??= now;
return { result: { action: "uncertain", effect: existing } };
}
const effect = {
key: effectKey,
status: "in_progress",
idempotencyKey: existing?.idempotencyKey ?? stableIdempotencyKey(projectId, runId, effectKey),
attempts: Number(existing?.attempts ?? 0) + 1,
claimedAt: now,
};
run.effects[effectKey] = effect;
run.updatedAt = now;
return {
result: { action: "execute", effect },
event: { type: "effect.claimed", runId, effectKey, idempotencyKey: effect.idempotencyKey, at: now },
};
}, options);
if (claimed.result.action === "completed") {
return { status: "completed", repeated: true, result: claimed.result.effect.result, idempotencyKey: claimed.result.effect.idempotencyKey };
}
if (claimed.result.action === "uncertain") {
return {
status: "uncertain",
repeated: false,
idempotencyKey: claimed.result.effect.idempotencyKey,
reason: "Effect was already claimed; reconcile it explicitly before retrying",
};
}
const effect = claimed.result.effect;
try {
const result = await execute({ idempotencyKey: effect.idempotencyKey, effectKey, runId });
await completeExternalEffect(home, projectId, runId, effectKey, result, options);
return { status: "completed", repeated: false, result, idempotencyKey: effect.idempotencyKey };
} catch (cause) {
await markEffectUncertain(home, projectId, runId, effectKey, cause, options).catch(() => {});
const error = new Error(`External effect ${effectKey} may have occurred; refusing automatic retry`, { cause });
error.code = "EXTERNAL_EFFECT_UNCERTAIN";
error.idempotencyKey = effect.idempotencyKey;
throw error;
}
}
export async function completeExternalEffect(home, projectId, runId, effectKey, result, options = {}) {
validateRunId(runId);
validateEffectKey(effectKey);
const paths = projectPaths(home, projectId);
const now = isoNow(options.now);
return updateWithEvent(paths, async (state) => {
const effect = ownedEffect(state, runId, effectKey);
if (!effect) throw codedError("EFFECT_NOT_FOUND", `Effect not found: ${effectKey}`);
if (effect.status !== "in_progress") {
throw codedError("EFFECT_NOT_IN_PROGRESS", `Effect is not in progress: ${effectKey}`);
}
effect.status = "completed";
effect.completedAt = now;
effect.result = jsonSafe(result);
delete effect.reason;
return {
result: effect,
event: { type: "effect.completed", runId, effectKey, at: now },
};
}, options);
}
export async function markEffectNotApplied(home, projectId, runId, effectKey, options = {}) {
validateRunId(runId);
validateEffectKey(effectKey);
const paths = projectPaths(home, projectId);
return updateWithEvent(paths, async (state) => {
const effect = ownedEffect(state, runId, effectKey);
if (!effect) throw codedError("EFFECT_NOT_FOUND", `Effect not found: ${effectKey}`);
if (effect.status !== "uncertain") {
throw codedError("EFFECT_NOT_UNCERTAIN", `Only an uncertain effect can be reconciled as not applied: ${effectKey}`);
}
effect.status = "ready";
effect.reconciledAt = isoNow(options.now);
effect.reason = "caller confirmed the external action did not occur";
return {
result: effect,
event: { type: "effect.reconciled_not_applied", runId, effectKey, at: effect.reconciledAt },
};
}, options);
}
export async function markEffectApplied(home, projectId, runId, effectKey, evidence, options = {}) {
validateRunId(runId);
validateEffectKey(effectKey);
if (typeof evidence !== "string" || !evidence.trim() || evidence.length > 500 || /[\r\n\0]/.test(evidence)) {
throw new TypeError("A compact, single-line external evidence reference is required");
}
const paths = projectPaths(home, projectId);
const now = isoNow(options.now);
return updateWithEvent(paths, async (state) => {
const effect = ownedEffect(state, runId, effectKey);
if (!effect) throw codedError("EFFECT_NOT_FOUND", `Effect not found: ${effectKey}`);
if (effect.status !== "uncertain") {
throw codedError("EFFECT_NOT_UNCERTAIN", `Only an uncertain effect can be reconciled as applied: ${effectKey}`);
}
effect.status = "completed";
effect.completedAt = now;
effect.reconciledAt = now;
effect.result = { reconciled: true, evidence: evidence.trim() };
effect.reason = "external inspection confirmed the action occurred";
return {
result: effect,
event: { type: "effect.reconciled_applied", runId, effectKey, evidence: evidence.trim(), at: now },
};
}, options);
}
export async function appendDecision(home, projectId, decision, options = {}) {
const paths = projectPaths(home, projectId);
return withLock(paths.lock, async () => {
const record = { ...jsonSafe(decision), id: randomUUID(), at: isoNow(options.now) };
await appendJsonLine(paths.decisions, record, { mode: 0o600 });
return record;
});
}
async function markEffectUncertain(home, projectId, runId, effectKey, cause, options) {
const paths = projectPaths(home, projectId);
const now = isoNow(options.now);
return updateWithEvent(paths, async (state) => {
const effect = ownedEffect(state, runId, effectKey);
if (!effect) throw codedError("EFFECT_NOT_FOUND", `Effect not found: ${effectKey}`);
effect.status = "uncertain";
effect.uncertainAt = now;
effect.reason = String(cause?.message ?? cause ?? "unknown external error").slice(0, 500);
return {
result: effect,
event: { type: "effect.uncertain", runId, effectKey, at: now },
};
}, options);
}
async function updateWithEvent(paths, mutator, options = {}) {
return withLock(paths.lock, async () => {
const state = await readJson(paths.state);
assertSupportedState(state, paths.state);
const mutation = await mutator(state);
assertSupportedState(state, paths.state);
state.revision = Number(state.revision ?? 0) + 1;
state.updatedAt = isoNow(options.now);
await atomicWriteJson(paths.state, state, { mode: 0o600 });
if (mutation.event) await appendJsonLine(paths.timeline, mutation.event, { mode: 0o600 });
return { state, result: mutation.result };
}, options.lock);
}
async function ensureJsonl(file) {
try {
const handle = await fs.open(file, "wx", 0o600);
await handle.close();
} catch (error) {
if (error?.code !== "EEXIST") throw error;
}
await fs.chmod(file, 0o600);
}
function newestIncompleteRun(state) {
return Object.values(state.runs ?? {})
.filter((run) => run.status !== "completed")
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))[0]?.id ?? null;
}
function assertSupportedState(state, file) {
if (!state || typeof state !== "object" || Array.isArray(state)) throw new Error(`Invalid state file: ${file}`);
if (!Number.isInteger(state.schemaVersion) || state.schemaVersion < 1) {
throw new Error(`Invalid state schema in ${file}`);
}
if (state.schemaVersion > RUNTIME_SCHEMA_VERSION) {
throw codedError("STATE_NEWER_THAN_RUNTIME", `State schema is newer than this runtime: ${file}`);
}
if (!Number.isInteger(state.revision) || state.revision < 0) throw new Error(`Invalid state revision in ${file}`);
state.runs = normalizeRecord(state.runs, validateRunId, "runs");
for (const [runId, run] of Object.entries(state.runs)) {
if (!run || typeof run !== "object" || Array.isArray(run)) throw new Error(`Invalid run record in ${file}`);
run.effects = normalizeRecord(run.effects, validateEffectKey, "effects");
if (run.id !== runId) throw new Error(`Run key/id mismatch in ${file}`);
if (!RUN_STATUSES.has(run.status)) throw new Error(`Invalid run status in ${file}`);
validateCompactLine(run.command, "run command", 128);
validateIso(run.startedAt, "run start timestamp");
validateIso(run.updatedAt, "run update timestamp");
if (!Number.isInteger(run.resumeCount) || run.resumeCount < 0) throw new Error(`Invalid resume count in ${file}`);
if (run.status === "completed") validateIso(run.completedAt, "run completion timestamp");
if (run.workflow == null) {
// Runs written by the first GStack 2 runtime are upgraded in memory and
// become durable on the next locked mutation. This preserves resume
// without treating missing metadata as trustworthy caller input.
run.workflow = createWorkflowState(run.command, {
currentWorkflowStage: run.status === "completed" ? "completed" : "initialized",
}, validIsoOrNow(run.updatedAt));
}
validateWorkflowState(run.workflow, `run ${runId} in ${file}`);
for (const [effectKey, effect] of Object.entries(run.effects)) {
if (!effect || typeof effect !== "object" || effect.key !== effectKey) {
throw new Error(`Effect key/id mismatch in ${file}`);
}
if (!EFFECT_STATUSES.has(effect.status)) throw new Error(`Invalid effect status in ${file}`);
if (typeof effect.idempotencyKey !== "string" || !/^gstack_[0-9a-f]{64}$/.test(effect.idempotencyKey)) {
throw new Error(`Invalid effect idempotency key in ${file}`);
}
if (!Number.isInteger(effect.attempts) || effect.attempts < 1) {
throw new Error(`Invalid effect attempt count in ${file}`);
}
validateIso(effect.claimedAt, "effect claim timestamp");
if (effect.status === "completed") validateIso(effect.completedAt, "effect completion timestamp");
if (effect.status === "uncertain") validateIso(effect.uncertainAt, "effect uncertainty timestamp");
if (effect.status === "ready") validateIso(effect.reconciledAt, "effect reconciliation timestamp");
}
}
if (state.activeRunId != null) {
validateRunId(state.activeRunId);
if (!Object.hasOwn(state.runs, state.activeRunId)) {
throw new Error(`Active run does not exist in ${file}`);
}
if (state.runs[state.activeRunId].status !== "running") {
throw new Error(`Active run is not running in ${file}`);
}
}
if (state.currentPlan === undefined) {
const active = state.activeRunId == null ? null : state.runs[state.activeRunId];
state.currentPlan = active
? currentPlanProjection(active.id, active.workflow, validIsoOrNow(active.updatedAt))
: null;
}
validateCurrentPlanProjection(state, file);
}
function createWorkflowState(command, options, now) {
const requestedFreshness = typeof options.evidenceFreshness === "string"
? options.evidenceFreshness
: options.evidenceFreshness?.status ?? "unknown";
const workflow = {
schemaVersion: WORKFLOW_STATE_SCHEMA_VERSION,
currentPlanPointer: options.currentPlanPointer == null ? null : options.currentPlanPointer.trim(),
originalGoal: options.originalGoal ?? String(command ?? "unknown"),
detourStack: options.detourStack ?? [],
currentWorkflowStage: options.currentWorkflowStage ?? "initialized",
selectedDepth: options.selectedDepth ?? "standard",
mutationAuthority: options.mutationAuthority ?? "source-defined",
activeModules: options.activeModules ?? [],
evidenceFreshness: {
status: requestedFreshness,
assessedAt: requestedFreshness === "unknown" ? null : now,
},
evidenceProvenance: options.evidenceProvenance ?? [],
pendingApprovalGates: options.pendingApprovalGates ?? [],
};
// Normalize generated timestamps for initial metadata before validation.
workflow.detourStack = workflow.detourStack.map((detour) => ({
...detour,
fromStage: detour?.fromStage ?? workflow.currentWorkflowStage,
enteredAt: now,
}));
workflow.evidenceProvenance = workflow.evidenceProvenance.map((entry) => ({
...entry,
capturedAt: entry?.capturedAt ?? now,
recordedAt: now,
}));
workflow.pendingApprovalGates = workflow.pendingApprovalGates.map((gate) => ({
...gate,
requestedAt: now,
}));
validateWorkflowState(workflow, "new workflow");
return workflow;
}
function validateWorkflowState(workflow, label) {
assertPlainRecord(workflow, label, WORKFLOW_KEYS);
if (workflow.schemaVersion !== WORKFLOW_STATE_SCHEMA_VERSION) {
throw codedError("WORKFLOW_SCHEMA_UNSUPPORTED", `Unsupported workflow schema in ${label}`);
}
validateOptionalPointer(workflow.currentPlanPointer);
validateGoal(workflow.originalGoal, "original goal");
if (!Array.isArray(workflow.detourStack) || workflow.detourStack.length > 64) {
throw new TypeError(`Invalid detour stack in ${label}`);
}
for (const detour of workflow.detourStack) validateDetour(detour);
validateWorkflowToken(workflow.currentWorkflowStage, "workflow stage");
if (!WORKFLOW_DEPTHS.has(workflow.selectedDepth)) throw new TypeError("Invalid selected depth");
validateMutationAuthority(workflow.mutationAuthority);
validateModuleList(workflow.activeModules);
validateEvidenceFreshness(workflow.evidenceFreshness);
if (!Array.isArray(workflow.evidenceProvenance) || workflow.evidenceProvenance.length > 512) {
throw new TypeError(`Invalid evidence provenance in ${label}`);
}
for (const entry of workflow.evidenceProvenance) validateEvidenceProvenance(entry);
if (workflow.evidenceFreshness.status === "fresh" && workflow.evidenceProvenance.length === 0) {
throw new TypeError("Fresh evidence requires provenance");
}
if (!Array.isArray(workflow.pendingApprovalGates) || workflow.pendingApprovalGates.length > 64) {
throw new TypeError(`Invalid pending approval gates in ${label}`);
}
const gateIds = new Set();
for (const gate of workflow.pendingApprovalGates) {
validateApprovalGate(gate);
if (gateIds.has(gate.id)) throw new TypeError(`Duplicate approval gate: ${gate.id}`);
gateIds.add(gate.id);
}
}
function validateWorkflowTransition(transition) {
assertPlainRecord(transition, "workflow transition", WORKFLOW_TRANSITION_KEYS);
if (Object.keys(transition).length === 0) throw new TypeError("Workflow transition cannot be empty");
if (Object.hasOwn(transition, "currentPlanPointer")) validateOptionalPointer(transition.currentPlanPointer);
if (Object.hasOwn(transition, "currentWorkflowStage")) {
validateWorkflowToken(transition.currentWorkflowStage, "workflow stage");
}
if (Object.hasOwn(transition, "selectedDepth") && !WORKFLOW_DEPTHS.has(transition.selectedDepth)) {
throw new TypeError("Invalid selected depth");
}
if (Object.hasOwn(transition, "mutationAuthority")) {
validateMutationAuthority(transition.mutationAuthority);
}
if (Object.hasOwn(transition, "activeModules")) validateModuleList(transition.activeModules);
if (Object.hasOwn(transition, "pushDetour")) validateGoal(transition.pushDetour, "detour goal");
if (Object.hasOwn(transition, "popDetour") && transition.popDetour !== true) {
throw new TypeError("popDetour must be true");
}
if (Object.hasOwn(transition, "pushDetour") && Object.hasOwn(transition, "popDetour")) {
throw new TypeError("Cannot push and pop a detour in one transition");
}
if (Object.hasOwn(transition, "evidenceFreshness") && !EVIDENCE_FRESHNESS.has(transition.evidenceFreshness)) {
throw new TypeError("Invalid evidence freshness");
}
if (Object.hasOwn(transition, "addEvidenceProvenance")) {
validateEvidenceProvenanceInput(transition.addEvidenceProvenance);
}
if (Object.hasOwn(transition, "addApprovalGate")) validateApprovalGateInput(transition.addApprovalGate);
if (Object.hasOwn(transition, "resolveApprovalGate")) validateStateKey(transition.resolveApprovalGate, "approval gate id");
if (Object.hasOwn(transition, "addApprovalGate") && Object.hasOwn(transition, "resolveApprovalGate") &&
transition.addApprovalGate.id === transition.resolveApprovalGate) {
throw new TypeError("Cannot add and resolve the same approval gate in one transition");
}
}
function applyWorkflowTransition(workflow, transition, now) {
const changes = [];
const previousStage = workflow.currentWorkflowStage;
if (Object.hasOwn(transition, "currentPlanPointer")) {
workflow.currentPlanPointer = transition.currentPlanPointer == null ? null : transition.currentPlanPointer.trim();
changes.push("currentPlanPointer");
}
if (Object.hasOwn(transition, "currentWorkflowStage")) {
workflow.currentWorkflowStage = transition.currentWorkflowStage;
changes.push("currentWorkflowStage");
}
if (Object.hasOwn(transition, "selectedDepth")) {
workflow.selectedDepth = transition.selectedDepth;
changes.push("selectedDepth");
}
if (Object.hasOwn(transition, "mutationAuthority")) {
workflow.mutationAuthority = transition.mutationAuthority;
changes.push("mutationAuthority");
}
if (Object.hasOwn(transition, "activeModules")) {
workflow.activeModules = [...new Set(transition.activeModules)];
changes.push("activeModules");
}
if (Object.hasOwn(transition, "pushDetour")) {
workflow.detourStack.push({
goal: transition.pushDetour.trim(),
fromStage: previousStage,
enteredAt: now,
});
changes.push("detourStack.push");
}
if (transition.popDetour === true) {
if (workflow.detourStack.length === 0) throw codedError("DETOUR_STACK_EMPTY", "No detour is available to pop");
workflow.detourStack.pop();
changes.push("detourStack.pop");
}
if (Object.hasOwn(transition, "addEvidenceProvenance")) {
const input = transition.addEvidenceProvenance;
workflow.evidenceProvenance.push({
source: input.source,
reference: input.reference.trim(),
capturedAt: input.capturedAt ?? now,
recordedAt: now,
});
if (!Object.hasOwn(transition, "evidenceFreshness")) {
workflow.evidenceFreshness = { status: "unknown", assessedAt: null };
}
changes.push("evidenceProvenance.add");
}
if (Object.hasOwn(transition, "evidenceFreshness")) {
if (transition.evidenceFreshness === "fresh" && workflow.evidenceProvenance.length === 0) {
throw new TypeError("Fresh evidence requires provenance");
}
workflow.evidenceFreshness = { status: transition.evidenceFreshness, assessedAt: now };
changes.push("evidenceFreshness");
}
if (Object.hasOwn(transition, "addApprovalGate")) {
const input = transition.addApprovalGate;
if (workflow.pendingApprovalGates.some((gate) => gate.id === input.id)) {
throw codedError("APPROVAL_GATE_EXISTS", `Approval gate already exists: ${input.id}`);
}
workflow.pendingApprovalGates.push({
id: input.id,
summary: input.summary.trim(),
requestedAt: now,
});
changes.push("pendingApprovalGates.add");
}
if (Object.hasOwn(transition, "resolveApprovalGate")) {
const index = workflow.pendingApprovalGates.findIndex((gate) => gate.id === transition.resolveApprovalGate);
if (index === -1) {
throw codedError("APPROVAL_GATE_NOT_FOUND", `Approval gate not found: ${transition.resolveApprovalGate}`);
}
workflow.pendingApprovalGates.splice(index, 1);
changes.push("pendingApprovalGates.resolve");
}
validateWorkflowState(workflow, "updated workflow");
return changes;
}
function workflowReconstruction(state, run) {
const currentDetour = run.workflow.detourStack.at(-1);
return {
runId: run.id,
status: run.status,
isActive: state.activeRunId === run.id,
currentPlan: state.activeRunId === run.id ? state.currentPlan : currentPlanProjection(run.id, run.workflow, run.updatedAt),
currentPlanPointer: run.workflow.currentPlanPointer,
originalGoal: run.workflow.originalGoal,
currentGoal: currentDetour?.goal ?? run.workflow.originalGoal,
detourStack: run.workflow.detourStack,
currentWorkflowStage: run.workflow.currentWorkflowStage,
selectedDepth: run.workflow.selectedDepth,
mutationAuthority: run.workflow.mutationAuthority,
activeModules: run.workflow.activeModules,
evidenceFreshness: run.workflow.evidenceFreshness,
evidenceProvenance: run.workflow.evidenceProvenance,
pendingApprovalGates: run.workflow.pendingApprovalGates,
effects: run.effects,
};
}
function currentPlanProjection(runId, workflow, at) {
return workflow.currentPlanPointer == null
? null
: { runId, pointer: workflow.currentPlanPointer, updatedAt: validIsoOrNow(at) };
}
function validateCurrentPlanProjection(state, file) {
if (state.currentPlan == null) {
const active = state.activeRunId == null ? null : state.runs[state.activeRunId];
if (active?.workflow.currentPlanPointer != null) {
throw new Error(`Current plan projection is missing in ${file}`);
}
return;
}
assertPlainRecord(state.currentPlan, `current plan in ${file}`, new Set(["runId", "pointer", "updatedAt"]));
validateRunId(state.currentPlan.runId);
validateOptionalPointer(state.currentPlan.pointer, false);
validateIso(state.currentPlan.updatedAt, "current plan timestamp");
if (state.activeRunId !== state.currentPlan.runId || !Object.hasOwn(state.runs, state.currentPlan.runId)) {
throw new Error(`Current plan is not owned by the active run in ${file}`);
}
if (state.runs[state.currentPlan.runId].workflow.currentPlanPointer !== state.currentPlan.pointer) {
throw new Error(`Current plan projection is inconsistent in ${file}`);
}
}
function validateDetour(detour) {
assertPlainRecord(detour, "detour", new Set(["goal", "fromStage", "enteredAt"]));
validateGoal(detour.goal, "detour goal");
validateWorkflowToken(detour.fromStage, "detour source stage");
validateIso(detour.enteredAt, "detour timestamp");
}
function validateEvidenceFreshness(value) {
assertPlainRecord(value, "evidence freshness", new Set(["status", "assessedAt"]));
if (!EVIDENCE_FRESHNESS.has(value.status)) throw new TypeError("Invalid evidence freshness");
if (value.assessedAt != null) validateIso(value.assessedAt, "evidence assessment timestamp");
if (value.status !== "unknown" && value.assessedAt == null) {
throw new TypeError("Assessed evidence freshness requires a timestamp");
}
}
function validateEvidenceProvenanceInput(entry) {
assertPlainRecord(entry, "evidence provenance input", new Set(["source", "reference", "capturedAt"]));
validateWorkflowToken(entry.source, "evidence source");
validateCompactLine(entry.reference, "evidence reference", 2_048);
if (entry.capturedAt != null) validateIso(entry.capturedAt, "evidence capture timestamp");
}
function validateEvidenceProvenance(entry) {
assertPlainRecord(entry, "evidence provenance", new Set(["source", "reference", "capturedAt", "recordedAt"]));
validateWorkflowToken(entry.source, "evidence source");
validateCompactLine(entry.reference, "evidence reference", 2_048);
validateIso(entry.capturedAt, "evidence capture timestamp");
validateIso(entry.recordedAt, "evidence record timestamp");
}
function validateApprovalGateInput(gate) {
assertPlainRecord(gate, "approval gate input", new Set(["id", "summary"]));
validateStateKey(gate.id, "approval gate id");
validateGoal(gate.summary, "approval gate summary", 2_048);
}
function validateApprovalGate(gate) {
assertPlainRecord(gate, "approval gate", new Set(["id", "summary", "requestedAt"]));
validateStateKey(gate.id, "approval gate id");
validateGoal(gate.summary, "approval gate summary", 2_048);
validateIso(gate.requestedAt, "approval gate timestamp");
}
function validateModuleList(modules) {
if (!Array.isArray(modules) || modules.length > 64) throw new TypeError("Invalid active modules");
const seen = new Set();
for (const moduleName of modules) {
validateStateKey(moduleName, "active module");
if (seen.has(moduleName)) throw new TypeError(`Duplicate active module: ${moduleName}`);
seen.add(moduleName);
}
}
function validateOptionalPointer(value, nullable = true) {
if (nullable && value == null) return;
validateCompactLine(value, "current plan pointer", 2_048);
}
function validateWorkflowToken(value, label) {
validateStateKey(value, label);
}
function validateMutationAuthority(value) {
validateWorkflowToken(value, "mutation authority");
if (!MUTATION_AUTHORITIES.has(value)) throw new TypeError("Unsupported mutation authority");
}
function validateGoal(value, label, max = 20_000) {
if (typeof value !== "string" || !value.trim() || value.length > max || value.includes("\0")) {
throw new TypeError(`Invalid ${label}`);
}
}
function validateCompactLine(value, label, max) {
if (typeof value !== "string" || !value.trim() || value.length > max || /[\r\n\0]/.test(value)) {
throw new TypeError(`Invalid ${label}`);
}
}
function validateIso(value, label) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) ||
Number.isNaN(Date.parse(value))) {
throw new TypeError(`Invalid ${label}`);
}
}
function validIsoOrNow(value) {
try {
validateIso(value, "timestamp");
return value;
} catch {
return new Date().toISOString();
}
}
function assertPlainRecord(value, label, allowedKeys) {
if (!value || typeof value !== "object" || Array.isArray(value) ||
![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
throw new TypeError(`Invalid ${label}`);
}
for (const key of Object.keys(value)) {
if (["__proto__", "prototype", "constructor"].includes(key) || !allowedKeys.has(key)) {
throw new TypeError(`Unknown ${label} field: ${key}`);
}
}
}
function normalizeRecord(value, validateKey, label) {
if (value == null) return Object.create(null);
if (typeof value !== "object" || Array.isArray(value)) throw new TypeError(`Invalid ${label} record`);
const normalized = Object.create(null);
for (const [key, child] of Object.entries(value)) {
validateKey(key);
normalized[key] = child;
}
return normalized;
}
function ownedEffect(state, runId, effectKey) {
const run = Object.hasOwn(state.runs ?? {}, runId) ? state.runs[runId] : null;
if (!run || !Object.hasOwn(run.effects ?? {}, effectKey)) return null;
return run.effects[effectKey];
}
function validateRunId(value) {
validateStateKey(value, "run id");
}
function validateEffectKey(value) {
validateStateKey(value, "external effect key");
}
function validateStateKey(value, label) {
if (typeof value !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/.test(value) ||
["__proto__", "prototype", "constructor"].includes(value)) {
throw new TypeError(`Invalid ${label}`);
}
}
function stableIdempotencyKey(projectId, runId, effectKey) {
const digest = createHash("sha256")
.update(String(projectId)).update("\0")
.update(runId).update("\0")
.update(effectKey)
.digest("hex");
return `gstack_${digest}`;
}
function isoNow(now) {
return (now ? now() : new Date()).toISOString();
}
function jsonSafe(value) {
if (value === undefined) return null;
try {
return JSON.parse(JSON.stringify(value));
} catch {
return String(value);
}
}
function codedError(code, message) {
const error = new Error(message);
error.code = code;
return error;
}
+201
View File
@@ -0,0 +1,201 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function pathExists(file) {
try {
await fs.access(file, fsConstants.F_OK);
return true;
} catch (error) {
if (["ENOENT", "ENOTDIR"].includes(error?.code)) return false;
throw error;
}
}
export async function readJson(file, fallback) {
try {
const raw = await fs.readFile(file, "utf8");
return JSON.parse(raw);
} catch (error) {
if (error?.code === "ENOENT" && arguments.length >= 2) return fallback;
if (error instanceof SyntaxError) {
error.message = `Invalid JSON in ${file}: ${error.message}`;
}
throw error;
}
}
export async function atomicWriteFile(file, data, options = {}) {
const directory = path.dirname(file);
const mode = options.mode ?? 0o644;
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
const temp = path.join(directory, `.${path.basename(file)}.tmp-${process.pid}-${randomUUID()}`);
let handle;
try {
handle = await fs.open(temp, "wx", mode);
await handle.writeFile(data, options.encoding ?? "utf8");
await handle.sync();
await handle.chmod(mode);
await handle.close();
handle = undefined;
await replaceFile(temp, file);
// umask does not get to weaken the privacy guarantee on secret files.
await fs.chmod(file, mode);
await syncDirectory(directory);
} catch (error) {
if (handle) await handle.close().catch(() => {});
await fs.rm(temp, { force: true }).catch(() => {});
throw error;
}
}
export async function atomicWriteJson(file, value, options = {}) {
const serialized = `${JSON.stringify(value, null, 2)}\n`;
await atomicWriteFile(file, serialized, options);
}
async function replaceFile(source, destination) {
try {
await fs.rename(source, destination);
} catch (error) {
// Windows cannot always rename over an existing file. Preserve the old
// copy until the new name is in place so a failed activation is recoverable.
if (!(["EEXIST", "EPERM", "EACCES"].includes(error?.code))) throw error;
const backup = `${destination}.replace-${process.pid}-${randomUUID()}`;
let backedUp = false;
try {
await fs.rename(destination, backup);
backedUp = true;
} catch (backupError) {
if (backupError?.code !== "ENOENT") throw error;
}
try {
await fs.rename(source, destination);
if (backedUp) await fs.rm(backup, { force: true });
} catch (replacementError) {
if (backedUp) await fs.rename(backup, destination).catch(() => {});
throw replacementError;
}
}
}
async function syncDirectory(directory) {
// Directory fsync is supported on Unix and not consistently on Windows.
try {
const handle = await fs.open(directory, "r");
await handle.sync();
await handle.close();
} catch (error) {
if (!(["EINVAL", "ENOTSUP", "EISDIR", "EPERM", "EACCES"].includes(error?.code))) {
throw error;
}
}
}
export async function acquireLock(lockPath, options = {}) {
const timeoutMs = options.timeoutMs ?? 10_000;
const staleMs = options.staleMs ?? 120_000;
const started = Date.now();
const token = randomUUID();
await fs.mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
for (let attempt = 0; ; attempt += 1) {
try {
await fs.mkdir(lockPath, { mode: 0o700 });
const owner = { token, pid: process.pid, hostname: os.hostname(), createdAt: new Date().toISOString() };
await atomicWriteJson(path.join(lockPath, "owner.json"), owner, { mode: 0o600 });
const heartbeatMs = Math.max(1_000, Math.min(30_000, Math.floor(staleMs / 3)));
const heartbeat = setInterval(() => {
const now = new Date();
fs.utimes(lockPath, now, now).catch(() => {});
}, heartbeatMs);
heartbeat.unref?.();
let released = false;
return async () => {
if (released) return;
released = true;
clearInterval(heartbeat);
try {
const current = await readJson(path.join(lockPath, "owner.json"), null);
if (current?.token === token) await fs.rm(lockPath, { recursive: true, force: true });
} catch {
// Locks are leases. A stale-lock reaper may already have removed it.
}
};
} catch (error) {
if (error?.code !== "EEXIST") throw error;
await reapStaleLock(lockPath, staleMs);
if (Date.now() - started >= timeoutMs) {
const timeout = new Error(`Timed out waiting for lock ${lockPath}`);
timeout.code = "LOCK_TIMEOUT";
throw timeout;
}
const delay = Math.min(20, 2 + Math.floor(attempt / 3));
await sleep(delay);
}
}
}
async function reapStaleLock(lockPath, staleMs) {
try {
const stat = await fs.stat(lockPath);
if (Date.now() - stat.mtimeMs <= staleMs) return false;
const owner = await readJson(path.join(lockPath, "owner.json"), null).catch(() => null);
if (owner?.hostname === os.hostname() && processIsAlive(owner.pid)) return false;
const staleName = `${lockPath}.stale-${process.pid}-${randomUUID()}`;
await fs.rename(lockPath, staleName);
await fs.rm(staleName, { recursive: true, force: true });
return true;
} catch (error) {
if (["ENOENT", "EEXIST", "ENOTEMPTY"].includes(error?.code)) return false;
throw error;
}
}
function processIsAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error?.code === "EPERM";
}
}
export async function withLock(lockPath, callback, options = {}) {
const release = await acquireLock(lockPath, options);
try {
return await callback();
} finally {
await release();
}
}
export async function appendJsonLine(file, value, options = {}) {
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
const handle = await fs.open(file, "a", options.mode ?? 0o600);
try {
await handle.writeFile(`${JSON.stringify(value)}\n`, "utf8");
await handle.sync();
await handle.chmod(options.mode ?? 0o600);
} finally {
await handle.close();
}
}
export async function ensurePrivateFile(file, initial = "") {
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
try {
const handle = await fs.open(file, "wx", 0o600);
await handle.writeFile(initial, "utf8");
await handle.sync();
await handle.close();
} catch (error) {
if (error?.code !== "EEXIST") throw error;
}
await fs.chmod(file, 0o600);
}
+347
View File
@@ -0,0 +1,347 @@
import fs from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { assertPathInside, resolveRuntimePaths } from "./paths.js";
import { atomicWriteJson, pathExists, readJson } from "./storage.js";
import {
assertManagedHome,
ensureManagedHome,
recoverRuntimeTransactionUnlocked,
withRuntimeLifecycleLock,
} from "./managed-home.js";
export async function stageUpgrade(options) {
const home = path.resolve(options.home);
return withRuntimeLifecycleLock(home, async () => {
await ensureManagedHome(home, options);
await recoverRuntimeTransactionUnlocked(home);
return stageUpgradeUnlocked({ ...options, home });
}, options);
}
/**
* Internal transaction primitive used by the managed installer while it holds
* the one lifecycle lock. Callers may update stable launchers/manifest in
* beforeActivate and restore their snapshot in onRollback.
*/
export async function stageUpgradeUnlocked(options) {
const { home, sourceDir } = options;
const version = validateVersion(options.version);
const paths = resolveRuntimePaths({ home });
const source = path.resolve(sourceDir);
await validateStageSource(source);
await fs.mkdir(paths.versions, { recursive: true, mode: 0o700 });
await recoverPendingUpgradeUnlocked(paths, options);
const previousExists = await pathExists(paths.versionPointer);
const previous = await readJson(paths.versionPointer, emptyPointer());
validatePointer(previous);
const destination = assertPathInside(paths.versions, path.join(paths.versions, version));
let staged = false;
if (!(await pathExists(destination))) {
const stage = assertPathInside(
paths.versions,
path.join(paths.versions, `.stage-${version}-${randomUUID()}`),
);
try {
await copyDirectory(source, stage);
await atomicWriteJson(path.join(stage, ".gstack-version.json"), {
schemaVersion: 2,
version,
stagedAt: isoNow(options.now),
}, { mode: 0o644 });
await assertTreeContainsNoLinks(stage);
if (options.verify) await options.verify(stage);
await fs.rename(stage, destination);
staged = true;
} catch (error) {
await fs.rm(stage, { recursive: true, force: true }).catch(() => {});
throw error;
}
} else {
if (!(await isRealDirectory(destination))) {
throw upgradeError(`Version destination is not a real directory: ${version}`, "UPGRADE_DESTINATION_INVALID");
}
await assertTreeContainsNoLinks(destination);
if (options.verify) await options.verify(destination);
}
const lastKnownGood = previous.status === "active" && previous.current && previous.current !== version
? previous.current
: previous.lastKnownGood ?? null;
const active = {
schemaVersion: 2,
status: "active",
current: version,
lastKnownGood,
activatedAt: isoNow(options.now),
verifiedAt: isoNow(options.now),
};
try {
// Health runs while the old active pointer remains visible. A candidate is
// never published as `pending`, so concurrent launchers cannot execute it.
if (options.healthCheck) await options.healthCheck(destination);
if (options.beforeActivate) await options.beforeActivate({
active,
previous,
previousExists,
destination,
staged,
paths,
});
await atomicWriteJson(paths.versionPointer, active, { mode: 0o600 });
if (options.afterActivate) await options.afterActivate({
active,
previous,
previousExists,
destination,
staged,
paths,
});
return { pointer: active, path: destination, staged };
} catch (cause) {
const rollbackErrors = [];
let pointerRollbackError = null;
try {
if (previousExists) {
await atomicWriteJson(paths.versionPointer, previous, { mode: 0o600 });
} else {
await atomicWriteJson(paths.versionPointer, {
...emptyPointer(),
status: "rolled_back",
failedVersion: version,
rolledBackAt: isoNow(options.now),
}, { mode: 0o600 });
}
} catch (error) {
pointerRollbackError = error;
rollbackErrors.push(error);
}
try {
if (options.onRollback) await options.onRollback({
active,
previous,
previousExists,
destination,
staged,
paths,
cause,
pointerRollbackError,
});
} catch (error) {
rollbackErrors.push(error);
}
const error = upgradeError(`Upgrade ${version} failed health checks and was rolled back`, "UPGRADE_ROLLED_BACK", cause);
if (rollbackErrors.length === 1) error.rollbackError = rollbackErrors[0];
else if (rollbackErrors.length > 1) error.rollbackError = new AggregateError(rollbackErrors, "Runtime rollback was incomplete");
throw error;
}
}
export async function recoverPendingUpgrade(home, options = {}) {
const resolved = path.resolve(home);
return withRuntimeLifecycleLock(resolved, async () => {
await assertManagedHome(resolved, options);
await recoverRuntimeTransactionUnlocked(resolved);
return recoverPendingUpgradeUnlocked(resolveRuntimePaths({ home: resolved }), options);
}, options);
}
export async function recoverPendingUpgradeUnlocked(paths, options = {}) {
const pointer = await readJson(paths.versionPointer, emptyPointer());
validatePointer(pointer);
if (pointer.status !== "pending") return { recovered: false, pointer };
const fallback = pointer.lastKnownGood;
const fallbackPath = fallback
? assertPathInside(paths.versions, path.join(paths.versions, validateVersion(fallback)))
: null;
const fallbackExists = fallbackPath && await isRealDirectory(fallbackPath);
const recovered = fallbackExists
? {
schemaVersion: 2,
status: "active",
current: fallback,
lastKnownGood: null,
recoveredFrom: pointer.current,
recoveredAt: isoNow(options.now),
}
: {
...emptyPointer(),
status: "rolled_back",
failedVersion: pointer.current,
recoveredAt: isoNow(options.now),
};
await atomicWriteJson(paths.versionPointer, recovered, { mode: 0o600 });
return { recovered: true, pointer: recovered };
}
export async function rollbackUpgrade(home, options = {}) {
const resolved = path.resolve(home);
return withRuntimeLifecycleLock(resolved, async () => {
await assertManagedHome(resolved, options);
await recoverRuntimeTransactionUnlocked(resolved);
const paths = resolveRuntimePaths({ home: resolved });
const recovered = await recoverPendingUpgradeUnlocked(paths, options);
const pointer = recovered.pointer;
if (!pointer.lastKnownGood) {
throw upgradeError("No last-known-good version is available", "NO_ROLLBACK_VERSION");
}
const fallbackVersion = validateVersion(pointer.lastKnownGood);
const fallbackPath = assertPathInside(paths.versions, path.join(paths.versions, fallbackVersion));
if (!(await isRealDirectory(fallbackPath))) {
throw upgradeError(`Last-known-good version is missing: ${fallbackVersion}`, "ROLLBACK_VERSION_MISSING");
}
await assertTreeContainsNoLinks(fallbackPath);
if (options.healthCheck) await options.healthCheck(fallbackPath);
const rolledBack = {
schemaVersion: 2,
status: "active",
current: fallbackVersion,
lastKnownGood: pointer.current ?? null,
rolledBackFrom: pointer.current ?? null,
rolledBackAt: isoNow(options.now),
};
await atomicWriteJson(paths.versionPointer, rolledBack, { mode: 0o600 });
return rolledBack;
}, options);
}
export async function activeVersion(home, options = {}) {
const recovered = await recoverPendingUpgrade(home, options);
return recovered.pointer;
}
export async function uninstallRuntime(home, options = {}) {
const resolved = path.resolve(home);
return withRuntimeLifecycleLock(resolved, async () => {
await assertManagedHome(resolved, options);
await recoverRuntimeTransactionUnlocked(resolved);
if (options.purge) {
return purgeManagedHomeUnlocked(resolved);
}
const paths = resolveRuntimePaths({ home: resolved });
await fs.rm(paths.versions, { recursive: true, force: true });
return { purged: false, preservedState: true, home: resolved };
}, options);
}
export async function purgeManagedHomeUnlocked(home) {
const resolved = path.resolve(home);
const ownership = await assertManagedHome(resolved);
const preexisting = new Set(ownership.sentinel.preexistingTopLevel ?? []);
const managedEntries = new Set([
".gstack-managed-home.json",
".gstack-runtime-transaction.json",
"bin",
"config.json",
"locks",
"migration.json",
"plans",
"projects",
"runtime-install.json",
"secrets.json",
"tmp",
"versions",
]);
const present = await fs.readdir(resolved);
const preserved = present.filter((entry) => !managedEntries.has(entry) || preexisting.has(entry));
const quarantine = `${resolved}.purge-${process.pid}-${randomUUID()}`;
await fs.mkdir(quarantine, { mode: 0o700 });
const moved = [];
try {
for (const entry of present.filter((name) => managedEntries.has(name) && !preexisting.has(name))) {
const source = assertPathInside(resolved, path.join(resolved, entry));
const destination = assertPathInside(quarantine, path.join(quarantine, entry));
await fs.rename(source, destination);
moved.push({ source, destination });
}
await fs.rm(quarantine, { recursive: true, force: true });
await fs.rmdir(resolved).catch((error) => {
if (error?.code !== "ENOTEMPTY" && error?.code !== "EEXIST") throw error;
});
return { purged: true, home: resolved, preserved };
} catch (error) {
for (const item of moved.reverse()) {
await fs.rename(item.destination, item.source).catch(() => {});
}
await fs.rmdir(quarantine).catch(() => {});
throw error;
}
}
function validateVersion(value) {
if (typeof value !== "string" || !/^[0-9A-Za-z][0-9A-Za-z._-]{0,79}$/.test(value)) {
throw new TypeError("Version must contain only letters, numbers, dots, underscores, or hyphens");
}
return value;
}
function validatePointer(pointer) {
const validStatuses = new Set(["inactive", "pending", "active", "rolled_back"]);
if (!pointer || pointer.schemaVersion !== 2 || !validStatuses.has(pointer.status)) {
throw upgradeError("Managed version pointer is missing or unsupported", "UPGRADE_POINTER_INVALID");
}
if (pointer.current != null) validateVersion(pointer.current);
if (pointer.lastKnownGood != null) validateVersion(pointer.lastKnownGood);
}
async function validateStageSource(source) {
const stat = await fs.lstat(source).catch((error) => {
if (error?.code === "ENOENT") throw upgradeError(`Upgrade source does not exist: ${source}`, "UPGRADE_SOURCE_INVALID", error);
throw error;
});
if (stat.isSymbolicLink() || !stat.isDirectory()) {
throw upgradeError("Upgrade source must be a real directory, not a symlink", "UPGRADE_SOURCE_INVALID");
}
if ((await fs.readdir(source)).length === 0) {
throw upgradeError("Upgrade source must not be empty", "UPGRADE_SOURCE_INVALID");
}
await assertTreeContainsNoLinks(source);
}
async function copyDirectory(source, destination) {
await fs.cp(source, destination, {
recursive: true,
force: false,
errorOnExist: true,
preserveTimestamps: true,
verbatimSymlinks: true,
});
}
async function assertTreeContainsNoLinks(root) {
const pending = [root];
while (pending.length > 0) {
const current = pending.pop();
const stat = await fs.lstat(current);
if (stat.isSymbolicLink()) {
throw upgradeError(`Upgrade source contains a symlink: ${current}`, "UPGRADE_SOURCE_INVALID");
}
if (stat.isDirectory()) {
for (const child of await fs.readdir(current)) pending.push(path.join(current, child));
} else if (!stat.isFile()) {
throw upgradeError(`Upgrade source contains an unsupported entry: ${current}`, "UPGRADE_SOURCE_INVALID");
}
}
}
async function isRealDirectory(directory) {
const stat = await fs.lstat(directory).catch(() => null);
return Boolean(stat?.isDirectory() && !stat.isSymbolicLink());
}
function emptyPointer() {
return { schemaVersion: 2, status: "inactive", current: null, lastKnownGood: null };
}
function isoNow(now) {
return (now ? now() : new Date()).toISOString();
}
function upgradeError(message, code, cause) {
const error = cause === undefined ? new Error(message) : new Error(message, { cause });
error.code = code;
return error;
}