akm-cli
Version:
akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur
294 lines (293 loc) • 14.5 kB
JavaScript
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
/**
* The `exec` unit runner — the one place a frozen workflow spawns a command:
* argv-only, detached with a SIGTERM→SIGKILL ladder against the process group,
* cwd-contained by a resolved-path recheck, bounded (timeout, output bytes,
* context size), with an allowlisted environment ({@link childEnv}). The
* caller redacts the outcome before anything is journaled.
* See docs/architecture/decisions/0003-child-env-allowlist-and-provenance.md.
*/
import fs from "node:fs";
import path from "node:path";
import { isWithinAsync } from "../../core/common.js";
import { COMMON_SPAWN_ENV_PASSTHROUGH, collectAllowlistedEnv, WIN32_SPAWN_ENV_FLOOR } from "../../core/spawn-env.js";
import { runManagedSubprocess, streamCaptureFailure, } from "../../core/subprocess.js";
import { warn } from "../../core/warn.js";
import { execContextLimits, utf8Bytes, WORKFLOW_EXEC_OUTPUT_TRUNCATED_MARKER, WORKFLOW_MAX_EXEC_OUTPUT_BYTES, WORKFLOW_UNIT_DIAGNOSTIC_CLIP, } from "../resource-limits.js";
/**
* Max characters of a failed command's stderr tail kept in its diagnostic —
* below {@link WORKFLOW_UNIT_DIAGNOSTIC_CLIP} so the journal's head-first clip
* of the composed message never cuts the tail.
*/
const EXEC_STDERR_DIAGNOSTIC_CLIP = WORKFLOW_UNIT_DIAGNOSTIC_CLIP - 500;
/**
* The default environment allowlist for an exec unit's child (`exec.passEnv`
* extends it): the agent-harness baseline plus names ordinary commands need
* and `AKM_EVENT_SOURCE`. Credentials, cloud/CI vars, and proxies reach a
* child only through `pass_env` or `env:`.
*/
export const EXEC_DEFAULT_ENV_PASSTHROUGH = [
// PATH, HOME, USER, LANG, LC_ALL, TERM, TMPDIR, AKM_EVENT_SOURCE
...COMMON_SPAWN_ENV_PASSTHROUGH,
// POSIX names a raw shell command needs beyond the agent baseline
"LOGNAME",
"SHELL",
"LC_CTYPE",
"TZ",
// SystemRoot, SystemDrive, WINDIR, COMSPEC, PATHEXT, USERPROFILE, HOMEDRIVE,
// HOMEPATH, TEMP, TMP. Named here as well as appended by `spawnEnvNamesFor`
// because this list is consumed on POSIX too, where nothing is appended.
...WIN32_SPAWN_ENV_FLOOR,
// Windows toolchain roots, deliberately not part of the floor
"APPDATA",
"LOCALAPPDATA",
"ProgramData",
"ProgramFiles",
];
/**
* Run one exec unit and map its outcome onto the dispatch vocabulary
* (`non_zero_exit`, `timeout`, `aborted`, `spawn_failed` — retryable). The
* `exec_*` reasons (cwd escape, output limit, context too large, incomplete
* capture) are deliberately outside `retry.on`. Output overflow fails only a
* unit that declared an `output:` schema.
*/
export async function runExecUnit(input) {
const cwd = await resolveExecCwd(input);
if (!cwd.ok)
return { ok: false, text: "", failureReason: cwd.failureReason, error: cwd.error };
const context = checkExecContextSize(input);
if (context)
return context;
const result = await runManagedSubprocess([...input.exec.command], {
capture: true,
cwd: cwd.path,
env: childEnv(input.exec, input.env, input.context, input.eventSource),
timeoutMs: input.timeoutMs,
// stdout is the artifact; retention is bounded (discarding past the cap, never killing).
maxOutputBytes: WORKFLOW_MAX_EXEC_OUTPUT_BYTES,
...(input.signal ? { signal: input.signal } : {}),
...(input.spawnFn ? { spawnFn: input.spawnFn } : {}),
});
const display = describeCommand(input.exec.command);
if (result.spawnError) {
return {
ok: false,
text: "",
failureReason: "spawn_failed",
error: `exec unit "${input.unitId}" could not start ${display}: ${result.spawnError.message}`,
};
}
// Whatever this unit hands back as `text` is marked when stdout was truncated
// — on the failure paths too, where `text` is a diagnostic that would
// otherwise read like the command's whole output.
const stdout = markTruncatedStdout(result);
// Abort is checked BEFORE timeout: a budget/user cancellation that raced a
// wall-clock expiry is still a cancellation, and reporting it as `timeout`
// would let a `retry.on: [timeout]` policy re-dispatch work the caller just
// cancelled.
if (result.aborted) {
return {
ok: false,
text: stdout,
failureReason: "aborted",
error: `exec unit "${input.unitId}" was cancelled while running ${display}${stderrTail(result.stderr)}`,
};
}
if (result.timedOut) {
return {
ok: false,
text: stdout,
failureReason: "timeout",
error: `exec unit "${input.unitId}" exceeded its ${input.timeoutMs}ms timeout running ${display} ` +
`and its process group was terminated${stderrTail(result.stderr)}`,
};
}
if (result.exitCode !== 0) {
return {
ok: false,
text: stdout,
failureReason: "non_zero_exit",
error: `exec unit "${input.unitId}" ran ${display} and it exited ${result.exitCode}${stderrTail(result.stderr)}`,
};
}
// An exit code of 0 does NOT prove the output was fully captured — see the
// module note above. Checked before the artifact is promoted, so a partial
// stdout can never become `steps.<id>.output`.
const captureFailure = streamCaptureFailure(result.stdoutRead, DRAINED_CLEAN);
if (captureFailure) {
return {
ok: false,
text: "",
failureReason: "exec_capture_incomplete",
error: `exec unit "${input.unitId}" ran ${display} and the command COMPLETED (it exited 0), but its stdout could ` +
`not be fully captured (${captureFailure}), so the stdout artifact would be incomplete. The unit is NOT ` +
`retried: the command already ran, and re-dispatching identical argv to fix a capture problem would run its ` +
`side effects a second time. A background descendant still holding stdout open is the usual cause — have ` +
`the command wait for its children, or redirect their output${stderrTail(result.stderr)}`,
};
}
reportStderrCaptureFailure(input, display, result);
// The command exited 0 and the pipes drained to their end. The ONE thing an
// overflow can still ruin is a TYPED artifact: a truncated prefix is not one
// JSON value, so there is nothing to validate and nothing safe to promote.
if (result.stdoutRead.overflowed && input.hasOutputSchema) {
return outputLimitFailure(input, display, result);
}
// The artifact is stdout with trailing newlines stripped, like `$(…)`; stderr is diagnostic only.
return { ok: true, text: stripTrailingNewlines(stdout) };
}
/** A clean drain report, passed as the other pipe so {@link streamCaptureFailure} classifies one. */
const DRAINED_CLEAN = {
text: "",
timedOut: false,
overflowed: false,
bytesRead: 0,
retainedBytes: 0,
};
/** Warn about an unfinished stderr drain on an otherwise successful unit (its stdout artifact is whole). */
function reportStderrCaptureFailure(input, display, result) {
const stderrFailure = streamCaptureFailure(DRAINED_CLEAN, result.stderrRead);
if (!stderrFailure)
return;
warn(`exec unit "${input.unitId}" ran ${display} and it exited 0 with its stdout fully captured, but ` +
`${stderrFailure}. stderr is a diagnostic channel and never contributes to the artifact, so the unit stands; ` +
`any stderr shown for it may be missing its tail. A background descendant still holding stderr open is the ` +
`usual cause.`);
}
/** The sentence naming what the retention cap discarded, shared by both reports below. */
function truncationNote(read) {
return (`the command wrote ${read.bytesRead} bytes to stdout and only the first ${read.retainedBytes} were retained ` +
`(the ${WORKFLOW_MAX_EXEC_OUTPUT_BYTES}-byte per-pipe capture limit)`);
}
/** The captured stdout, with a truncation block naming both byte counts when the cap discarded some. */
function markTruncatedStdout(result) {
const read = result.stdoutRead;
if (!read.overflowed)
return result.stdout;
const discarded = read.bytesRead - read.retainedBytes;
return (`${result.stdout}\n\n[${WORKFLOW_EXEC_OUTPUT_TRUNCATED_MARKER}] ` +
`stdout was TRUNCATED: ${truncationNote(read)}. ` +
`The remaining ${discarded} bytes were read and discarded — the command itself ran to completion, ` +
`so its exit code is real, but THIS TEXT IS INCOMPLETE and must not be treated as the command's whole output. ` +
`Have the command write bulk output to a file and print the path, or quiet it down.`);
}
/** The output-cap failure for a unit with an `output:` schema: no partial text, byte counts in the message. */
function outputLimitFailure(input, display, result) {
return {
ok: false,
text: "",
failureReason: "exec_output_limit",
error: `exec unit "${input.unitId}" ran ${display}, it exited 0, but ${truncationNote(result.stdoutRead)}. ` +
`This unit declares an output: schema, so its stdout must parse as exactly one JSON value — a truncated ` +
`prefix cannot, and promoting it would silently corrupt every downstream reference to the typed artifact. ` +
`NO artifact was promoted. Have the command write bulk output to a file and print the path, quiet it down, ` +
`or drop the output: schema if the step does not actually need a typed artifact.`,
};
}
/**
* Name the `AKM_*` variable that would not fit in the child's environment on
* this platform ({@link execContextLimits}), instead of a bare E2BIG from the
* spawn. Only the engine-authored context is measured.
*/
function checkExecContextSize(input) {
const limits = execContextLimits(input.platform ?? process.platform);
const entries = Object.entries(input.context ?? {});
let total = 0;
for (const [name, value] of entries) {
const bytes = utf8Bytes(value);
total += bytes + utf8Bytes(name) + 1;
if (bytes > limits.perVarBytes) {
return contextTooLarge(input, `its ${name} context variable is ${bytes} bytes, over the ${limits.perVarBytes}-byte per-variable limit`, name, limits);
}
}
if (total > limits.totalBytes) {
return contextTooLarge(input, `its AKM_* context variables total ${total} bytes, over the ${limits.totalBytes}-byte limit`, entries.map(([name]) => name).join(", "), limits);
}
return undefined;
}
function contextTooLarge(input, what, names, limits) {
return {
ok: false,
text: "",
failureReason: "exec_context_too_large",
error: `exec unit "${input.unitId}" cannot be spawned: ${what}. ` +
`Environment variables (${names}) are how a frozen argv receives data, and this platform caps them ` +
`(${limits.source}) — spawning would fail with a bare E2BIG. ` +
`Have the producing step emit a REFERENCE (a file path, an id) instead of inline bulk data, narrow the step's ` +
`declared inputs:, or reduce the fan-out item size.`,
};
}
/**
* Resolve `exec.cwd` inside `baseDir` and prove containment against the
* resolved base (a symlinked `reports` could point at `/etc`). Async: it runs
* per unit on the dispatch path.
*/
async function resolveExecCwd(input) {
const base = path.resolve(input.baseDir);
const target = input.exec.cwd ? path.resolve(base, input.exec.cwd) : base;
if (!(await isWithinAsync(target, base))) {
return {
ok: false,
failureReason: "exec_cwd_escape",
error: `exec unit "${input.unitId}" declares cwd ${JSON.stringify(input.exec.cwd ?? ".")}, which resolves to ` +
`${target} — outside its working directory ${base}. Refusing to run outside the unit's tree.`,
};
}
if (!(await isExistingDirectory(target))) {
return {
ok: false,
failureReason: "spawn_failed",
error: `exec unit "${input.unitId}" cannot run: its working directory ${target} does not exist or is not a directory.`,
};
}
return { ok: true, path: target };
}
async function isExistingDirectory(candidate) {
try {
return (await fs.promises.stat(candidate)).isDirectory();
}
catch {
return false;
}
}
/**
* The child's environment, in precedence order: the allowlist plus
* `exec.passEnv`; the resolved `env:` bindings; then the engine's `AKM_*`
* context, last so a binding cannot shadow it.
*/
function childEnv(exec, bindings, context, eventSource) {
const env = collectAllowlistedEnv(execAllowlist(exec));
// Only when absent from the base, and before the overlays, so ambient and authored values win.
if (eventSource !== undefined && env.AKM_EVENT_SOURCE === undefined) {
env.AKM_EVENT_SOURCE = eventSource;
}
for (const [name, value] of Object.entries(bindings ?? {}))
env[name] = value;
for (const [name, value] of Object.entries(context ?? {}))
env[name] = value;
return env;
}
/** The unit's effective allowlist: the shared default plus its own `passEnv` names. */
function execAllowlist(exec) {
return exec.passEnv ? [...EXEC_DEFAULT_ENV_PASSTHROUGH, ...exec.passEnv] : [...EXEC_DEFAULT_ENV_PASSTHROUGH];
}
/** `argv[0]` plus its argument count — never the full argv, which can carry values. */
function describeCommand(command) {
const rest = command.length - 1;
return `${JSON.stringify(command[0])} (${rest} argument${rest === 1 ? "" : "s"})`;
}
/** The tail of a failed command's stderr, clipped and explicitly marked when truncated. */
function stderrTail(stderr) {
const trimmed = stderr.trim();
if (!trimmed)
return "";
if (trimmed.length <= EXEC_STDERR_DIAGNOSTIC_CLIP)
return `. stderr:\n${trimmed}`;
return `. stderr (last ${EXEC_STDERR_DIAGNOSTIC_CLIP} chars):\n…${trimmed.slice(-EXEC_STDERR_DIAGNOSTIC_CLIP)}`;
}
/** Strip trailing line terminators, matching shell `$(…)` command substitution. */
function stripTrailingNewlines(text) {
return text.replace(/(?:\r?\n)+$/, "");
}