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.
- package/CHANGELOG.md +724 -0
- package/README.md +28 -63
- package/STABILITY.md +4 -2
- package/dist/cli/parse-args.js +7 -1
- package/dist/commands/agent/contribute-cli.js +1 -1
- package/dist/commands/env/child-env.js +14 -0
- package/dist/commands/feedback-cli.js +7 -1
- package/dist/commands/health/llm-usage.js +2 -1
- package/dist/commands/health/surfaces.js +4 -77
- package/dist/commands/health.js +65 -11
- package/dist/commands/improve/distill/quality-gate.js +6 -1
- package/dist/commands/improve/eligibility.js +7 -1
- package/dist/commands/improve/eval-cases.js +2 -0
- package/dist/commands/improve/improve.js +126 -10
- package/dist/commands/improve/locks.js +7 -0
- package/dist/commands/improve/memory/memory-improve.js +9 -0
- package/dist/commands/improve/run-context.js +5 -0
- package/dist/commands/improve/session-asset.js +4 -0
- package/dist/commands/lint/base-linter.js +31 -7
- package/dist/commands/lint/index.js +205 -51
- package/dist/commands/lint/types.js +22 -1
- package/dist/commands/proposal/repository.js +17 -1
- package/dist/commands/sources/add-cli.js +8 -2
- package/dist/commands/sources/info.js +12 -2
- package/dist/commands/sources/installed-stashes.js +6 -1
- package/dist/commands/sources/migration-help.js +12 -3
- package/dist/commands/sources/self-update.js +9 -1
- package/dist/commands/tasks/tasks.js +8 -2
- package/dist/commands/workflow-cli.js +17 -11
- package/dist/core/abort-deadline.js +28 -0
- package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
- package/dist/core/adapter/adapters/akm-adapter.js +13 -10
- package/dist/core/adapter/adapters/akm-lint.js +78 -22
- package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
- package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
- package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
- package/dist/core/asset/frontmatter.js +10 -1
- package/dist/core/common.js +147 -9
- package/dist/core/concurrent.js +32 -0
- package/dist/core/config/config-io.js +5 -45
- package/dist/core/config/schema/engines.js +14 -3
- package/dist/core/config/schema/workflow.js +11 -0
- package/dist/core/errors.js +25 -0
- package/dist/core/events.js +30 -24
- package/dist/core/extra-params.js +11 -0
- package/dist/core/file-lock.js +7 -1
- package/dist/core/fs-txn.js +15 -2
- package/dist/core/improve-result.js +5 -0
- package/dist/core/json-schema.js +344 -9
- package/dist/core/loopback.js +89 -0
- package/dist/core/migration-operation.js +17 -2
- package/dist/core/path-access.js +107 -0
- package/dist/core/paths.js +16 -2
- package/dist/core/redaction.js +86 -18
- package/dist/core/spawn-env.js +234 -0
- package/dist/core/state-db-scope.js +134 -0
- package/dist/core/state-db.js +1 -0
- package/dist/core/subprocess.js +181 -37
- package/dist/core/write-provenance.js +85 -0
- package/dist/core/write-source.js +33 -2
- package/dist/indexer/db/graph-db.js +17 -6
- package/dist/indexer/ensure-index.js +10 -3
- package/dist/indexer/index-written-assets.js +17 -2
- package/dist/indexer/indexer.js +86 -21
- package/dist/indexer/passes/memory-inference.js +4 -0
- package/dist/indexer/search/db-search.js +25 -17
- package/dist/indexer/walk/walker.js +6 -1
- package/dist/integrations/agent/detect.js +13 -1
- package/dist/integrations/agent/engine-resolution.js +24 -11
- package/dist/integrations/agent/model-aliases.js +1 -1
- package/dist/integrations/agent/profiles.js +9 -1
- package/dist/integrations/agent/spawn.js +15 -87
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
- package/dist/integrations/lockfile.js +55 -2
- package/dist/llm/client.js +14 -19
- package/dist/llm/embedder.js +23 -3
- package/dist/llm/embedders/remote.js +27 -2
- package/dist/output/html-render.js +40 -1
- package/dist/output/text/lint-format.js +17 -4
- package/dist/runtime.js +23 -1
- package/dist/scripts/akm-migrate-node.js +1714 -836
- package/dist/scripts/akm-migrate.js +1682 -804
- package/dist/setup/setup.js +22 -7
- package/dist/sources/providers/git-install.js +25 -2
- package/dist/sources/providers/git-stash.js +19 -0
- package/dist/sources/providers/git.js +1 -1
- package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
- package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
- package/dist/storage/database.js +71 -7
- package/dist/storage/engines/sqlite-migrations.js +61 -2
- package/dist/storage/managed-db.js +19 -0
- package/dist/storage/repositories/index-connection.js +39 -4
- package/dist/storage/repositories/index-entries-repository.js +6 -1
- package/dist/storage/repositories/index-meta-repository.js +11 -0
- package/dist/storage/repositories/index-schema.js +17 -2
- package/dist/storage/repositories/index-vec-repository.js +43 -5
- package/dist/storage/repositories/workflow-runs-repository.js +66 -13
- package/dist/storage/sqlite-pragmas.js +12 -1
- package/dist/tasks/log-redaction.js +156 -0
- package/dist/tasks/parser.js +82 -5
- package/dist/tasks/runner.js +222 -17
- package/dist/tasks/scheduler-invocation.js +19 -0
- package/dist/tasks/schema.js +86 -1
- package/dist/text-import-hook.mjs +1 -1
- package/dist/workflows/concurrency-policy.js +95 -1
- package/dist/workflows/exec/dispatch-redaction.js +114 -0
- package/dist/workflows/exec/exec-unit.js +542 -0
- package/dist/workflows/exec/frozen-judge.js +114 -42
- package/dist/workflows/exec/native-executor.js +465 -238
- package/dist/workflows/exec/param-secrets.js +4 -3
- package/dist/workflows/exec/run-workflow.js +424 -219
- package/dist/workflows/exec/step-work.js +506 -167
- package/dist/workflows/exec/unit-dispatch.js +31 -1
- package/dist/workflows/exec/unit-writer.js +53 -13
- package/dist/workflows/exec/worktree.js +454 -41
- package/dist/workflows/ir/compile.js +26 -2
- package/dist/workflows/ir/freeze.js +82 -15
- package/dist/workflows/ir/schema.js +105 -20
- package/dist/workflows/parser.js +242 -19
- package/dist/workflows/program/schema.js +24 -0
- package/dist/workflows/renderer.js +32 -4
- package/dist/workflows/resource-limits.js +182 -0
- package/dist/workflows/runtime/runs.js +146 -6
- package/dist/workflows/validate-summary.js +17 -2
- package/docs/README.md +74 -32
- package/docs/migration/release-notes/0.9.0.md +2 -1
- package/docs/migration/v0.7-to-v0.8.md +2 -1
- package/docs/migration/v0.8-to-v0.9.md +3 -1
- package/docs/reference/README.md +11 -4
- package/docs/reference/bundle-types.md +19 -0
- package/docs/reference/cli.md +105 -16
- package/docs/reference/configuration.md +15 -2
- package/docs/reference/data-and-telemetry.md +30 -10
- package/docs/reference/supported-formats.md +50 -0
- package/docs/reference/workflow-schema.md +1014 -0
- package/docs/reference/workflows.md +37 -633
- package/package.json +13 -6
- package/schemas/akm-config.json +18 -5
- package/schemas/akm-task.json +27 -5
- 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
|
+
}
|