akm-cli 0.9.0 → 0.9.1-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +724 -0
  2. package/README.md +28 -63
  3. package/STABILITY.md +4 -2
  4. package/dist/cli/parse-args.js +7 -1
  5. package/dist/commands/agent/contribute-cli.js +1 -1
  6. package/dist/commands/env/child-env.js +14 -0
  7. package/dist/commands/feedback-cli.js +7 -1
  8. package/dist/commands/health/llm-usage.js +2 -1
  9. package/dist/commands/health/surfaces.js +4 -77
  10. package/dist/commands/health.js +65 -11
  11. package/dist/commands/improve/distill/quality-gate.js +6 -1
  12. package/dist/commands/improve/eligibility.js +7 -1
  13. package/dist/commands/improve/eval-cases.js +2 -0
  14. package/dist/commands/improve/improve.js +126 -10
  15. package/dist/commands/improve/locks.js +7 -0
  16. package/dist/commands/improve/memory/memory-improve.js +9 -0
  17. package/dist/commands/improve/run-context.js +5 -0
  18. package/dist/commands/improve/session-asset.js +4 -0
  19. package/dist/commands/lint/base-linter.js +31 -7
  20. package/dist/commands/lint/index.js +205 -51
  21. package/dist/commands/lint/types.js +22 -1
  22. package/dist/commands/proposal/repository.js +17 -1
  23. package/dist/commands/sources/add-cli.js +8 -2
  24. package/dist/commands/sources/info.js +12 -2
  25. package/dist/commands/sources/installed-stashes.js +6 -1
  26. package/dist/commands/sources/migration-help.js +12 -3
  27. package/dist/commands/sources/self-update.js +9 -1
  28. package/dist/commands/tasks/tasks.js +8 -2
  29. package/dist/commands/workflow-cli.js +17 -11
  30. package/dist/core/abort-deadline.js +28 -0
  31. package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
  32. package/dist/core/adapter/adapters/akm-adapter.js +13 -10
  33. package/dist/core/adapter/adapters/akm-lint.js +78 -22
  34. package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
  35. package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
  36. package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
  37. package/dist/core/asset/frontmatter.js +10 -1
  38. package/dist/core/common.js +147 -9
  39. package/dist/core/concurrent.js +32 -0
  40. package/dist/core/config/config-io.js +5 -45
  41. package/dist/core/config/schema/engines.js +14 -3
  42. package/dist/core/config/schema/workflow.js +11 -0
  43. package/dist/core/errors.js +25 -0
  44. package/dist/core/events.js +30 -24
  45. package/dist/core/extra-params.js +11 -0
  46. package/dist/core/file-lock.js +7 -1
  47. package/dist/core/fs-txn.js +15 -2
  48. package/dist/core/improve-result.js +5 -0
  49. package/dist/core/json-schema.js +344 -9
  50. package/dist/core/loopback.js +89 -0
  51. package/dist/core/migration-operation.js +17 -2
  52. package/dist/core/path-access.js +107 -0
  53. package/dist/core/paths.js +16 -2
  54. package/dist/core/redaction.js +86 -18
  55. package/dist/core/spawn-env.js +234 -0
  56. package/dist/core/state-db-scope.js +134 -0
  57. package/dist/core/state-db.js +1 -0
  58. package/dist/core/subprocess.js +181 -37
  59. package/dist/core/write-provenance.js +85 -0
  60. package/dist/core/write-source.js +33 -2
  61. package/dist/indexer/db/graph-db.js +17 -6
  62. package/dist/indexer/ensure-index.js +10 -3
  63. package/dist/indexer/index-written-assets.js +17 -2
  64. package/dist/indexer/indexer.js +86 -21
  65. package/dist/indexer/passes/memory-inference.js +4 -0
  66. package/dist/indexer/search/db-search.js +25 -17
  67. package/dist/indexer/walk/walker.js +6 -1
  68. package/dist/integrations/agent/detect.js +13 -1
  69. package/dist/integrations/agent/engine-resolution.js +24 -11
  70. package/dist/integrations/agent/model-aliases.js +1 -1
  71. package/dist/integrations/agent/profiles.js +9 -1
  72. package/dist/integrations/agent/spawn.js +15 -87
  73. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
  74. package/dist/integrations/lockfile.js +55 -2
  75. package/dist/llm/client.js +14 -19
  76. package/dist/llm/embedder.js +23 -3
  77. package/dist/llm/embedders/remote.js +27 -2
  78. package/dist/output/html-render.js +40 -1
  79. package/dist/output/text/lint-format.js +17 -4
  80. package/dist/runtime.js +23 -1
  81. package/dist/scripts/akm-migrate-node.js +1714 -836
  82. package/dist/scripts/akm-migrate.js +1682 -804
  83. package/dist/setup/setup.js +22 -7
  84. package/dist/sources/providers/git-install.js +25 -2
  85. package/dist/sources/providers/git-stash.js +19 -0
  86. package/dist/sources/providers/git.js +1 -1
  87. package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
  88. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
  89. package/dist/storage/database.js +71 -7
  90. package/dist/storage/engines/sqlite-migrations.js +61 -2
  91. package/dist/storage/managed-db.js +19 -0
  92. package/dist/storage/repositories/index-connection.js +39 -4
  93. package/dist/storage/repositories/index-entries-repository.js +6 -1
  94. package/dist/storage/repositories/index-meta-repository.js +11 -0
  95. package/dist/storage/repositories/index-schema.js +17 -2
  96. package/dist/storage/repositories/index-vec-repository.js +43 -5
  97. package/dist/storage/repositories/workflow-runs-repository.js +66 -13
  98. package/dist/storage/sqlite-pragmas.js +12 -1
  99. package/dist/tasks/log-redaction.js +156 -0
  100. package/dist/tasks/parser.js +82 -5
  101. package/dist/tasks/runner.js +222 -17
  102. package/dist/tasks/scheduler-invocation.js +19 -0
  103. package/dist/tasks/schema.js +86 -1
  104. package/dist/text-import-hook.mjs +1 -1
  105. package/dist/workflows/concurrency-policy.js +95 -1
  106. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  107. package/dist/workflows/exec/exec-unit.js +542 -0
  108. package/dist/workflows/exec/frozen-judge.js +114 -42
  109. package/dist/workflows/exec/native-executor.js +465 -238
  110. package/dist/workflows/exec/param-secrets.js +4 -3
  111. package/dist/workflows/exec/run-workflow.js +424 -219
  112. package/dist/workflows/exec/step-work.js +506 -167
  113. package/dist/workflows/exec/unit-dispatch.js +31 -1
  114. package/dist/workflows/exec/unit-writer.js +53 -13
  115. package/dist/workflows/exec/worktree.js +454 -41
  116. package/dist/workflows/ir/compile.js +26 -2
  117. package/dist/workflows/ir/freeze.js +82 -15
  118. package/dist/workflows/ir/schema.js +105 -20
  119. package/dist/workflows/parser.js +242 -19
  120. package/dist/workflows/program/schema.js +24 -0
  121. package/dist/workflows/renderer.js +32 -4
  122. package/dist/workflows/resource-limits.js +182 -0
  123. package/dist/workflows/runtime/runs.js +146 -6
  124. package/dist/workflows/validate-summary.js +17 -2
  125. package/docs/README.md +74 -32
  126. package/docs/migration/release-notes/0.9.0.md +2 -1
  127. package/docs/migration/v0.7-to-v0.8.md +2 -1
  128. package/docs/migration/v0.8-to-v0.9.md +3 -1
  129. package/docs/reference/README.md +11 -4
  130. package/docs/reference/bundle-types.md +19 -0
  131. package/docs/reference/cli.md +105 -16
  132. package/docs/reference/configuration.md +15 -2
  133. package/docs/reference/data-and-telemetry.md +30 -10
  134. package/docs/reference/supported-formats.md +50 -0
  135. package/docs/reference/workflow-schema.md +1014 -0
  136. package/docs/reference/workflows.md +37 -633
  137. package/package.json +13 -6
  138. package/schemas/akm-config.json +18 -5
  139. package/schemas/akm-task.json +27 -5
  140. package/schemas/akm-workflow.json +92 -13
@@ -0,0 +1,114 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * The ONE redaction contract every frozen-workflow dispatch is held to before
6
+ * anything about its outcome reaches durable state.
7
+ *
8
+ * This is a LEAF module on purpose. Both dispatch paths need it — the unit path
9
+ * (`exec/native-executor.ts`) and the gate-judge path (`exec/frozen-judge.ts`) —
10
+ * and the judge path is reached from `runtime/runs.ts`, which the executor's own
11
+ * dependency chain runs back into. Keeping the helpers here (importing only
12
+ * `core/redaction` plus erased types) means the judge can reuse the exact unit
13
+ * scrub without opening a runtime import cycle.
14
+ *
15
+ * @module workflows/exec/dispatch-redaction
16
+ */
17
+ import { collectSensitiveValues, isEnvPassthroughValueSafeToExpose, redactSensitiveText, redactSensitiveValue, } from "../../core/redaction.js";
18
+ /**
19
+ * Every exact value that must never survive into the journal from ONE frozen
20
+ * dispatch: the resolved `env` bindings injected into the child, the selected
21
+ * engine's (and its SDK fallback's) credential env values, and any
22
+ * `envPassthrough` value the redaction policy does not consider safe to expose.
23
+ *
24
+ * Shared by the unit path and the gate-judge path. There is deliberately ONE
25
+ * collector: a second, parallel implementation is exactly how a dispatch path
26
+ * silently loses the scrub.
27
+ *
28
+ * The credential values are read from `process.env` AT CALL TIME, so a caller
29
+ * must collect no earlier than the dispatch whose outcome it scrubs. A snapshot
30
+ * taken when the dispatch was merely *planned* can predate a credential the
31
+ * dispatch then resolves live, leaving the exact value it must remove out of
32
+ * the set.
33
+ */
34
+ export function collectWorkflowDispatchSensitiveValues(dispatch, env) {
35
+ const values = new Set(Object.values(env ?? {}));
36
+ const addCredential = (engine) => {
37
+ if (!engine)
38
+ return;
39
+ if (engine.kind === "llm") {
40
+ for (const name of engine.credential?.names ?? []) {
41
+ const value = process.env[name]?.trim();
42
+ if (value)
43
+ values.add(value);
44
+ }
45
+ return;
46
+ }
47
+ for (const name of engine.envPassthrough) {
48
+ const value = process.env[name];
49
+ if (!isEnvPassthroughValueSafeToExpose(name, value) && value)
50
+ values.add(value);
51
+ }
52
+ };
53
+ addCredential(dispatch.engine);
54
+ addCredential(dispatch.fallbackEngine);
55
+ return collectSensitiveValues(values);
56
+ }
57
+ /**
58
+ * Scrub a dispatch outcome before ANYTHING about it is journaled.
59
+ *
60
+ * The `failureReason` downgrade is part of the contract: if redaction ALTERED
61
+ * the reason, the reason itself carried a secret, and the persisted failure
62
+ * vocabulary must not become a side channel for it.
63
+ *
64
+ * Structurally typed over `{ failureReason? }` rather than importing
65
+ * `UnitOutcome` from `step-work.ts`: this module must stay a LEAF, and even an
66
+ * erased `import type` edge here would put `frozen-judge → step-work →
67
+ * runtime/runs → frozen-judge` back on the static import graph (the
68
+ * import-cycle ratchet is shrink-only). Callers keep their exact outcome type
69
+ * through the generic.
70
+ */
71
+ export function redactUnitOutcome(outcome, sensitiveValues) {
72
+ const redacted = redactSensitiveValue(outcome, sensitiveValues);
73
+ if (outcome.failureReason !== undefined && redacted.failureReason !== outcome.failureReason) {
74
+ redacted.failureReason = "reported_failure";
75
+ }
76
+ return redacted;
77
+ }
78
+ /**
79
+ * Scrub a value THROWN out of a dispatch. A rejection is as durable as a
80
+ * resolved failure — the message becomes the blocked step's notes — so it goes
81
+ * through the same value set. Re-wrapped ONLY when redaction actually changed
82
+ * the message, so an untouched throw keeps its original object (and its type,
83
+ * which callers branch on) byte-identical.
84
+ */
85
+ function redactDispatchError(err, sensitiveValues) {
86
+ if (sensitiveValues.length === 0 || !(err instanceof Error))
87
+ return err;
88
+ const redacted = redactSensitiveText(err.message, sensitiveValues);
89
+ if (redacted === err.message)
90
+ return err;
91
+ const replacement = new Error(redacted);
92
+ replacement.name = err.name;
93
+ return replacement;
94
+ }
95
+ /**
96
+ * Wrap a dispatcher so BOTH its exits are scrubbed with the request's own
97
+ * `sensitiveValues` — the outcome it resolves to and the error it throws — so a
98
+ * caller holding the wrapped dispatcher cannot observe (or journal) either one
99
+ * unredacted. Used at seams whose results head STRAIGHT for durable state (the
100
+ * gate judge, on both its agent and its llm branch); the unit path instead
101
+ * scrubs once at its own journal boundary (`dispatchJournaledAttempt`), AFTER
102
+ * the structured-output parse loop has seen the raw text.
103
+ */
104
+ export function withDispatchRedaction(inner) {
105
+ return async (request, feedback) => {
106
+ const sensitiveValues = request.sensitiveValues ?? [];
107
+ try {
108
+ return redactUnitOutcome(await inner(request, feedback), sensitiveValues);
109
+ }
110
+ catch (err) {
111
+ throw redactDispatchError(err, sensitiveValues);
112
+ }
113
+ };
114
+ }
@@ -0,0 +1,542 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * The `exec` unit runner — the ONE place a frozen workflow spawns a shell
6
+ * command as a unit. The invariants it exists to hold:
7
+ *
8
+ * - ARGV, NEVER A SHELL STRING. {@link IrExecSpec.command} is an argv ARRAY
9
+ * and the format has no shell-string spelling at all; the child is spawned
10
+ * directly, so `;`, `|`, `&&`, `$(…)`, backticks, `>` and `*` are inert
11
+ * literal argument BYTES. A workflow that wants a pipeline names the
12
+ * interpreter itself (`["bash", "-lc", "a | b"]`), visibly in frontmatter.
13
+ * - NON-BLOCKING. Everything on this path is async. A synchronous call here
14
+ * blocks the event loop and with it every concurrently-scheduled unit, the
15
+ * run's lease heartbeat, and abort handling.
16
+ * - NO LEAKED CHILDREN. {@link runManagedSubprocess} spawns DETACHED and runs
17
+ * a SIGTERM→SIGKILL ladder against the whole process group, so `--timeout`
18
+ * and Ctrl-C really do stop a running command and its descendants.
19
+ * - CONTAINMENT. `exec.cwd` is relative and `..`-free by construction (parser
20
+ * and frozen-plan decoder), which is necessary but not sufficient: a
21
+ * subdirectory can be a symlink. The RESOLVED path is therefore re-checked
22
+ * against the RESOLVED base immediately before spawning.
23
+ * - BOUNDED SPEND. A command is arbitrary code with no resource discipline of
24
+ * its own, so each resource it spends on akm's behalf has a ceiling in
25
+ * `workflows/resource-limits.ts`: wall clock ({@link DEFAULT_EXEC_TIMEOUT_MS}
26
+ * or the authored `timeout:`), retained output
27
+ * ({@link WORKFLOW_MAX_EXEC_OUTPUT_BYTES} per pipe) and the context
28
+ * environment ({@link execContextLimits}, checked BEFORE the spawn so an
29
+ * oversized artifact yields an actionable akm error, not a bare `E2BIG`).
30
+ * - ALLOWLISTED ENVIRONMENT. The child does NOT inherit akm's environment: it
31
+ * starts EMPTY and receives exactly {@link EXEC_DEFAULT_ENV_PASSTHROUGH}
32
+ * plus the unit's `exec.passEnv`, then the resolved `env:` bindings, then
33
+ * the engine-authored `AKM_*` context. `exec.inheritEnv` opts back into full
34
+ * inheritance. See {@link childEnv}.
35
+ *
36
+ * Secrets: `env` values reaching this module are already resolved from `env:`
37
+ * bindings by NAME (`resolveEnvBinding`) — the plan never carries inline secrets
38
+ * and the input hash only ever carries names. The caller scrubs the outcome with
39
+ * `redactUnitOutcome` BEFORE anything is journaled, which is why this module may
40
+ * return raw stdout/stderr diagnostics without knowing anything about redaction.
41
+ *
42
+ * Layering: a LEAF. Node built-ins, `core/spawn-env`, `core/subprocess`,
43
+ * `core/warn` and the import-free `workflows/resource-limits` (plus erased
44
+ * types) only, so the executor can consume it without opening an import cycle.
45
+ *
46
+ * @module workflows/exec/exec-unit
47
+ */
48
+ import fs from "node:fs";
49
+ import path from "node:path";
50
+ import { isWithinAsync } from "../../core/common.js";
51
+ import { COMMON_SPAWN_ENV_PASSTHROUGH, collectAllowlistedEnv, supplementPathForSchedulerContext, WIN32_SPAWN_ENV_FLOOR, } from "../../core/spawn-env.js";
52
+ import { runManagedSubprocess, streamCaptureFailure, } from "../../core/subprocess.js";
53
+ import { warn } from "../../core/warn.js";
54
+ import { execContextLimits, utf8Bytes, WORKFLOW_EXEC_OUTPUT_TRUNCATED_MARKER, WORKFLOW_MAX_EXEC_OUTPUT_BYTES, WORKFLOW_UNIT_DIAGNOSTIC_CLIP, } from "../resource-limits.js";
55
+ /**
56
+ * Max characters of a failed command's stderr retained in the unit's `error`
57
+ * diagnostic.
58
+ *
59
+ * Deliberately BELOW {@link WORKFLOW_UNIT_DIAGNOSTIC_CLIP}: the composed
60
+ * diagnostic reads `<what happened>. stderr (last N chars): <tail>`, and the
61
+ * journal clips that COMPOSED string head-first. Reserving 500 characters for
62
+ * the prefix keeps the whole stderr tail — the part that actually says why the
63
+ * command failed — inside the journaled and displayed diagnostic, instead of
64
+ * losing its final few hundred characters to the outer clip.
65
+ */
66
+ const EXEC_STDERR_DIAGNOSTIC_CLIP = WORKFLOW_UNIT_DIAGNOSTIC_CLIP - 500;
67
+ /**
68
+ * The DEFAULT environment allowlist for an exec unit's child — the single
69
+ * definition of the EXEC list (the docs describe it, the tests assert against
70
+ * it, and `exec.passEnv` extends it per unit). The win32 process-creation
71
+ * names are not re-spelled here: they are spread from
72
+ * {@link WIN32_SPAWN_ENV_FLOOR}, which owns them.
73
+ *
74
+ * The child starts from an EMPTY environment and receives only these names,
75
+ * matching how an agent harness child is already built
76
+ * (`profile.envPassthrough` → `collectAllowlistedEnv`) — and literally
77
+ * extending the same {@link COMMON_SPAWN_ENV_PASSTHROUGH} baseline those
78
+ * profiles start from, so the two child-spawn allowlists share one floor.
79
+ * Every entry earns its place by being load-bearing for ordinary commands on
80
+ * some supported platform:
81
+ *
82
+ * - `PATH` — command resolution. Without it only an absolute `argv[0]`
83
+ * can ever be spawned.
84
+ * - `HOME` — the config/cache root essentially every toolchain reads
85
+ * (git, npm, bun, cargo, ssh). Absent, tools fall back to
86
+ * `/` or fail outright.
87
+ * - `USER`, `LOGNAME` — process identity; git and ssh read them to attribute
88
+ * and authenticate.
89
+ * - `SHELL` — read by tools that re-exec a login shell for the user's
90
+ * own environment (an explicit `["bash", "-lc", …]` argv
91
+ * does not need it, but `git`'s pagers/editors do).
92
+ * - `LANG`, `LC_ALL`, `LC_CTYPE` — text encoding. Without a locale a command
93
+ * falls back to the C locale and mangles non-ASCII stdout,
94
+ * which IS this unit's artifact.
95
+ * - `TERM` — some CLIs abort or emit raw escape bytes with no TERM.
96
+ * - `TZ` — timestamps a command prints would otherwise silently
97
+ * switch to the host default.
98
+ * - `TMPDIR` — POSIX scratch space; absent, tools write to `/tmp` or
99
+ * fail on read-only hosts.
100
+ * - {@link WIN32_SPAWN_ENV_FLOOR} — what Windows itself requires of any
101
+ * child (process creation, command resolution, and the
102
+ * win32 analogues of `HOME`/`TMPDIR`). Spread in rather
103
+ * than re-spelled: `spawnEnvNamesFor` appends the same
104
+ * names on win32, and two hand-written copies of one OS
105
+ * requirement is exactly how a floor drifts.
106
+ * - `APPDATA`, `LOCALAPPDATA` — Windows config/cache roots (npm, bun, git).
107
+ * - `ProgramData`, `ProgramFiles` — machine-wide install roots that Windows
108
+ * toolchain shims resolve against. These four are NOT on
109
+ * the floor (a child is creatable without them), so an
110
+ * arbitrary shell command asks for them here.
111
+ * - `AKM_EVENT_SOURCE` — provenance, never a secret: an exec unit that calls
112
+ * `akm` must record machine traffic rather than user
113
+ * demand, exactly as the agent passthrough list does
114
+ * (`integrations/agent/profiles.ts`, DRIFT-6).
115
+ *
116
+ * Deliberately ABSENT and reachable only through `exec.passEnv` / `env:` /
117
+ * `exec.inheritEnv`: credentials of every kind, cloud/CI vars, and the proxy
118
+ * family (`HTTP_PROXY` & friends) — proxy URLs routinely embed credentials,
119
+ * which is why akm's redaction policy already treats URL-shaped passthrough
120
+ * values as credential-bearing.
121
+ */
122
+ export const EXEC_DEFAULT_ENV_PASSTHROUGH = [
123
+ // PATH, HOME, USER, LANG, LC_ALL, TERM, TMPDIR, AKM_EVENT_SOURCE
124
+ ...COMMON_SPAWN_ENV_PASSTHROUGH,
125
+ // POSIX names a raw shell command needs beyond the agent baseline
126
+ "LOGNAME",
127
+ "SHELL",
128
+ "LC_CTYPE",
129
+ "TZ",
130
+ // SystemRoot, SystemDrive, WINDIR, COMSPEC, PATHEXT, USERPROFILE, HOMEDRIVE,
131
+ // HOMEPATH, TEMP, TMP. Named here as well as appended by `spawnEnvNamesFor`
132
+ // because this list is consumed on POSIX too, where nothing is appended.
133
+ ...WIN32_SPAWN_ENV_FLOOR,
134
+ // Windows toolchain roots, deliberately not part of the floor
135
+ "APPDATA",
136
+ "LOCALAPPDATA",
137
+ "ProgramData",
138
+ "ProgramFiles",
139
+ ];
140
+ /**
141
+ * Run one exec unit and map its process outcome onto the dispatch vocabulary:
142
+ * non-zero exit → `non_zero_exit`, wall-clock expiry → `timeout`, cancellation
143
+ * → `aborted`, a child that never started → `spawn_failed`. All four are
144
+ * pre-existing `AgentFailureReason` members, so `retry.on` keeps working
145
+ * unchanged. The out-of-taxonomy `exec_cwd_escape`, `exec_output_limit`,
146
+ * `exec_context_too_large` and `exec_capture_incomplete` are deliberate: each is
147
+ * tampering, a runaway, an authoring bug, or work that ALREADY RAN — never a
148
+ * transient — so no `retry.on` value can ever re-dispatch one.
149
+ *
150
+ * ## An INCOMPLETE STDOUT capture is a failure, never a partial artifact
151
+ *
152
+ * `exitCode === 0` is not on its own proof that stdout was fully read: a pipe
153
+ * can error, and the stream-drain timeout can fire while the command LEADER has
154
+ * already exited 0 because a background descendant still holds the stdout fd
155
+ * open. Both leave a PREFIX, and promoting it would hand the next step, the gate
156
+ * judge and `steps.<id>.output` a silently truncated artifact. So the unit fails
157
+ * instead, through the same shared classifier (`streamCaptureFailure`) the agent
158
+ * spawn path uses.
159
+ *
160
+ * Only STDOUT is fatal here. stderr is a diagnostic channel that never
161
+ * contributes to the artifact, so a stderr drain that did not finish leaves the
162
+ * unit's actual result — a completed command, a fully captured stdout — intact;
163
+ * failing it would throw away a valid artifact over a lost log tail. The agent
164
+ * path classifies both pipes because there stderr genuinely feeds its
165
+ * diagnostics; the difference lives in the CALLERS, not in the shared
166
+ * classifier.
167
+ *
168
+ * The stdout reason is `exec_capture_incomplete`, deliberately OUTSIDE the
169
+ * `retry.on` taxonomy, for the same reason `journal_write_failed` is: the
170
+ * command RAN TO COMPLETION and exited 0 — what failed is akm's record of it. A
171
+ * retryable reason here would let `retry.on: [spawn_failed]` re-dispatch
172
+ * byte-identical argv for a command that already deployed, already published,
173
+ * already migrated. `spawn_failed` keeps its documented meaning — the child
174
+ * never started.
175
+ *
176
+ * ## Output OVERFLOW does not fail a command that passed
177
+ *
178
+ * Crossing {@link WORKFLOW_MAX_EXEC_OUTPUT_BYTES} is a different condition: the
179
+ * reader DID drain the pipe to its end, it just stopped RETAINING, so the child
180
+ * never blocked and its exit code is real. Failing a passing-but-chatty test
181
+ * suite over its log volume would be a tripwire, so overflow splits by what the
182
+ * unit PROMISED about its output:
183
+ *
184
+ * - NO declared `output:` schema → success, with the artifact carrying a
185
+ * {@link WORKFLOW_EXEC_OUTPUT_TRUNCATED_MARKER} block naming both byte
186
+ * counts, so truncated text can never pass for complete text.
187
+ * - a declared `output:` schema → `exec_output_limit`: stdout must parse as
188
+ * EXACTLY one JSON value, a truncated prefix cannot, and promoting it would
189
+ * corrupt every downstream reference to the typed artifact.
190
+ *
191
+ * stderr overflow never fails anything: stderr is a diagnostic channel, and
192
+ * {@link EXEC_STDERR_DIAGNOSTIC_CLIP} already bounds and marks what reaches the
193
+ * journal.
194
+ */
195
+ export async function runExecUnit(input) {
196
+ const cwd = await resolveExecCwd(input);
197
+ if (!cwd.ok)
198
+ return { ok: false, text: "", failureReason: cwd.failureReason, error: cwd.error };
199
+ const context = checkExecContextSize(input);
200
+ if (context)
201
+ return context;
202
+ const result = await runManagedSubprocess([...input.exec.command], {
203
+ capture: true,
204
+ cwd: cwd.path,
205
+ env: childEnv(input.exec, input.env, input.context),
206
+ timeoutMs: input.timeoutMs,
207
+ // stdout IS this unit's artifact, so RETENTION is BOUNDED: an unbounded
208
+ // capture is memory the akm process spends on a command's behalf with no
209
+ // ceiling at all until it exits or the (default 10-minute) budget expires.
210
+ // The cap discards past the bound rather than killing — the command's own
211
+ // outcome is not akm's memory problem to solve.
212
+ maxOutputBytes: WORKFLOW_MAX_EXEC_OUTPUT_BYTES,
213
+ ...(input.signal ? { signal: input.signal } : {}),
214
+ ...(input.spawnFn ? { spawnFn: input.spawnFn } : {}),
215
+ });
216
+ const display = describeCommand(input.exec.command);
217
+ if (result.spawnError) {
218
+ return {
219
+ ok: false,
220
+ text: "",
221
+ failureReason: "spawn_failed",
222
+ error: `exec unit "${input.unitId}" could not start ${display}: ${result.spawnError.message}`,
223
+ };
224
+ }
225
+ // Whatever this unit hands back as `text` is marked when stdout was truncated
226
+ // — on the failure paths too, where `text` is a diagnostic that would
227
+ // otherwise read like the command's whole output.
228
+ const stdout = markTruncatedStdout(result);
229
+ // Abort is checked BEFORE timeout: a budget/user cancellation that raced a
230
+ // wall-clock expiry is still a cancellation, and reporting it as `timeout`
231
+ // would let a `retry.on: [timeout]` policy re-dispatch work the caller just
232
+ // cancelled.
233
+ if (result.aborted) {
234
+ return {
235
+ ok: false,
236
+ text: stdout,
237
+ failureReason: "aborted",
238
+ error: `exec unit "${input.unitId}" was cancelled while running ${display}${stderrTail(result.stderr)}`,
239
+ };
240
+ }
241
+ if (result.timedOut) {
242
+ return {
243
+ ok: false,
244
+ text: stdout,
245
+ failureReason: "timeout",
246
+ error: `exec unit "${input.unitId}" exceeded its ${input.timeoutMs}ms timeout running ${display} ` +
247
+ `and its process group was terminated${stderrTail(result.stderr)}`,
248
+ };
249
+ }
250
+ if (result.exitCode !== 0) {
251
+ return {
252
+ ok: false,
253
+ text: stdout,
254
+ failureReason: "non_zero_exit",
255
+ error: `exec unit "${input.unitId}" ran ${display} and it exited ${result.exitCode}${stderrTail(result.stderr)}`,
256
+ };
257
+ }
258
+ // An exit code of 0 does NOT prove the output was fully captured — see the
259
+ // module note above. Checked before the artifact is promoted, so a partial
260
+ // stdout can never become `steps.<id>.output`.
261
+ const captureFailure = streamCaptureFailure(result.stdoutRead, DRAINED_CLEAN);
262
+ if (captureFailure) {
263
+ return {
264
+ ok: false,
265
+ text: "",
266
+ failureReason: "exec_capture_incomplete",
267
+ error: `exec unit "${input.unitId}" ran ${display} and the command COMPLETED (it exited 0), but its stdout could ` +
268
+ `not be fully captured (${captureFailure}), so the stdout artifact would be incomplete. The unit is NOT ` +
269
+ `retried: the command already ran, and re-dispatching identical argv to fix a capture problem would run its ` +
270
+ `side effects a second time. A background descendant still holding stdout open is the usual cause — have ` +
271
+ `the command wait for its children, or redirect their output${stderrTail(result.stderr)}`,
272
+ };
273
+ }
274
+ reportStderrCaptureFailure(input, display, result);
275
+ // The command exited 0 and the pipes drained to their end. The ONE thing an
276
+ // overflow can still ruin is a TYPED artifact: a truncated prefix is not one
277
+ // JSON value, so there is nothing to validate and nothing safe to promote.
278
+ if (result.stdoutRead.overflowed && input.hasOutputSchema) {
279
+ return outputLimitFailure(input, display, result);
280
+ }
281
+ // The promoted artifact is STDOUT. Trailing newlines are stripped, exactly
282
+ // like shell command substitution `$(…)`, so a one-line command's artifact is
283
+ // the value an author expects rather than the value plus a `\n`. stderr is a
284
+ // diagnostic channel only and never contributes to the artifact. When stdout
285
+ // overflowed, `stdout` already carries the truncation marker (which is
286
+ // deliberately the LAST thing in the artifact, so it survives the strip).
287
+ return { ok: true, text: stripTrailingNewlines(stdout) };
288
+ }
289
+ /**
290
+ * A drain report with nothing wrong in it, passed as the OTHER pipe so
291
+ * {@link streamCaptureFailure} classifies exactly one of them.
292
+ *
293
+ * The classifier stays shared with the agent path — what a failed drain means
294
+ * must not drift — while each caller decides which pipes are fatal for IT.
295
+ */
296
+ const DRAINED_CLEAN = {
297
+ text: "",
298
+ timedOut: false,
299
+ overflowed: false,
300
+ bytesRead: 0,
301
+ retainedBytes: 0,
302
+ };
303
+ /**
304
+ * Report a stderr drain that did not finish on an otherwise successful unit.
305
+ *
306
+ * Warn-only by construction: the artifact is stdout, which was captured whole,
307
+ * so there is nothing wrong with the unit's RESULT — only with how much of its
308
+ * log tail akm holds. A dispatch result has no channel for a non-fatal note, so
309
+ * the operator surface is the warn stream.
310
+ */
311
+ function reportStderrCaptureFailure(input, display, result) {
312
+ const stderrFailure = streamCaptureFailure(DRAINED_CLEAN, result.stderrRead);
313
+ if (!stderrFailure)
314
+ return;
315
+ warn(`exec unit "${input.unitId}" ran ${display} and it exited 0 with its stdout fully captured, but ` +
316
+ `${stderrFailure}. stderr is a diagnostic channel and never contributes to the artifact, so the unit stands; ` +
317
+ `any stderr shown for it may be missing its tail. A background descendant still holding stderr open is the ` +
318
+ `usual cause.`);
319
+ }
320
+ /** The sentence naming what the retention cap discarded, shared by both reports below. */
321
+ function truncationNote(read) {
322
+ return (`the command wrote ${read.bytesRead} bytes to stdout and only the first ${read.retainedBytes} were retained ` +
323
+ `(the ${WORKFLOW_MAX_EXEC_OUTPUT_BYTES}-byte per-pipe capture limit)`);
324
+ }
325
+ /**
326
+ * The captured stdout, with an unmistakable truncation block appended when the
327
+ * retention cap discarded part of it.
328
+ *
329
+ * Same idiom, same reason as `WORKFLOW_EVIDENCE_TRUNCATED_MARKER`
330
+ * (`runtime/runs.ts`): truncated data must never be mistakable for complete
331
+ * data. The block names both byte counts, so a reader can see exactly how much
332
+ * is missing rather than inferring it from a suspiciously round length.
333
+ */
334
+ function markTruncatedStdout(result) {
335
+ const read = result.stdoutRead;
336
+ if (!read.overflowed)
337
+ return result.stdout;
338
+ const discarded = read.bytesRead - read.retainedBytes;
339
+ return (`${result.stdout}\n\n[${WORKFLOW_EXEC_OUTPUT_TRUNCATED_MARKER}] ` +
340
+ `stdout was TRUNCATED: ${truncationNote(read)}. ` +
341
+ `The remaining ${discarded} bytes were read and discarded — the command itself ran to completion, ` +
342
+ `so its exit code is real, but THIS TEXT IS INCOMPLETE and must not be treated as the command's whole output. ` +
343
+ `Have the command write bulk output to a file and print the path, or quiet it down.`);
344
+ }
345
+ /**
346
+ * The output-cap failure for a unit that declared an `output:` schema —
347
+ * deliberately UNMISTAKABLE.
348
+ *
349
+ * `text` is emptied rather than carrying the partial capture: for a failed unit
350
+ * `text` is only a diagnostic (the durable evidence graph keeps a failure's
351
+ * `failureReason` alone), and handing back several megabytes of a runaway
352
+ * command's output as "the text" would just move the memory problem one layer
353
+ * up. The byte counts go in the message instead, so the operator can see how far
354
+ * past the cap the command ran.
355
+ */
356
+ function outputLimitFailure(input, display, result) {
357
+ return {
358
+ ok: false,
359
+ text: "",
360
+ failureReason: "exec_output_limit",
361
+ error: `exec unit "${input.unitId}" ran ${display}, it exited 0, but ${truncationNote(result.stdoutRead)}. ` +
362
+ `This unit declares an output: schema, so its stdout must parse as exactly one JSON value — a truncated ` +
363
+ `prefix cannot, and promoting it would silently corrupt every downstream reference to the typed artifact. ` +
364
+ `NO artifact was promoted. Have the command write bulk output to a file and print the path, quiet it down, ` +
365
+ `or drop the output: schema if the step does not actually need a typed artifact.`,
366
+ };
367
+ }
368
+ /**
369
+ * Refuse to spawn when the engine-authored `AKM_*` context would not fit in the
370
+ * child's environment ON THIS PLATFORM.
371
+ *
372
+ * A workflow artifact has no bound comparable to an OS environment entry, so a
373
+ * perfectly legitimate declared input can serialize into an `AKM_INPUTS` far
374
+ * past what `execve` accepts. Left unchecked that surfaces as a bare `E2BIG`
375
+ * from the spawn syscall — reported as `spawn_failed` with a message about
376
+ * "argument list too long" that names neither the variable nor the artifact
377
+ * that produced it. Checking here converts it into a located, actionable
378
+ * failure BEFORE process creation is attempted.
379
+ *
380
+ * ## The ceiling is the CURRENT platform's, never the smallest one
381
+ *
382
+ * That translation is this check's ONLY job, which fixes its bound exactly: the
383
+ * limits come from {@link execContextLimits} for the platform the run is on. A
384
+ * guard that applied Windows' 32 767-character ceiling on Linux would fail
385
+ * spawns the kernel would happily have accepted — inventing a failure instead of
386
+ * explaining an inevitable one, which is a tripwire and not a guard. Workflows
387
+ * that must also run on Windows should stay under the smaller bound; that is
388
+ * documented guidance (`docs/reference/workflow-schema.md`), not something a
389
+ * Linux host enforces.
390
+ *
391
+ * Only the engine-authored context is measured. The unit's `env:` bindings are
392
+ * authored values a human wrote and sized; this is the surface where the SIZE
393
+ * is data-dependent and therefore surprising.
394
+ */
395
+ function checkExecContextSize(input) {
396
+ const limits = execContextLimits(input.platform ?? process.platform);
397
+ const entries = Object.entries(input.context ?? {});
398
+ let total = 0;
399
+ for (const [name, value] of entries) {
400
+ const bytes = utf8Bytes(value);
401
+ total += bytes + utf8Bytes(name) + 1;
402
+ if (bytes > limits.perVarBytes) {
403
+ return contextTooLarge(input, `its ${name} context variable is ${bytes} bytes, over the ${limits.perVarBytes}-byte per-variable limit`, name, limits);
404
+ }
405
+ }
406
+ if (total > limits.totalBytes) {
407
+ return contextTooLarge(input, `its AKM_* context variables total ${total} bytes, over the ${limits.totalBytes}-byte limit`, entries.map(([name]) => name).join(", "), limits);
408
+ }
409
+ return undefined;
410
+ }
411
+ function contextTooLarge(input, what, names, limits) {
412
+ return {
413
+ ok: false,
414
+ text: "",
415
+ failureReason: "exec_context_too_large",
416
+ error: `exec unit "${input.unitId}" cannot be spawned: ${what}. ` +
417
+ `Environment variables (${names}) are how a frozen argv receives data, and this platform caps them ` +
418
+ `(${limits.source}) — spawning would fail with a bare E2BIG. ` +
419
+ `Have the producing step emit a REFERENCE (a file path, an id) instead of inline bulk data, narrow the step's ` +
420
+ `declared inputs:, or reduce the fan-out item size.`,
421
+ };
422
+ }
423
+ /**
424
+ * Resolve `exec.cwd` inside `baseDir` and prove containment against the
425
+ * RESOLVED base (symlinks included). The syntactic checks the parser and the
426
+ * decoder already ran are necessary but not sufficient: `reports` can be a
427
+ * symlink to `/etc`, and only a realpath comparison catches that.
428
+ *
429
+ * Async on purpose: this runs once per unit — up to 10 000 times for one map
430
+ * step — on the dispatch path that must never block (see the module note).
431
+ */
432
+ async function resolveExecCwd(input) {
433
+ const base = path.resolve(input.baseDir);
434
+ const target = input.exec.cwd ? path.resolve(base, input.exec.cwd) : base;
435
+ if (!(await isWithinAsync(target, base))) {
436
+ return {
437
+ ok: false,
438
+ failureReason: "exec_cwd_escape",
439
+ error: `exec unit "${input.unitId}" declares cwd ${JSON.stringify(input.exec.cwd ?? ".")}, which resolves to ` +
440
+ `${target} — outside its working directory ${base}. Refusing to run outside the unit's tree.`,
441
+ };
442
+ }
443
+ if (!(await isExistingDirectory(target))) {
444
+ return {
445
+ ok: false,
446
+ failureReason: "spawn_failed",
447
+ error: `exec unit "${input.unitId}" cannot run: its working directory ${target} does not exist or is not a directory.`,
448
+ };
449
+ }
450
+ return { ok: true, path: target };
451
+ }
452
+ async function isExistingDirectory(candidate) {
453
+ try {
454
+ return (await fs.promises.stat(candidate)).isDirectory();
455
+ }
456
+ catch {
457
+ return false;
458
+ }
459
+ }
460
+ /**
461
+ * The child's environment, in three layers with fixed precedence:
462
+ *
463
+ * 1. the BASE — an allowlist by default ({@link EXEC_DEFAULT_ENV_PASSTHROUGH}
464
+ * plus the unit's `exec.passEnv` names), or the akm process's whole
465
+ * environment when the unit opted in with `exec.inheritEnv`;
466
+ * 2. the unit's resolved `env:` bindings;
467
+ * 3. the engine-authored `AKM_*` context, LAST so a workflow-supplied binding
468
+ * can never shadow the ids/item the engine is telling the command the
469
+ * truth about.
470
+ *
471
+ * ## Why the default is an allowlist
472
+ *
473
+ * Not because it stops an attacker: a command that runs at all can read the
474
+ * same credentials off disk that the environment would have handed it, and a
475
+ * workflow source is executed code either way (`docs/guides/run-workflows.md`,
476
+ * "workflow sources are executed code"). The allowlist earns its place for
477
+ * three narrower, real reasons:
478
+ *
479
+ * - it bounds ACCIDENTAL exposure — the ambient shell of whoever ran
480
+ * `akm workflow run` (or the CI job that did) routinely carries tokens for
481
+ * unrelated services, and a third-party workflow step that merely prints
482
+ * its environment, or a tool that ships one in a crash report, should not
483
+ * get them for free;
484
+ * - it makes the environment surface EXPLICIT and REVIEWABLE — what a
485
+ * command can see is this constant plus lines in the frontmatter diff,
486
+ * rather than "whatever the invoking shell happened to export";
487
+ * - it matches the convention akm already applies to spawned children —
488
+ * `profile.envPassthrough` in `integrations/agent/spawn.ts` has always
489
+ * built agent-harness children this way, and the SAME
490
+ * {@link collectAllowlistedEnv} does it here, so there is one mechanism to
491
+ * review instead of two.
492
+ *
493
+ * `inheritEnv` is the honest escape hatch for a command that genuinely needs the
494
+ * caller's whole environment. It passes everything through, PATH included —
495
+ * supplemented for scheduler contexts exactly as {@link collectAllowlistedEnv}
496
+ * does it, because the MORE permissive branch must never hand a command a WORSE
497
+ * PATH than the restrictive default does under cron/launchd.
498
+ */
499
+ function childEnv(exec, bindings, context) {
500
+ const env = exec.inheritEnv ? inheritedProcessEnv() : collectAllowlistedEnv(execAllowlist(exec));
501
+ for (const [name, value] of Object.entries(bindings ?? {}))
502
+ env[name] = value;
503
+ for (const [name, value] of Object.entries(context ?? {}))
504
+ env[name] = value;
505
+ return env;
506
+ }
507
+ /** The unit's effective allowlist: the shared default plus its own `passEnv` names. */
508
+ function execAllowlist(exec) {
509
+ return exec.passEnv ? [...EXEC_DEFAULT_ENV_PASSTHROUGH, ...exec.passEnv] : [...EXEC_DEFAULT_ENV_PASSTHROUGH];
510
+ }
511
+ /**
512
+ * The akm process's own environment (`exec.inheritEnv`), with the same
513
+ * scheduler-context PATH supplementation {@link collectAllowlistedEnv} applies.
514
+ */
515
+ function inheritedProcessEnv() {
516
+ const env = {};
517
+ for (const [name, value] of Object.entries(process.env)) {
518
+ if (typeof value === "string")
519
+ env[name] = value;
520
+ }
521
+ if (env.PATH !== undefined)
522
+ env.PATH = supplementPathForSchedulerContext(env.PATH);
523
+ return env;
524
+ }
525
+ /** `argv[0]` plus its argument count — never the full argv, which can carry values. */
526
+ function describeCommand(command) {
527
+ const rest = command.length - 1;
528
+ return `${JSON.stringify(command[0])} (${rest} argument${rest === 1 ? "" : "s"})`;
529
+ }
530
+ /** The tail of a failed command's stderr, clipped and explicitly marked when truncated. */
531
+ function stderrTail(stderr) {
532
+ const trimmed = stderr.trim();
533
+ if (!trimmed)
534
+ return "";
535
+ if (trimmed.length <= EXEC_STDERR_DIAGNOSTIC_CLIP)
536
+ return `. stderr:\n${trimmed}`;
537
+ return `. stderr (last ${EXEC_STDERR_DIAGNOSTIC_CLIP} chars):\n…${trimmed.slice(-EXEC_STDERR_DIAGNOSTIC_CLIP)}`;
538
+ }
539
+ /** Strip trailing line terminators, matching shell `$(…)` command substitution. */
540
+ function stripTrailingNewlines(text) {
541
+ return text.replace(/(?:\r?\n)+$/, "");
542
+ }