mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
harden runtime packaging and verification
This commit is contained in:
+3
-13
@@ -37,8 +37,8 @@ import {
|
||||
import { rollbackUpgrade } from "./upgrade.js";
|
||||
import { installManagedRuntime, uninstallManagedRuntime } from "./install.js";
|
||||
import { assertManagedHome, withRuntimeLifecycleLock } from "./managed-home.js";
|
||||
|
||||
const RUNTIME_VERSION = "2.0.0";
|
||||
import { errorWithCode as cliError } from "./errors.js";
|
||||
import { RUNTIME_VERSION } from "./index.js";
|
||||
|
||||
export async function main(argv = process.argv.slice(2), options = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
@@ -85,7 +85,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
||||
}
|
||||
} catch (error) {
|
||||
const json = args.includes("--json");
|
||||
const safeMessage = redactSecrets(error?.message ?? String(error));
|
||||
const safeMessage = redactSensitiveText(error?.message ?? String(error));
|
||||
if (json) {
|
||||
write(stderr, `${JSON.stringify({ ok: false, error: error?.code ?? "ERROR", message: safeMessage })}\n`);
|
||||
} else {
|
||||
@@ -619,22 +619,12 @@ 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" +
|
||||
|
||||
+27
-19
@@ -20,6 +20,7 @@ const PREFIXED_CREDENTIAL = /(?:^|[^A-Za-z0-9])(?:AIza[0-9A-Za-z_-]{20,}|AKIA[0-
|
||||
// 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 UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
||||
const MAX_CREDENTIAL_DECODE_PASSES = 8;
|
||||
const MIN_OPAQUE_TOKEN_ENTROPY = 4.25;
|
||||
|
||||
@@ -125,6 +126,10 @@ export class ContextError extends Error {
|
||||
this.unsupported = Boolean(options.unsupported);
|
||||
}
|
||||
|
||||
static fromCause(code, message, cause, options = {}) {
|
||||
return new ContextError(code, message, { ...options, cause });
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name,
|
||||
@@ -171,7 +176,7 @@ export function assertPublicUrl(input) {
|
||||
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 });
|
||||
throw ContextError.fromCause("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");
|
||||
@@ -245,7 +250,7 @@ export async function assertPublicUrlResolved(input, options = {}) {
|
||||
try {
|
||||
records = await lookup(hostname, { all: true, verbatim: true });
|
||||
} catch (cause) {
|
||||
throw new ContextError("CONTEXT_BLOCKED", "Target hostname could not be resolved publicly", { cause });
|
||||
throw ContextError.fromCause("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))) {
|
||||
@@ -440,9 +445,8 @@ export class ContextClient {
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch (cause) {
|
||||
throw new ContextError("CONTEXT_BAD_RESPONSE", "Context.dev returned malformed JSON", {
|
||||
throw ContextError.fromCause("CONTEXT_BAD_RESPONSE", "Context.dev returned malformed JSON", cause, {
|
||||
status: response.status,
|
||||
cause,
|
||||
secrets: [key],
|
||||
});
|
||||
}
|
||||
@@ -564,7 +568,7 @@ function validateBaseUrl(input) {
|
||||
try {
|
||||
url = new URL(String(input));
|
||||
} catch (cause) {
|
||||
throw new ContextError("CONTEXT_BAD_RESPONSE", "Invalid Context.dev API base URL", { cause });
|
||||
throw ContextError.fromCause("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) {
|
||||
@@ -580,9 +584,7 @@ export function redactSensitiveText(message, knownSecrets = []) {
|
||||
const raw = String(secret ?? "");
|
||||
if (raw.length < 8) continue;
|
||||
exactSecrets.add(raw);
|
||||
try {
|
||||
exactSecrets.add(encodeURIComponent(raw));
|
||||
} catch {}
|
||||
if (hasWellFormedUtf16(raw)) exactSecrets.add(encodeURIComponent(raw));
|
||||
}
|
||||
for (const secret of exactSecrets) safe = safe.split(secret).join("[REDACTED]");
|
||||
safe = safe.replace(/(\bAuthorization\s*[:=]\s*)[^\r\n,}]+/gi, "$1[REDACTED]");
|
||||
@@ -644,16 +646,12 @@ function containsCredentialMaterial(value) {
|
||||
|
||||
function looksOpaqueCredential(candidate) {
|
||||
const token = candidate.replace(/[.,;:!?]+$/, "");
|
||||
if (token.length < 32 || /^[a-f0-9]{32,}$/i.test(token) || isUuid(token) || isReadablePublicSlug(token)) return false;
|
||||
if (token.length < 32 || /^[a-f0-9]{32,}$/i.test(token) || UUID.test(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;
|
||||
@@ -693,6 +691,20 @@ function isSensitiveFieldName(key) {
|
||||
/(?:credential|password|secret|signature|token)$/.test(normalized);
|
||||
}
|
||||
|
||||
function hasWellFormedUtf16(value) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const unit = value.charCodeAt(index);
|
||||
if (unit >= 0xd800 && unit <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (next < 0xdc00 || next > 0xdfff) return false;
|
||||
index += 1;
|
||||
} else if (unit >= 0xdc00 && unit <= 0xdfff) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function containsCredentialLabel(value) {
|
||||
let candidate = String(value ?? "");
|
||||
for (let pass = 0; pass < MAX_CREDENTIAL_DECODE_PASSES; pass += 1) {
|
||||
@@ -787,12 +799,8 @@ function inIpv4Cidr(value, base, bits) {
|
||||
}
|
||||
|
||||
function isPublicIpv6(address) {
|
||||
let value;
|
||||
try {
|
||||
value = ipv6BigInt(address);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// isPublicIp has already required Node's IPv6 parser to accept the address.
|
||||
const value = ipv6BigInt(address);
|
||||
if ((value >> 32n) === 0xffffn) {
|
||||
const ipv4 = Number(value & 0xffffffffn);
|
||||
return isPublicIpv4(`${ipv4 >>> 24}.${(ipv4 >>> 16) & 255}.${(ipv4 >>> 8) & 255}.${ipv4 & 255}`);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Create an operational error whose stable code can cross the CLI boundary. */
|
||||
export function errorWithCode(message, code, cause) {
|
||||
const error = cause === undefined ? new Error(message) : new Error(message, { cause });
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
// Public runtime metadata lives with the public runtime surface so the CLI and
|
||||
// embedders report one version.
|
||||
export const RUNTIME_VERSION = "2.0.0";
|
||||
|
||||
export * from "./paths.js";
|
||||
export * from "./managed-home.js";
|
||||
export * from "./storage.js";
|
||||
|
||||
+65
-12
@@ -4,6 +4,7 @@ import process from "node:process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { spawn as nodeSpawn } from "node:child_process";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { familySync as detectLibcFamilySync, GLIBC, MUSL } from "detect-libc";
|
||||
import { resolveGstackHome, resolveRuntimePaths, assertPathInside } from "./paths.js";
|
||||
import { atomicWriteFile, atomicWriteJson, pathExists, readJson } from "./storage.js";
|
||||
import { purgeManagedHomeUnlocked, stageUpgradeUnlocked } from "./upgrade.js";
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
RUNTIME_TRANSACTION_FILE,
|
||||
withRuntimeLifecycleLock,
|
||||
} from "./managed-home.js";
|
||||
import { errorWithCode as installError } from "./errors.js";
|
||||
import { currentIsoTimestamp as isoNow } from "./time.js";
|
||||
|
||||
const INSTALL_SCHEMA_VERSION = 2;
|
||||
|
||||
@@ -131,6 +134,50 @@ const RUNTIME_HELPER_TARGETS = Object.freeze([...new Set(
|
||||
Object.values(DEFAULT_RUNTIME_HELPERS).map((descriptor) => descriptor.target),
|
||||
)]);
|
||||
|
||||
/**
|
||||
* Resolve only the native packages loaded on this host. Package managers may
|
||||
* leave optional binaries for several platforms in node_modules; copying an
|
||||
* entire scope would make the managed bundle depend on that incidental state.
|
||||
*/
|
||||
export function runtimeNativePackagePaths(options = {}) {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const arch = options.arch ?? process.arch;
|
||||
const supportedArch = ["x64", "arm64"].includes(arch);
|
||||
if (!["darwin", "linux", "win32"].includes(platform) || !supportedArch) {
|
||||
throw new TypeError(`Unsupported managed-runtime platform: ${platform}-${arch}`);
|
||||
}
|
||||
|
||||
const paths = ["node_modules/@img/colour"];
|
||||
if (platform === "darwin") {
|
||||
paths.push(
|
||||
`node_modules/@img/sharp-darwin-${arch}`,
|
||||
`node_modules/@img/sharp-libvips-darwin-${arch}`,
|
||||
// The ngrok loader tries its universal macOS binary before the
|
||||
// architecture-specific fallback, so retain that single canonical copy.
|
||||
"node_modules/@ngrok/ngrok-darwin-universal",
|
||||
);
|
||||
} else if (platform === "win32") {
|
||||
paths.push(
|
||||
`node_modules/@img/sharp-win32-${arch}`,
|
||||
`node_modules/@ngrok/ngrok-win32-${arch}-msvc`,
|
||||
);
|
||||
} else {
|
||||
const libc = options.libc ?? detectLibcFamilySync();
|
||||
if (![GLIBC, MUSL].includes(libc)) {
|
||||
throw new TypeError(`Unsupported managed-runtime libc: ${String(libc)}`);
|
||||
}
|
||||
const sharpPlatform = libc === MUSL ? `linuxmusl-${arch}` : `linux-${arch}`;
|
||||
const ngrokLibc = libc === MUSL ? "musl" : "gnu";
|
||||
paths.push(
|
||||
`node_modules/@img/sharp-${sharpPlatform}`,
|
||||
`node_modules/@img/sharp-libvips-${sharpPlatform}`,
|
||||
`node_modules/@ngrok/ngrok-linux-${arch}-${ngrokLibc}`,
|
||||
);
|
||||
}
|
||||
paths.push("node_modules/@ngrok/ngrok");
|
||||
return Object.freeze(paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed bundle is deliberately narrow. Skills remain installed by a
|
||||
* standards-based Agent Skills installer; this list contains only optional
|
||||
@@ -166,10 +213,9 @@ export const DEFAULT_RUNTIME_BUNDLE = Object.freeze([
|
||||
// the compiled CLI: Sharp powers full-page screenshot resizing, while
|
||||
// ngrok is an explicit opt-in tunnel for pair-agent (never a cloud browser).
|
||||
entry("node_modules/sharp"),
|
||||
entry("node_modules/@img"),
|
||||
...runtimeNativePackagePaths().map((target) => entry(target)),
|
||||
entry("node_modules/detect-libc"),
|
||||
entry("node_modules/semver"),
|
||||
entry("node_modules/@ngrok"),
|
||||
entry("node_modules/@anthropic-ai/sdk"),
|
||||
entry(platformBinary("design/dist/design"), "core", true),
|
||||
entry("design/dist/.version", "core"),
|
||||
@@ -516,6 +562,23 @@ export async function smokeRuntimeBundle(directory, options = {}) {
|
||||
if (!/gstack/i.test(`${result?.stdout ?? ""}${result?.stderr ?? ""}`)) {
|
||||
throw installError("Runtime launcher smoke test returned an unexpected response", "INSTALL_SMOKE_FAILED");
|
||||
}
|
||||
const nativeImports = [];
|
||||
for (const packageName of ["sharp", "@ngrok/ngrok"]) {
|
||||
if (await pathExists(path.join(directory, "node_modules", packageName, "package.json"))) {
|
||||
nativeImports.push(packageName);
|
||||
}
|
||||
}
|
||||
if (nativeImports.length > 0) {
|
||||
try {
|
||||
await run(command, [
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
nativeImports.map((packageName) => `await import(${JSON.stringify(packageName)});`).join(" "),
|
||||
], { cwd: directory, capture: true });
|
||||
} catch (cause) {
|
||||
throw installError("Runtime native dependency smoke test failed", "INSTALL_SMOKE_FAILED", cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runInstallerCli(argv = process.argv.slice(2), options = {}) {
|
||||
@@ -1250,16 +1313,6 @@ function installerUsage() {
|
||||
"Install the six skills separately with: npx skills add time-attack/gstack\n";
|
||||
}
|
||||
|
||||
function installError(message, code, cause) {
|
||||
const error = cause === undefined ? new Error(message) : new Error(message, { cause });
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function isoNow(now) {
|
||||
return (now ? now() : new Date()).toISOString();
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
|
||||
if (invokedPath === import.meta.url) {
|
||||
process.exitCode = await runInstallerCli();
|
||||
|
||||
+4
-11
@@ -4,6 +4,8 @@ import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { assertPathInside, resolveRuntimePaths } from "./paths.js";
|
||||
import { atomicWriteFile, atomicWriteJson, readJson, withLock } from "./storage.js";
|
||||
import { errorWithCode as managedHomeError } from "./errors.js";
|
||||
import { currentIsoTimestamp as isoNow } from "./time.js";
|
||||
|
||||
export const MANAGED_HOME_SCHEMA_VERSION = 1;
|
||||
export const MANAGED_HOME_SENTINEL = ".gstack-managed-home.json";
|
||||
@@ -142,7 +144,8 @@ async function inspectRecognizedLegacyHome(home, entries) {
|
||||
try {
|
||||
privacyMap = JSON.parse(privacyText);
|
||||
} catch {
|
||||
return null;
|
||||
// Invalid legacy metadata is not sufficient proof that this home is ours.
|
||||
privacyMap = null;
|
||||
}
|
||||
const hasCanonicalPrivacyEntry = Array.isArray(privacyMap) && privacyMap.some((entry) =>
|
||||
entry?.pattern === "projects/*/learnings.jsonl" && entry?.class === "artifact",
|
||||
@@ -302,13 +305,3 @@ 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();
|
||||
}
|
||||
|
||||
+31
-47
@@ -4,6 +4,8 @@ import { appendJsonLine, atomicWriteJson, readJson, withLock } from "./storage.j
|
||||
import { projectPaths } from "./paths.js";
|
||||
import { discoverProjectIdentity } from "./identity.js";
|
||||
import { RUNTIME_SCHEMA_VERSION } from "./migrations.js";
|
||||
import { errorWithCode } from "./errors.js";
|
||||
import { currentIsoTimestamp as isoNow } from "./time.js";
|
||||
|
||||
const PROJECT_DIRECTORIES = ["evidence", "artifacts", "reviews", "checkpoints"];
|
||||
export const WORKFLOW_STATE_SCHEMA_VERSION = 1;
|
||||
@@ -126,9 +128,7 @@ export async function inspectProject(home, identityOrId) {
|
||||
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;
|
||||
throw errorWithCode(`No state found for project ${id}`, "STATE_NOT_FOUND");
|
||||
}
|
||||
assertSupportedState(state, paths.state);
|
||||
return { paths, state };
|
||||
@@ -143,7 +143,7 @@ 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}`);
|
||||
if (!run) throw errorWithCode(`Run not found: ${runId}`, "RUN_NOT_FOUND");
|
||||
return {
|
||||
paths,
|
||||
state,
|
||||
@@ -173,9 +173,7 @@ export async function beginRun(home, projectId, command, options = {}) {
|
||||
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;
|
||||
throw errorWithCode(`Run already exists: ${runId}`, "RUN_EXISTS");
|
||||
}
|
||||
const workflow = createWorkflowState(command, options, now);
|
||||
state.runs[runId] = {
|
||||
@@ -206,14 +204,10 @@ export async function resumeRun(home, projectId, runId, options = {}) {
|
||||
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;
|
||||
throw errorWithCode(selected ? `Run not found: ${selected}` : "No resumable run found", "RUN_NOT_FOUND");
|
||||
}
|
||||
if (run.status === "completed") {
|
||||
const error = new Error(`Run is already complete: ${run.id}`);
|
||||
error.code = "RUN_COMPLETED";
|
||||
throw error;
|
||||
throw errorWithCode(`Run is already complete: ${run.id}`, "RUN_COMPLETED");
|
||||
}
|
||||
for (const effect of Object.values(run.effects ?? {})) {
|
||||
if (effect.status === "in_progress") {
|
||||
@@ -242,14 +236,14 @@ export async function completeRun(home, projectId, runId, options = {}) {
|
||||
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) throw errorWithCode(`Run not found: ${runId}`, "RUN_NOT_FOUND");
|
||||
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");
|
||||
throw errorWithCode("Run has unresolved external effects", "EFFECTS_UNCERTAIN");
|
||||
}
|
||||
if (run.workflow.pendingApprovalGates.length) {
|
||||
throw codedError("APPROVAL_GATES_PENDING", "Run has pending approval gates");
|
||||
throw errorWithCode("Run has pending approval gates", "APPROVAL_GATES_PENDING");
|
||||
}
|
||||
run.status = "completed";
|
||||
run.completedAt = now;
|
||||
@@ -277,10 +271,10 @@ export async function updateRunWorkflow(home, projectId, runId, transition, opti
|
||||
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 (!run) throw errorWithCode(`Run not found: ${runId}`, "RUN_NOT_FOUND");
|
||||
if (run.status === "completed") throw errorWithCode(`Run is already complete: ${runId}`, "RUN_COMPLETED");
|
||||
if (state.activeRunId !== runId) {
|
||||
throw codedError("RUN_NOT_ACTIVE", `Run is not active; resume it before updating: ${runId}`);
|
||||
throw errorWithCode(`Run is not active; resume it before updating: ${runId}`, "RUN_NOT_ACTIVE");
|
||||
}
|
||||
|
||||
const changes = applyWorkflowTransition(run.workflow, transition, now);
|
||||
@@ -313,18 +307,18 @@ export async function runExternalEffect(home, projectId, runId, effectKey, execu
|
||||
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 (!run) throw errorWithCode(`Run not found: ${runId}`, "RUN_NOT_FOUND");
|
||||
if (run.status === "completed") throw errorWithCode(`Run is already complete: ${runId}`, "RUN_COMPLETED");
|
||||
if (state.activeRunId !== runId) {
|
||||
throw codedError("RUN_NOT_ACTIVE", `Run is not active; resume it before an external effect: ${runId}`);
|
||||
throw errorWithCode(`Run is not active; resume it before an external effect: ${runId}`, "RUN_NOT_ACTIVE");
|
||||
}
|
||||
if (run.workflow.pendingApprovalGates.length) {
|
||||
throw codedError("APPROVAL_REQUIRED", "Resolve pending approval gates before external effects");
|
||||
throw errorWithCode("Resolve pending approval gates before external effects", "APPROVAL_REQUIRED");
|
||||
}
|
||||
if (!EXTERNAL_EFFECT_AUTHORITIES.has(run.workflow.mutationAuthority)) {
|
||||
throw codedError(
|
||||
"MUTATION_NOT_AUTHORIZED",
|
||||
throw errorWithCode(
|
||||
`Mutation authority ${run.workflow.mutationAuthority} does not permit external effects`,
|
||||
"MUTATION_NOT_AUTHORIZED",
|
||||
);
|
||||
}
|
||||
run.effects = normalizeRecord(run.effects, validateEffectKey, "effects");
|
||||
@@ -385,9 +379,9 @@ export async function completeExternalEffect(home, projectId, runId, effectKey,
|
||||
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) throw errorWithCode(`Effect not found: ${effectKey}`, "EFFECT_NOT_FOUND");
|
||||
if (effect.status !== "in_progress") {
|
||||
throw codedError("EFFECT_NOT_IN_PROGRESS", `Effect is not in progress: ${effectKey}`);
|
||||
throw errorWithCode(`Effect is not in progress: ${effectKey}`, "EFFECT_NOT_IN_PROGRESS");
|
||||
}
|
||||
effect.status = "completed";
|
||||
effect.completedAt = now;
|
||||
@@ -406,9 +400,9 @@ export async function markEffectNotApplied(home, projectId, runId, effectKey, op
|
||||
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) throw errorWithCode(`Effect not found: ${effectKey}`, "EFFECT_NOT_FOUND");
|
||||
if (effect.status !== "uncertain") {
|
||||
throw codedError("EFFECT_NOT_UNCERTAIN", `Only an uncertain effect can be reconciled as not applied: ${effectKey}`);
|
||||
throw errorWithCode(`Only an uncertain effect can be reconciled as not applied: ${effectKey}`, "EFFECT_NOT_UNCERTAIN");
|
||||
}
|
||||
effect.status = "ready";
|
||||
effect.reconciledAt = isoNow(options.now);
|
||||
@@ -430,9 +424,9 @@ export async function markEffectApplied(home, projectId, runId, effectKey, evide
|
||||
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) throw errorWithCode(`Effect not found: ${effectKey}`, "EFFECT_NOT_FOUND");
|
||||
if (effect.status !== "uncertain") {
|
||||
throw codedError("EFFECT_NOT_UNCERTAIN", `Only an uncertain effect can be reconciled as applied: ${effectKey}`);
|
||||
throw errorWithCode(`Only an uncertain effect can be reconciled as applied: ${effectKey}`, "EFFECT_NOT_UNCERTAIN");
|
||||
}
|
||||
effect.status = "completed";
|
||||
effect.completedAt = now;
|
||||
@@ -460,7 +454,7 @@ async function markEffectUncertain(home, projectId, runId, effectKey, cause, opt
|
||||
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) throw errorWithCode(`Effect not found: ${effectKey}`, "EFFECT_NOT_FOUND");
|
||||
effect.status = "uncertain";
|
||||
effect.uncertainAt = now;
|
||||
effect.reason = String(cause?.message ?? cause ?? "unknown external error").slice(0, 500);
|
||||
@@ -507,7 +501,7 @@ function assertSupportedState(state, file) {
|
||||
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}`);
|
||||
throw errorWithCode(`State schema is newer than this runtime: ${file}`, "STATE_NEWER_THAN_RUNTIME");
|
||||
}
|
||||
if (!Number.isInteger(state.revision) || state.revision < 0) throw new Error(`Invalid state revision in ${file}`);
|
||||
state.runs = normalizeRecord(state.runs, validateRunId, "runs");
|
||||
@@ -607,7 +601,7 @@ function createWorkflowState(command, options, now) {
|
||||
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}`);
|
||||
throw errorWithCode(`Unsupported workflow schema in ${label}`, "WORKFLOW_SCHEMA_UNSUPPORTED");
|
||||
}
|
||||
validateOptionalPointer(workflow.currentPlanPointer);
|
||||
validateGoal(workflow.originalGoal, "original goal");
|
||||
@@ -705,7 +699,7 @@ function applyWorkflowTransition(workflow, transition, 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");
|
||||
if (workflow.detourStack.length === 0) throw errorWithCode("No detour is available to pop", "DETOUR_STACK_EMPTY");
|
||||
workflow.detourStack.pop();
|
||||
changes.push("detourStack.pop");
|
||||
}
|
||||
@@ -732,7 +726,7 @@ function applyWorkflowTransition(workflow, transition, now) {
|
||||
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}`);
|
||||
throw errorWithCode(`Approval gate already exists: ${input.id}`, "APPROVAL_GATE_EXISTS");
|
||||
}
|
||||
workflow.pendingApprovalGates.push({
|
||||
id: input.id,
|
||||
@@ -744,7 +738,7 @@ function applyWorkflowTransition(workflow, transition, now) {
|
||||
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}`);
|
||||
throw errorWithCode(`Approval gate not found: ${transition.resolveApprovalGate}`, "APPROVAL_GATE_NOT_FOUND");
|
||||
}
|
||||
workflow.pendingApprovalGates.splice(index, 1);
|
||||
changes.push("pendingApprovalGates.resolve");
|
||||
@@ -950,10 +944,6 @@ function stableIdempotencyKey(projectId, runId, effectKey) {
|
||||
return `gstack_${digest}`;
|
||||
}
|
||||
|
||||
function isoNow(now) {
|
||||
return (now ? now() : new Date()).toISOString();
|
||||
}
|
||||
|
||||
function jsonSafe(value) {
|
||||
if (value === undefined) return null;
|
||||
try {
|
||||
@@ -962,9 +952,3 @@ function jsonSafe(value) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function codedError(code, message) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
+22
-7
@@ -119,12 +119,10 @@ export async function acquireLock(lockPath, options = {}) {
|
||||
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.
|
||||
}
|
||||
// Locks are leases. A stale-lock reaper may already have removed it,
|
||||
// which readJson represents as null; other failures remain actionable.
|
||||
const current = await readJson(path.join(lockPath, "owner.json"), null);
|
||||
if (current?.token === token) await fs.rm(lockPath, { recursive: true, force: true });
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.code !== "EEXIST") throw error;
|
||||
@@ -168,10 +166,27 @@ function processIsAlive(pid) {
|
||||
|
||||
export async function withLock(lockPath, callback, options = {}) {
|
||||
const release = await acquireLock(lockPath, options);
|
||||
let callbackError;
|
||||
let callbackFailed = false;
|
||||
try {
|
||||
return await callback();
|
||||
} catch (error) {
|
||||
callbackFailed = true;
|
||||
callbackError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
await release();
|
||||
try {
|
||||
await release();
|
||||
} catch (releaseError) {
|
||||
if (callbackFailed) {
|
||||
throw new AggregateError(
|
||||
[callbackError, releaseError],
|
||||
`Locked operation and lock release both failed: ${lockPath}`,
|
||||
{ cause: callbackError },
|
||||
);
|
||||
}
|
||||
throw releaseError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Resolve an injectable clock and serialize it for durable runtime state. */
|
||||
export function currentIsoTimestamp(now) {
|
||||
const current = now ? now() : new Date();
|
||||
return current.toISOString();
|
||||
}
|
||||
+9
-10
@@ -9,6 +9,8 @@ import {
|
||||
recoverRuntimeTransactionUnlocked,
|
||||
withRuntimeLifecycleLock,
|
||||
} from "./managed-home.js";
|
||||
import { errorWithCode as upgradeError } from "./errors.js";
|
||||
import { currentIsoTimestamp as isoNow } from "./time.js";
|
||||
|
||||
export async function stageUpgrade(options) {
|
||||
const home = path.resolve(options.home);
|
||||
@@ -133,6 +135,13 @@ export async function stageUpgradeUnlocked(options) {
|
||||
} catch (error) {
|
||||
rollbackErrors.push(error);
|
||||
}
|
||||
if (staged) {
|
||||
try {
|
||||
await fs.rm(destination, { recursive: true, force: true });
|
||||
} 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");
|
||||
@@ -335,13 +344,3 @@ async function isRealDirectory(directory) {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user