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
274 lines (273 loc) • 13.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 shell + frozen-script dispatch arm: `runNativeTask`, plus its
* shell-command builders (`shellCommand`, `resolveLeadingBareAkmCommand`,
* `quoteShellArgument`).
*
* Moved from src/tasks/runner.ts (spec docs/plans/specs/p1b-model-extraction.md
* §5.1, §9, runner.ts:294-455).
*
* F-1 (spec §5.2 point 1): the child env's `AKM_EVENT_SOURCE` stamp now reads
* `provenance.eventSource` instead of the hardcoded literal `"task"` — with
* the default provenance context (run/provenance.ts) this is byte-equivalent
* to before (P-06 stays green unchanged).
*
* F-2 (D8, spec §5.3): the result's `target` is now `{kind:"shell", cmd}` or
* `{kind:"script", cmd}` — formerly one shared `{kind:"command", cmd}`.
*/
import path from "node:path";
import { assertNever } from "../../core/assert.js";
import { runManagedSubprocess, streamCaptureFailure } from "../../core/subprocess.js";
import { assertFrozenDirectoryContained } from "../../execution/directory-identity.js";
import { cleanupFrozenScript, frozenScriptCommand, materializeFrozenScript } from "../frozen-script.js";
import { resolveAkmInvocation } from "../resolve-akm-bin.js";
import { finishAttempt } from "./attempt-lifecycle.js";
import { DEFAULT_SCHEDULED_TASK_TIMEOUT_MS } from "./run-workflow-task.js";
import { appendHistory } from "./task-history.js";
import { persistRunLog, streamLines } from "./task-log.js";
/**
* Resolve the host shell to something spawnable in a scheduler-fired process.
*
* A scheduled run restores the PATH captured at install time
* (scheduler-invocation.ts), which can be minimal — the native-scheduler CI
* gate installs with PATH = System32 + SystemRoot, modeling what a real
* scheduler hands a job. powershell.exe does not live in System32 itself but
* in the WindowsPowerShell\v1.0 subdirectory, so a bare "powershell" spawn
* resolves only when that PATH happens to carry the extra entry; cmd.exe has
* a canonical ComSpec location for the same reason. Resolve both absolutely
* on Windows. pwsh has no fixed install location and the POSIX shells always
* live on the scheduler's default /bin:/usr/bin, so those stay PATH-resolved.
*
* Exported for direct unit testing with an explicit platform/env.
*/
export function shellExecutable(shell, platform = process.platform, env = process.env) {
if (platform !== "win32")
return shell;
const systemRoot = env.SystemRoot ?? "C:\\Windows";
if (shell === "powershell") {
return path.win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
}
if (shell === "cmd") {
return env.ComSpec ?? path.win32.join(systemRoot, "System32", "cmd.exe");
}
return shell;
}
/** Exported for direct unit testing with an explicit platform/env. */
export function shellCommand(task, platform = process.platform, env = process.env) {
const command = resolveLeadingBareAkmCommand(task.command, task.shell);
switch (task.shell) {
case "sh":
case "bash":
case "zsh":
return [task.shell, "-c", command];
case "pwsh":
case "powershell":
return [
shellExecutable(task.shell, platform, env),
"-NoProfile",
"-NonInteractive",
"-Command",
withExitCodePropagation(command),
];
case "cmd":
return [shellExecutable("cmd", platform, env), "/d", "/s", "/c", command];
default:
return assertNever(task.shell, "shellCommand");
}
}
/**
* `-Command` (documented identically for powershell.exe 5.1 and pwsh 7+, in
* about_PowerShell_exe / about_Pwsh) already derives its own process exit
* code from `$?`, so a genuinely failing last statement already yields a
* nonzero exit and status "failed" — but any native exit code outside {0, 1}
* is collapsed to 1, discarding the real value that task history reports.
*
* Appending a bare `exit $LASTEXITCODE` to recover it is unsafe on its own:
* `$LASTEXITCODE` stays `$null` for a command that never runs a native
* executable (a pure PowerShell/cmdlet command), and `exit $null` resolves
* to exit code 0 — turning a failed cmdlet into a false "completed".
*
* Reading `$?` first, before anything else can run, reproduces -Command's
* own completed/failed determination exactly (immune to that regression and
* to a `$LASTEXITCODE` left stale by an earlier native call in the same
* command), then upgrades to the precise native exit code only when the
* failing last statement actually set one.
*/
function withExitCodePropagation(command) {
return `${command}; if ($?) { exit 0 } elseif ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE } else { exit 1 }`;
}
/**
* Bind an unambiguous leading bare `akm` (including the task-v2 migrator's
* quoted form) to this installation. Explicit paths and arbitrary shell
* fragments remain author-controlled.
*/
function resolveLeadingBareAkmCommand(command, shell) {
const leadingBareAkm = /^(\s*)(?:akm(?:\.exe)?|'akm(?:\.exe)?'|"akm(?:\.exe)?")(?=$|[\s;|&])/i;
if (!leadingBareAkm.test(command))
return command;
const quoted = resolveAkmInvocation()
.argv.map((part) => quoteShellArgument(part, shell))
.join(" ");
// PowerShell parses a quoted string in command position as a string
// EXPRESSION, not an invocation — `'C:\akm.exe' --version` is a parse
// error. The call operator makes it a command. sh and cmd both treat a
// quoted word in command position as the command, so only PowerShell
// needs the prefix.
const invocation = shell === "pwsh" || shell === "powershell" ? `& ${quoted}` : quoted;
return command.replace(leadingBareAkm, (_match, leadingWhitespace) => `${leadingWhitespace}${invocation}`);
}
function quoteShellArgument(value, shell) {
switch (shell) {
case "sh":
case "bash":
case "zsh":
return `'${value.replaceAll("'", `'"'"'`)}'`;
case "pwsh":
case "powershell":
return `'${value.replaceAll("'", "''")}'`;
case "cmd":
return `"${value.replaceAll('"', '""')}"`;
default:
return assertNever(shell, "quoteShellArgument");
}
}
export async function runNativeTask(input) {
const { task, logPath, startedAt, now, historyReserved, provenance } = input;
let materialized;
let cmd = task.kind === "shell" ? shellCommand(task) : [];
// Unset → the unattended default; `null` → the explicit no-timeout opt-out.
const timeoutMs = task.timeoutMs !== undefined ? task.timeoutMs : DEFAULT_SCHEDULED_TASK_TIMEOUT_MS;
const header = task.kind === "shell"
? `[akm task] task=${task.taskId} kind=run shell=${task.shell}`
: `[akm task] task=${task.taskId} kind=script ref=${task.sourceRef} sha256=${task.sha256}`;
const logLines = [header];
const dbLines = [{ line: header }];
let exitCode = null;
// #956: the task runner's OWN process (`akm task run`, launched by cron /
// launchd / schtasks, or forwarded a SIGTERM by the published launcher)
// had no way to end its detached, own-process-group
// child (spawned by `runManagedSubprocess` for the group-kill guarantee
// above) — a timeout or SIGTERM to the direct child would reap the whole
// group, but nothing tied THIS process's own termination to that same
// ladder, so a signal to the runner left its child running as an orphan.
// Aborting `runManagedSubprocess` here runs its normal SIGTERM→SIGKILL
// kill ladder against the child's process group.
const abortController = new AbortController();
const forwardTerminationSignal = (signal) => {
abortController.abort(new Error(`task runner received ${signal}`));
};
const onSigterm = () => forwardTerminationSignal("SIGTERM");
const onSigint = () => forwardTerminationSignal("SIGINT");
process.once("SIGTERM", onSigterm);
process.once("SIGINT", onSigint);
try {
// Re-resolve the authored root/cwd immediately before spawn so a
// symlink, ancestor, bundle-root, or directory/file swap cannot redirect
// execution outside that physical workspace. This is a live containment
// check, not an identity comparison — it does not care whether the
// underlying device/inode changed (e.g. a remount), only whether the
// resolved cwd still lives inside its root.
assertFrozenDirectoryContained(task.cwdIdentity);
if (task.kind === "script") {
materialized = materializeFrozenScript(task);
cmd = frozenScriptCommand(task, materialized.file);
}
// Managed spawn (src/core/subprocess.ts): process-GROUP kill so a timeout
// reaps the whole command tree (no orphans), and a SIGTERM→SIGKILL ladder
// so a child that ignores SIGTERM can't wedge the run forever. `signal`
// ties that same ladder to a termination signal received by this
// process itself (#956), not only to the timeout.
const result = await runManagedSubprocess(cmd, {
capture: true,
cwd: task.cwd,
// Stamp task-runner provenance so any akm invocation in the command tree
// records usage events as machine traffic, not user demand (DRIFT-6).
// A more specific stamp already in the environment (e.g. improve's
// AKM_EVENT_SOURCE=improve on its child spawns) still wins in children.
env: {
...process.env,
...task.environment,
AKM_EVENT_SOURCE: process.env.AKM_EVENT_SOURCE ?? provenance.eventSource,
},
// cmd.exe's `/S /C` reads its tail as one hand-quoted command line, not
// standard argv — shellCommand() already built and quoted it for that.
// The default per-argument escaping would add an incompatible second
// layer on top and break any resolved path containing a space.
windowsVerbatimArguments: task.kind === "shell" && task.shell === "cmd",
timeoutMs,
signal: abortController.signal,
...(input.spawnFn ? { spawnFn: input.spawnFn } : {}),
...(input.setTimeoutFn ? { setTimeoutFn: input.setTimeoutFn } : {}),
...(input.clearTimeoutFn ? { clearTimeoutFn: input.clearTimeoutFn } : {}),
});
// A synchronous spawn throw / exit rejection surfaces as spawn_error below.
if (result.spawnError)
throw result.spawnError;
const { stdout, stderr, timedOut } = result;
exitCode = result.exitCode ?? (timedOut ? 143 : 1);
// A pipe that errored or stopped draining yields EMPTY output that is
// otherwise indistinguishable from a command that printed nothing — the
// log reads like a clean success. Say so instead, the way the workflow
// (exec-unit.ts) and agent (agent/spawn.ts) capture paths already do.
const captureFailure = streamCaptureFailure(result.stdoutRead, result.stderrRead);
if (captureFailure) {
const line = `output_capture_incomplete=${captureFailure}`;
logLines.push(line);
dbLines.push({ level: "warn", line });
}
if (timedOut) {
logLines.push(`timed_out=true timeout_ms=${timeoutMs}`);
dbLines.push({ level: "error", line: `timed_out=true timeout_ms=${timeoutMs}` });
}
logLines.push(`exit_code=${exitCode}`);
dbLines.push({ level: exitCode === 0 ? "info" : "error", line: `exit_code=${exitCode}` });
if (stdout) {
logLines.push("--- stdout ---");
logLines.push(stdout);
dbLines.push(...streamLines(stdout, "stdout", "info"));
}
if (stderr) {
logLines.push("--- stderr ---");
logLines.push(stderr);
dbLines.push(...streamLines(stderr, "stderr", "error"));
}
}
catch (e) {
const msg = e instanceof Error ? e.message : String(e);
logLines.push(`spawn_error=${msg}`);
dbLines.push({ level: "error", line: `spawn_error=${msg}` });
exitCode = 1;
}
finally {
process.off("SIGTERM", onSigterm);
process.off("SIGINT", onSigint);
if (materialized)
cleanupFrozenScript(materialized);
}
const finishedAt = finishAttempt(startedAt, now());
persistRunLog({
taskId: task.taskId,
startedAtIso: startedAt.toISOString(),
finishedAtIso: finishedAt.toISOString(),
logPath,
fileText: `${logLines.join("\n")}\n`,
dbLines,
redactNames: task.redact,
environment: task.environment,
});
const status = exitCode === 0 ? "completed" : "failed";
const result = {
id: task.taskId,
status,
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
durationMs: finishedAt.getTime() - startedAt.getTime(),
log: logPath,
target: task.kind === "shell" ? { kind: "shell", cmd } : { kind: "script", cmd },
detail: { exitCode },
};
appendHistory(result, historyReserved);
return result;
}