akm-cli 0.10.261012-beta → 0.10.261013-beta

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 (146) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/SECURITY.md +2 -2
  3. package/STABILITY.md +2 -2
  4. package/dist/assets/hints/cli-hints-full.md +6 -7
  5. package/dist/assets/hints/cli-hints-short.md +3 -4
  6. package/dist/commands/command/execution-source-loader.js +10 -14
  7. package/dist/commands/env/env-binding.js +47 -58
  8. package/dist/commands/env/env-cli.js +20 -22
  9. package/dist/commands/env/env.js +10 -58
  10. package/dist/commands/env/secret-cli.js +9 -27
  11. package/dist/commands/env/secret.js +3 -10
  12. package/dist/commands/health/scheduler-binary.js +1 -1
  13. package/dist/commands/improve/distill.js +0 -2
  14. package/dist/commands/improve/eligibility.js +0 -2
  15. package/dist/commands/lint/env-key-rules.js +7 -6
  16. package/dist/commands/lint/index.js +1 -1
  17. package/dist/commands/proposal/validators/proposal-validators.js +1 -4
  18. package/dist/commands/read/show.js +4 -25
  19. package/dist/commands/sources/add-cli.js +8 -63
  20. package/dist/commands/sources/bundle-cli.js +8 -9
  21. package/dist/commands/sources/installed-stashes.js +117 -61
  22. package/dist/commands/sources/schema-repair.js +0 -2
  23. package/dist/commands/sources/source-clone.js +31 -7
  24. package/dist/commands/tasks/explain.js +1 -1
  25. package/dist/commands/tasks/tasks.js +38 -56
  26. package/dist/commands/ui/redact.js +4 -4
  27. package/dist/commands/ui/workspace-actions.js +1 -1
  28. package/dist/commands/workflow/plan.js +47 -8
  29. package/dist/commands/workflow-cli.js +37 -41
  30. package/dist/core/adapter/adapters/akm-adapter.js +3 -3
  31. package/dist/core/adapter/adapters/akm-lint.js +3 -4
  32. package/dist/core/adapter/adapters/akm-metadata.js +2 -2
  33. package/dist/core/adapter/adapters/akm-task-adapter.js +5 -6
  34. package/dist/core/adapter/adapters/dotenv-adapter.js +4 -3
  35. package/dist/core/asset/resolve-ref.js +5 -0
  36. package/dist/core/bundle-rename.js +12 -4
  37. package/dist/core/common.js +4 -52
  38. package/dist/core/config/config-walker.js +1 -1
  39. package/dist/core/config/config.js +55 -18
  40. package/dist/core/config/schema/primitives.js +12 -6
  41. package/dist/core/config/schema/sources-bundles.js +2 -2
  42. package/dist/core/env-secret-ref.js +84 -73
  43. package/dist/core/errors.js +1 -1
  44. package/dist/core/paths.js +1 -6
  45. package/dist/core/redaction.js +16 -3
  46. package/dist/core/ref-token.js +82 -0
  47. package/dist/core/secret-value.js +14 -0
  48. package/dist/core/write-source.js +20 -4
  49. package/dist/indexer/indexer.js +27 -36
  50. package/dist/indexer/installations.js +1 -8
  51. package/dist/indexer/lookup/adapter-concept-owner.js +40 -14
  52. package/dist/indexer/materialize-embeddings.js +1 -1
  53. package/dist/indexer/scan/drain-dir.js +3 -3
  54. package/dist/indexer/search/search-source.js +4 -2
  55. package/dist/indexer/walk/path-resolver.js +13 -68
  56. package/dist/indexer/walk/walker.js +18 -22
  57. package/dist/integrations/agent/engine-resolution.js +9 -7
  58. package/dist/integrations/agent/runner-dispatch.js +4 -5
  59. package/dist/integrations/agent/runner.js +2 -0
  60. package/dist/llm/client.js +1 -1
  61. package/dist/llm/embedders/remote.js +4 -3
  62. package/dist/output/renderers.js +3 -4
  63. package/dist/output/shapes/helpers.js +0 -2
  64. package/dist/output/text/command-format.js +12 -2
  65. package/dist/output/text/show-directives.js +16 -28
  66. package/dist/output/text/workflow-format.js +37 -7
  67. package/dist/scripts/akm-migrate-node.js +45865 -44692
  68. package/dist/scripts/akm-migrate.js +45804 -44630
  69. package/dist/setup/steps/tasks.js +1 -2
  70. package/dist/sources/providers/provider-utils.js +12 -0
  71. package/dist/sources/resolve.js +12 -16
  72. package/dist/sources/snapshot-fetchers/secret-seam.js +2 -4
  73. package/dist/sources/snapshot-fetchers/x.js +1 -1
  74. package/dist/storage/repositories/proposals-repository.js +2 -2
  75. package/dist/storage/repositories/task-history-repository.js +2 -2
  76. package/dist/storage/repositories/workflow-runs-repository.js +38 -116
  77. package/dist/tasks/embedded.js +1 -1
  78. package/dist/tasks/prepare/prepare-support.js +3 -2
  79. package/dist/tasks/prepare/prepare.js +3 -4
  80. package/dist/tasks/prepare/script-capture.js +8 -22
  81. package/dist/tasks/run/load-task.js +46 -14
  82. package/dist/tasks/run/run-native-task.js +0 -8
  83. package/dist/tasks/run/run-workflow-task.js +2 -2
  84. package/dist/tasks/scheduler-sync.js +0 -1
  85. package/dist/tasks/source/bounded-document.js +0 -41
  86. package/dist/tasks/source/parse-task-source.js +0 -1
  87. package/dist/tasks/source/task-source-v4.js +1 -4
  88. package/dist/workflows/compile.js +1 -15
  89. package/dist/workflows/exec/child-workflow.js +1 -2
  90. package/dist/workflows/exec/dispatch-redaction.js +24 -6
  91. package/dist/workflows/exec/environment.js +11 -74
  92. package/dist/workflows/exec/exec-unit.js +31 -23
  93. package/dist/workflows/exec/frozen-judge.js +17 -3
  94. package/dist/workflows/exec/native-executor.js +70 -26
  95. package/dist/workflows/exec/run-workflow.js +31 -18
  96. package/dist/workflows/exec/step-work.js +4 -3
  97. package/dist/workflows/exec/unit-dispatch.js +1 -8
  98. package/dist/workflows/freeze/environment.js +14 -119
  99. package/dist/workflows/freeze/freeze.js +0 -1
  100. package/dist/workflows/freeze/step-values.js +1 -2
  101. package/dist/workflows/freeze/targets/child-workflow.js +3 -5
  102. package/dist/workflows/freeze/targets/command.js +6 -9
  103. package/dist/workflows/freeze/targets/shell.js +0 -3
  104. package/dist/workflows/parser.js +6 -25
  105. package/dist/workflows/plan.js +6 -2
  106. package/dist/workflows/renderer.js +77 -4
  107. package/dist/workflows/runtime/run-plan.js +37 -23
  108. package/dist/workflows/runtime/runs.js +41 -84
  109. package/dist/workflows/runtime/workflow-asset-loader.js +55 -86
  110. package/dist/workflows/source-files.js +7 -19
  111. package/docs/architecture/0.10-process-rules.md +6 -6
  112. package/docs/architecture/architecture.md +6 -6
  113. package/docs/architecture/comparisons/claude-code-vs-akm-workflows-full.md +12 -18
  114. package/docs/architecture/decisions/0003-child-env-allowlist-and-provenance.md +6 -8
  115. package/docs/architecture/internals/storage-locations.md +4 -4
  116. package/docs/architecture/persisted-data-compat.md +3 -2
  117. package/docs/architecture/specs/0.9.0-public-api-issue-backlog.md +1 -1
  118. package/docs/architecture/specs/akm-format-neutral-bundle-workspace-spec.md +3 -3
  119. package/docs/architecture/testing/manual-testing-checklist.md +46 -78
  120. package/docs/architecture/testing/task-workflow-manual-tests.md +22 -40
  121. package/docs/architecture/testing/testing-workflow.md +9 -11
  122. package/docs/architecture/workflow-engine.md +41 -27
  123. package/docs/guides/author-bundles.md +14 -17
  124. package/docs/guides/author-workflows.md +43 -9
  125. package/docs/guides/bundles.md +16 -11
  126. package/docs/guides/claude-code-vs-akm-workflows.md +1 -1
  127. package/docs/guides/run-workflows.md +25 -13
  128. package/docs/guides/scheduling.md +1 -1
  129. package/docs/maintainers/README.md +2 -3
  130. package/docs/maintainers/release-checklist.md +73 -126
  131. package/docs/migration/v0.9-to-v0.10.md +5 -1
  132. package/docs/reference/cli.md +124 -112
  133. package/docs/reference/configuration.md +16 -9
  134. package/docs/reference/data-and-telemetry.md +1 -1
  135. package/docs/reference/env-and-secrets.md +66 -15
  136. package/docs/reference/registry.md +6 -11
  137. package/docs/reference/tasks.md +11 -3
  138. package/docs/reference/ui-api.md +3 -3
  139. package/docs/reference/workflow-schema.md +109 -36
  140. package/package.json +1 -1
  141. package/schemas/akm-task.json +2 -4
  142. package/dist/commands/sources/dangerous-env-audit.js +0 -134
  143. package/dist/core/activation-policy.js +0 -67
  144. package/dist/execution/directory-identity.js +0 -78
  145. package/dist/workflows/authoring/scope-key.js +0 -79
  146. package/dist/workflows/source-semantics.js +0 -110
@@ -71,7 +71,7 @@ export async function runWorkflowTask(input) {
71
71
  // aborted by a timer. The engine reads `options.signal` at every step
72
72
  // boundary and breaks GRACEFULLY — in-flight units are cancelled, the journal
73
73
  // and the run lease are retained, and the run is left `active`, i.e.
74
- // resumable with `akm workflow resume`.
74
+ // continuable with `akm workflow run <run-id>`.
75
75
  const controller = new AbortController();
76
76
  const deadline = armAbortDeadline(controller, {
77
77
  timeoutMs,
@@ -130,7 +130,7 @@ export async function runWorkflowTask(input) {
130
130
  const timeoutError = timedOutAfterMs === undefined
131
131
  ? undefined
132
132
  : new Error(`Workflow run timed out after ${timedOutAfterMs}ms and was aborted at a step boundary` +
133
- (detail?.id ? ` — resume it with \`akm workflow resume ${detail.id}\`.` : "."));
133
+ (detail?.id ? ` — continue it with \`akm workflow run ${detail.id}\`.` : "."));
134
134
  // One failure value for the three sinks below (status, log line, history
135
135
  // detail): a thrown error outranks a gate rejection, which outranks the
136
136
  // deadline. Re-laddering per sink is how a log line ends up naming a
@@ -49,7 +49,6 @@ async function compileTaskSources(input, desired, failures) {
49
49
  const parsed = parseTaskSource({
50
50
  yaml: fs.readFileSync(file, "utf8"),
51
51
  filePath: file,
52
- workspaceRoot: input.sourceRoot,
53
52
  });
54
53
  // A task with no `schedule:` is run by hand or by a workflow step; it
55
54
  // contributes no binding and is not sync's to validate.
@@ -43,8 +43,6 @@
43
43
  * `["akm", …]`-hardcoding helper but is NOT in D2-N4's named list and stays
44
44
  * declared directly in `source-v3.ts`.
45
45
  */
46
- import fs from "node:fs";
47
- import path from "node:path";
48
46
  import { types as utilTypes } from "node:util";
49
47
  import { isAlias, isMap, isScalar, isSeq, LineCounter, parseDocument } from "yaml";
50
48
  import { wellFormedUnicode } from "../../core/common.js";
@@ -256,45 +254,6 @@ export function parseTools(value, ctx) {
256
254
  return value;
257
255
  sourceError(ctx, ["akm", "tools"], "must be a string, string array, mapping, or null.");
258
256
  }
259
- /**
260
- * `working-directory:` is already a top-level field in both grammars —
261
- * reused body-intact, field path unchanged (`source-v3.ts:677-714`).
262
- */
263
- export function validateWorkingDirectory(value, ctx) {
264
- if (value.trim().length === 0 ||
265
- value.includes("\0") ||
266
- path.posix.isAbsolute(value.replaceAll("\\", "/")) ||
267
- /^[A-Za-z]:[\\/]/.test(value) ||
268
- value.startsWith("\\\\")) {
269
- sourceError(ctx, ["working-directory"], "must be a non-empty relative path contained by the workspace root.");
270
- }
271
- const segments = value.replaceAll("\\", "/").split("/");
272
- if (segments.some((segment) => segment === ".." || segment.length === 0)) {
273
- sourceError(ctx, ["working-directory"], "must not contain empty or escaping path segments.");
274
- }
275
- if (!ctx.workspaceRoot) {
276
- sourceError(ctx, ["working-directory"], "requires a workspace root so physical containment can be verified.");
277
- }
278
- let realRoot;
279
- let realCandidate;
280
- try {
281
- realRoot = fs.realpathSync(ctx.workspaceRoot);
282
- const candidate = path.resolve(realRoot, value);
283
- const stat = fs.statSync(candidate);
284
- if (!stat.isDirectory())
285
- sourceError(ctx, ["working-directory"], "must resolve to a directory.");
286
- realCandidate = fs.realpathSync(candidate);
287
- }
288
- catch (cause) {
289
- if (cause instanceof UsageError)
290
- throw cause;
291
- sourceError(ctx, ["working-directory"], `cannot be physically verified: ${cause instanceof Error ? cause.message : String(cause)}.`);
292
- }
293
- const relative = path.relative(realRoot, realCandidate);
294
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
295
- sourceError(ctx, ["working-directory"], "resolves outside the workspace root and is not physically contained.");
296
- }
297
- }
298
257
  export function yamlProblem(message) {
299
258
  return message.split("\n")[0]?.trim() || "invalid YAML";
300
259
  }
@@ -75,7 +75,6 @@ export function parseTaskSource(input) {
75
75
  }
76
76
  const documentOptions = {
77
77
  filePath: input.filePath,
78
- ...(input.workspaceRoot ? { workspaceRoot: input.workspaceRoot } : {}),
79
78
  lineAt,
80
79
  };
81
80
  return Object.freeze({ version: 4, v4: parseTaskSourceV4Document(root, documentOptions) });
@@ -45,7 +45,7 @@ import { detectSecretShapedParams } from "../../workflows/exec/param-secrets.js"
45
45
  import { WORKFLOW_ENV_VAR_NAME_PATTERN, WORKFLOW_MAX_SCHEMA_BYTES } from "../../workflows/resource-limits.js";
46
46
  import { TASK_V3_HOST_SHELLS, TASK_V3_MAX_SCHEDULES } from "../source-v3.js";
47
47
  import { TASK_RUN_RESERVED_FLAG_NAMES } from "../task-run-reserved-flags.js";
48
- import { asRecord, checkKeys, cloneBoundedJson, noGithubExpression, own, parseEnvironment, parseStringArray, presentJsonValue, readBoundedTaskSourceYaml, sourceError, stringField, TASK_V3_MAX_REDACT_NAMES, utf8Bytes, validateWorkingDirectory, } from "./bounded-document.js";
48
+ import { asRecord, checkKeys, cloneBoundedJson, noGithubExpression, own, parseEnvironment, parseStringArray, presentJsonValue, readBoundedTaskSourceYaml, sourceError, stringField, TASK_V3_MAX_REDACT_NAMES, utf8Bytes, } from "./bounded-document.js";
49
49
  // ── Closed constants (D1, D2-N3, D2-N7) ─────────────────────────────────────
50
50
  export const TASK_SOURCE_V4_VERSION = 4;
51
51
  /** The exact, closed top-level key set (D2-N7) — `akm` and `on` are deliberately absent. */
@@ -95,7 +95,6 @@ function ctxFrom(options) {
95
95
  return {
96
96
  filePath: options.filePath,
97
97
  sourceLabel: SOURCE_LABEL,
98
- ...(options.workspaceRoot ? { workspaceRoot: options.workspaceRoot } : {}),
99
98
  ...(options.lineAt ? { lineAt: options.lineAt } : {}),
100
99
  };
101
100
  }
@@ -272,7 +271,6 @@ function parseTarget(input, ctx) {
272
271
  workingDirectory = stringField(input["working-directory"], ctx, ["working-directory"], {
273
272
  nonempty: true,
274
273
  });
275
- validateWorkingDirectory(workingDirectory, ctx);
276
274
  }
277
275
  return Object.freeze({
278
276
  kind: "run",
@@ -596,7 +594,6 @@ export function parseTaskSourceV4(input) {
596
594
  const { root, lineAt } = readBoundedTaskSourceYaml(input, { sourceLabel: SOURCE_LABEL });
597
595
  return parseTaskSourceV4Document(root, {
598
596
  filePath: input.filePath,
599
- ...(input.workspaceRoot ? { workspaceRoot: input.workspaceRoot } : {}),
600
597
  lineAt,
601
598
  });
602
599
  }
@@ -11,7 +11,6 @@
11
11
  import path from "node:path";
12
12
  import { parseWorkflow } from "./parser.js";
13
13
  import { formatReference, parseReference } from "./program/expressions.js";
14
- import { canonicalizeWorkflowWorkingDirectory, WorkflowSourceSemanticError } from "./source-semantics.js";
15
14
  /** Compile by authoritative source extension. */
16
15
  export function compileWorkflowSource(source, options) {
17
16
  const extension = path.extname(options.path).toLowerCase();
@@ -29,20 +28,7 @@ export function compileWorkflowSource(source, options) {
29
28
  ],
30
29
  };
31
30
  }
32
- const parsed = parseWorkflow(source, {
33
- path: options.path,
34
- title,
35
- validateExecCwd: (value) => {
36
- try {
37
- return { ok: true, value: canonicalizeWorkflowWorkingDirectory(value, options.workspaceRoot) };
38
- }
39
- catch (cause) {
40
- if (cause instanceof WorkflowSourceSemanticError)
41
- return { ok: false, code: cause.code, message: cause.message };
42
- return { ok: false, code: "working-directory-unverifiable", message: "cwd cannot be physically verified." };
43
- }
44
- },
45
- });
31
+ const parsed = parseWorkflow(source, { path: options.path, title });
46
32
  if (parsed.ok)
47
33
  return parsed;
48
34
  return {
@@ -104,8 +104,6 @@ async function publishChildRun(input, invocationKey) {
104
104
  run: {
105
105
  id: childRunId,
106
106
  workflowRef: target.ref,
107
- scopeKey: parentRow.scope_key,
108
- workflowEntryId: null,
109
107
  workflowTitle: target.frozenPlan.title,
110
108
  paramsJson: JSON.stringify(childParams),
111
109
  currentStepId: target.frozenPlan.steps[0]?.stepId ?? null,
@@ -149,6 +147,7 @@ async function driveChildRun(input, childRow) {
149
147
  ...(ctx.dispatcher ? { dispatcher: ctx.dispatcher } : {}),
150
148
  ...(ctx.maxConcurrency !== undefined ? { maxConcurrency: ctx.maxConcurrency } : {}),
151
149
  ...(ctx.eventSource !== undefined ? { eventSource: ctx.eventSource } : {}),
150
+ ...(ctx.bundleRootOverride !== undefined ? { bundleRootOverride: ctx.bundleRootOverride } : {}),
152
151
  // The parent's own `finally` owns the process-lifecycle drain; no maxSteps/maxRetries.
153
152
  disposeDispatchResources: () => { },
154
153
  };
@@ -10,12 +10,14 @@ import { collectSensitiveValues, isEnvPassthroughValueSafeToExpose, redactSensit
10
10
  import { lookupApiKeyFileValue, lookupApiKeySecretRefValue } from "../../integrations/agent/engine-resolution.js";
11
11
  /**
12
12
  * Every exact value that must never reach the journal from one dispatch: the
13
- * resolved `env` bindings, the engine's (and SDK fallback's) credential from
14
- * env, file, or secret store, and unsafe passthrough values. Read at call time,
15
- * so collect no earlier than the dispatch being scrubbed.
13
+ * secrets substituted into the unit's env bindings (`dispatch.sensitiveValues`;
14
+ * the rest of an env asset is configuration and reaches the journal as is), the
15
+ * engine's (and SDK fallback's) credential from env, file, or secret store, and
16
+ * unsafe passthrough values. Read at call time, so collect no earlier than the
17
+ * dispatch being scrubbed.
16
18
  */
17
- export function collectWorkflowDispatchSensitiveValues(dispatch, env) {
18
- const values = new Set([...Object.values(env ?? {}), ...(dispatch.sensitiveValues ?? [])]);
19
+ export function collectWorkflowDispatchSensitiveValues(dispatch) {
20
+ const values = new Set(dispatch.sensitiveValues ?? []);
19
21
  const addCredential = (runner) => {
20
22
  if (!runner)
21
23
  return;
@@ -66,12 +68,28 @@ export function collectWorkflowDispatchSensitiveValues(dispatch, env) {
66
68
  addCredential(dispatch.runner);
67
69
  return collectSensitiveValues(values);
68
70
  }
71
+ /**
72
+ * Turn a number leaf that is exactly a sensitive value into its text, so the string scrub redacts it (a
73
+ * numeric secret echoed as a JSON number). Only the unit's parsed `result` takes this path: a number
74
+ * elsewhere in an outcome (`tokens`) stays a number.
75
+ */
76
+ function stringifySensitiveNumbers(value, sensitiveValues) {
77
+ if (typeof value === "number")
78
+ return sensitiveValues.includes(String(value)) ? String(value) : value;
79
+ if (Array.isArray(value))
80
+ return value.map((entry) => stringifySensitiveNumbers(entry, sensitiveValues));
81
+ if (value && typeof value === "object") {
82
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, stringifySensitiveNumbers(child, sensitiveValues)]));
83
+ }
84
+ return value;
85
+ }
69
86
  /**
70
87
  * Scrub a dispatch outcome before anything about it is journaled. A
71
88
  * `failureReason` the scrub altered carried a secret, so it is downgraded.
72
89
  */
73
90
  export function redactUnitOutcome(outcome, sensitiveValues) {
74
- const redacted = redactSensitiveValue(outcome, sensitiveValues);
91
+ const prepared = "result" in outcome ? { ...outcome, result: stringifySensitiveNumbers(outcome.result, sensitiveValues) } : outcome;
92
+ const redacted = redactSensitiveValue(prepared, sensitiveValues);
75
93
  if (outcome.failureReason !== undefined && redacted.failureReason !== outcome.failureReason) {
76
94
  redacted.failureReason = "reported_failure";
77
95
  }
@@ -3,27 +3,18 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
5
  * Materialize a unit's frozen environment at dispatch. Env-file values are the
6
- * one deliberately live input to a frozen plan: freeze records each ref's
7
- * owner, key set and `${secret:…}` names; the values (and the secrets they
8
- * name) are read here, per dispatch, and nothing is returned until every read
9
- * has succeeded.
6
+ * one deliberately live input to a frozen plan: freeze records each env ref;
7
+ * the values (and the secrets and paths their `${ref}` tokens name) are read
8
+ * here, per dispatch, through `resolveEnvBinding` — the same machinery `akm env
9
+ * run` uses — and nothing is returned until every read has succeeded.
10
10
  */
11
- import fs from "node:fs";
12
- import path from "node:path";
13
- import dotenv from "dotenv";
14
- import { assetPathForName } from "../../core/asset/asset-placement.js";
15
- import { compareCodePoints, isWithin } from "../../core/common.js";
16
- import { NotFoundError, UsageError } from "../../core/errors.js";
17
- export const SECRET_TOKEN_RE = /\$\{secret:([A-Za-z0-9_./-]+)\}/g;
18
- export function materializeFrozenWorkflowEnvironment(descriptors, options = {}) {
11
+ import { resolveEnvBinding } from "../../commands/env/env-binding.js";
12
+ export function materializeFrozenWorkflowEnvironment(descriptors) {
19
13
  const values = {};
20
14
  const sensitive = new Set();
21
- const audits = [];
22
15
  for (const descriptor of descriptors) {
23
16
  if (descriptor.kind === "literal") {
24
17
  values[descriptor.name] = descriptor.value;
25
- if (descriptor.value)
26
- sensitive.add(descriptor.value);
27
18
  continue;
28
19
  }
29
20
  // `pass_env` names reach the exec child through its allowlist
@@ -31,64 +22,10 @@ export function materializeFrozenWorkflowEnvironment(descriptors, options = {})
31
22
  // deliberately not redacted, so they are neither read nor marked sensitive here.
32
23
  if (descriptor.kind === "pass-through")
33
24
  continue;
34
- const source = options.readEnvFile ? options.readEnvFile(descriptor) : readEnvFile(descriptor.owner);
35
- const parsed = dotenv.parse(typeof source === "string" ? source : Buffer.from(source));
36
- const keys = Object.keys(parsed).sort(compareCodePoints);
37
- if (!sameStrings(keys, descriptor.keys))
38
- invalid(`environment ${descriptor.ref} key set changed after it was frozen`);
39
- const secretValues = new Map();
40
- for (const name of descriptor.secretNames) {
41
- const raw = options.readSecret ? options.readSecret({ name, descriptor }) : readSecret(descriptor.owner, name);
42
- if (raw === undefined) {
43
- throw new NotFoundError(`Environment ${descriptor.ref} references missing secret ${name}; nothing was materialized.`, "FILE_NOT_FOUND");
44
- }
45
- const value = typeof raw === "string" ? raw : Buffer.from(raw).toString("utf8");
46
- secretValues.set(name, value);
47
- if (value)
48
- sensitive.add(value);
49
- }
50
- for (const [key, rawValue] of Object.entries(parsed)) {
51
- const resolved = rawValue.replace(SECRET_TOKEN_RE, (_token, name) => {
52
- const secret = secretValues.get(name);
53
- if (secret === undefined)
54
- invalid(`environment ${descriptor.ref} secret ${name} was not frozen`);
55
- return secret;
56
- });
57
- values[key] = resolved;
58
- if (resolved)
59
- sensitive.add(resolved);
60
- }
61
- audits.push({
62
- eventType: "env_access",
63
- ref: descriptor.ref,
64
- keys: [...descriptor.keys],
65
- secretNames: [...descriptor.secretNames],
66
- });
67
- }
68
- return { values, sensitiveValues: [...sensitive], audits };
69
- }
70
- function readEnvFile(owner) {
71
- if (!isWithin(owner.requestedPath, owner.requestedRoot))
72
- invalid(`env file ${owner.relativePath} escapes its bundle`);
73
- return fs.readFileSync(owner.requestedPath);
74
- }
75
- function readSecret(owner, name) {
76
- const secretsRoot = path.join(owner.requestedRoot, "secrets");
77
- const secretPath = assetPathForName("secret", secretsRoot, name);
78
- if (!isWithin(secretPath, secretsRoot))
79
- invalid(`secret name ${name} escapes its bundle`);
80
- try {
81
- return fs.readFileSync(secretPath);
82
- }
83
- catch (cause) {
84
- if (cause.code === "ENOENT")
85
- return undefined;
86
- throw cause;
25
+ const binding = resolveEnvBinding(descriptor.ref);
26
+ Object.assign(values, binding.values);
27
+ for (const secret of binding.secretValues)
28
+ sensitive.add(secret);
87
29
  }
88
- }
89
- function sameStrings(left, right) {
90
- return left.length === right.length && left.every((value, index) => value === right[index]);
91
- }
92
- function invalid(message) {
93
- throw new UsageError(`Invalid frozen workflow environment: ${message}.`, "WORKFLOW_SOURCE_INVALID");
30
+ return { values, sensitiveValues: [...sensitive] };
94
31
  }
@@ -4,14 +4,14 @@
4
4
  /**
5
5
  * The `exec` unit runner — the one place a frozen workflow spawns a command:
6
6
  * argv-only, detached with a SIGTERM→SIGKILL ladder against the process group,
7
- * cwd-contained by a resolved-path recheck, bounded (timeout, output bytes,
8
- * context size), with an allowlisted environment ({@link childEnv}). The
7
+ * bounded (timeout, output bytes, context size), with an allowlisted
8
+ * environment ({@link childEnv}). The
9
9
  * caller redacts the outcome before anything is journaled.
10
10
  * See docs/architecture/decisions/0003-child-env-allowlist-and-provenance.md.
11
11
  */
12
12
  import fs from "node:fs";
13
13
  import path from "node:path";
14
- import { isWithinAsync } from "../../core/common.js";
14
+ import { getCacheDir, getConfigDir, getDataDir, getStateDir } from "../../core/paths.js";
15
15
  import { COMMON_SPAWN_ENV_PASSTHROUGH, collectAllowlistedEnv, WIN32_SPAWN_ENV_FLOOR } from "../../core/spawn-env.js";
16
16
  import { runManagedSubprocess, streamCaptureFailure, } from "../../core/subprocess.js";
17
17
  import { warn } from "../../core/warn.js";
@@ -25,8 +25,9 @@ const EXEC_STDERR_DIAGNOSTIC_CLIP = WORKFLOW_UNIT_DIAGNOSTIC_CLIP - 500;
25
25
  /**
26
26
  * The default environment allowlist for an exec unit's child (`exec.passEnv`
27
27
  * extends it): the agent-harness baseline plus names ordinary commands need
28
- * and `AKM_EVENT_SOURCE`. Credentials, cloud/CI vars, and proxies reach a
29
- * child only through `pass_env` or `env:`.
28
+ * and `AKM_EVENT_SOURCE`. akm's own directories are not listed: {@link childEnv}
29
+ * sets them from the parent's effective locations. Credentials, cloud/CI vars, and proxies reach a child only through
30
+ * `pass_env` or `env:`.
30
31
  */
31
32
  export const EXEC_DEFAULT_ENV_PASSTHROUGH = [
32
33
  // PATH, HOME, USER, LANG, LC_ALL, TERM, TMPDIR, AKM_EVENT_SOURCE
@@ -49,7 +50,7 @@ export const EXEC_DEFAULT_ENV_PASSTHROUGH = [
49
50
  /**
50
51
  * Run one exec unit and map its outcome onto the dispatch vocabulary
51
52
  * (`non_zero_exit`, `timeout`, `aborted`, `spawn_failed` — retryable). The
52
- * `exec_*` reasons (cwd escape, output limit, context too large, incomplete
53
+ * `exec_*` reasons (output limit, context too large, incomplete
53
54
  * capture) are deliberately outside `retry.on`. Output overflow fails only a
54
55
  * unit that declared an `output:` schema.
55
56
  */
@@ -220,21 +221,11 @@ function contextTooLarge(input, what, names, limits) {
220
221
  };
221
222
  }
222
223
  /**
223
- * Resolve `exec.cwd` inside `baseDir` and prove containment against the
224
- * resolved base (a symlinked `reports` could point at `/etc`). Async: it runs
225
- * per unit on the dispatch path.
224
+ * Join `exec.cwd` onto `baseDir` once. `exec.cwd` may be relative or absolute and is not contained:
225
+ * the command is arbitrary code in its own right. Async: it runs per unit on the dispatch path.
226
226
  */
227
227
  async function resolveExecCwd(input) {
228
- const base = path.resolve(input.baseDir);
229
- const target = input.exec.cwd ? path.resolve(base, input.exec.cwd) : base;
230
- if (!(await isWithinAsync(target, base))) {
231
- return {
232
- ok: false,
233
- failureReason: "exec_cwd_escape",
234
- error: `exec unit "${input.unitId}" declares cwd ${JSON.stringify(input.exec.cwd ?? ".")}, which resolves to ` +
235
- `${target} — outside its working directory ${base}. Refusing to run outside the unit's tree.`,
236
- };
237
- }
228
+ const target = path.resolve(input.baseDir, input.exec.cwd ?? ".");
238
229
  if (!(await isExistingDirectory(target))) {
239
230
  return {
240
231
  ok: false,
@@ -253,12 +244,12 @@ async function isExistingDirectory(candidate) {
253
244
  }
254
245
  }
255
246
  /**
256
- * The child's environment, in precedence order: the allowlist plus
257
- * `exec.passEnv`; the resolved `env:` bindings; then the engine's `AKM_*`
258
- * context, last so a binding cannot shadow it.
247
+ * The child's environment, in precedence order: the parent's effective akm
248
+ * directories; the allowlist plus `exec.passEnv`; the resolved `env:` bindings;
249
+ * then the engine's `AKM_*` context, last so a binding cannot shadow it.
259
250
  */
260
251
  function childEnv(exec, bindings, context, eventSource) {
261
- const env = collectAllowlistedEnv(execAllowlist(exec));
252
+ const env = { ...akmLocations(), ...collectAllowlistedEnv(execAllowlist(exec)) };
262
253
  // Only when absent from the base, and before the overlays, so ambient and authored values win.
263
254
  if (eventSource !== undefined && env.AKM_EVENT_SOURCE === undefined) {
264
255
  env.AKM_EVENT_SOURCE = eventSource;
@@ -269,6 +260,23 @@ function childEnv(exec, bindings, context, eventSource) {
269
260
  env[name] = value;
270
261
  return env;
271
262
  }
263
+ /**
264
+ * The directories this akm resolved, as the `AKM_*` variables that name them, so
265
+ * a step that runs `akm` uses the same installation even when the parent found
266
+ * them through `XDG_*_HOME`. `AKM_BUNDLE_DIR` is passed only when the parent set one.
267
+ */
268
+ function akmLocations() {
269
+ const locations = {
270
+ AKM_CONFIG_DIR: getConfigDir(),
271
+ AKM_DATA_DIR: getDataDir(),
272
+ AKM_CACHE_DIR: getCacheDir(),
273
+ AKM_STATE_DIR: getStateDir(),
274
+ };
275
+ const bundleDir = process.env.AKM_BUNDLE_DIR?.trim();
276
+ if (bundleDir)
277
+ locations.AKM_BUNDLE_DIR = bundleDir;
278
+ return locations;
279
+ }
272
280
  /** The unit's effective allowlist: the shared default plus its own `passEnv` names. */
273
281
  function execAllowlist(exec) {
274
282
  return exec.passEnv ? [...EXEC_DEFAULT_ENV_PASSTHROUGH, ...exec.passEnv] : [...EXEC_DEFAULT_ENV_PASSTHROUGH];
@@ -9,12 +9,22 @@
9
9
  * bindings (a step's environment belongs to the work, not the verifier).
10
10
  */
11
11
  import { warn } from "../../core/warn.js";
12
+ import { resolveRunBundleRoot } from "../runtime/workflow-asset-loader.js";
12
13
  import { collectWorkflowDispatchSensitiveValues, withDispatchRedaction } from "./dispatch-redaction.js";
13
14
  import { dispatchWorkflowExecution, prepareWorkflowExecution, } from "./unit-dispatch.js";
14
15
  /** The gate's node id — identical to what `journalGateEvaluationStart` writes. */
15
16
  export function gateNodeId(stepId) {
16
17
  return `${stepId}.gate`;
17
18
  }
19
+ /**
20
+ * The root of the bundle the run's workflow lives in, found when the judge is built (a bundle update moves it).
21
+ * No frozen judge, no root needed. When the bundle does not resolve the judge is not built, and the error names the ref.
22
+ */
23
+ export function resolveJudgeCwd(target, workflowRef) {
24
+ if (!target)
25
+ return undefined;
26
+ return resolveRunBundleRoot(workflowRef);
27
+ }
18
28
  /** Identity for one judge dispatch: the caller's row identity, else the run/step with the gate node id. */
19
29
  function dispatchIdentity(owner, identity) {
20
30
  return identity ?? { ...owner, unitId: gateNodeId(owner.stepId) };
@@ -31,8 +41,11 @@ function warnLoweringNotices(...groups) {
31
41
  }
32
42
  }
33
43
  }
34
- /** Build a gate judge from the frozen target without consulting live config; `eventSource` stamps it like a unit. */
35
- export function frozenSummaryJudge(target, signal, dispatcher, owner, eventSource) {
44
+ /**
45
+ * Build a gate judge from the frozen target without consulting live config; `eventSource` stamps it like a unit.
46
+ * `cwd` is the root of the run's bundle, where the judge works like every other agent dispatch.
47
+ */
48
+ export function frozenSummaryJudge(target, signal, dispatcher, owner, eventSource, cwd) {
36
49
  if (!target)
37
50
  return null;
38
51
  const dispatch = withDispatchRedaction(dispatcher ?? dispatchWorkflowExecution);
@@ -52,6 +65,7 @@ export function frozenSummaryJudge(target, signal, dispatcher, owner, eventSourc
52
65
  const request = {
53
66
  ...commonRequest,
54
67
  frozenTarget: target,
68
+ ...(cwd !== undefined ? { cwd } : {}),
55
69
  timeoutMs: target.runner.timeoutMs ?? null,
56
70
  ...(eventSource !== undefined ? { eventSource } : {}),
57
71
  };
@@ -59,7 +73,7 @@ export function frozenSummaryJudge(target, signal, dispatcher, owner, eventSourc
59
73
  // sample. The injected dispatcher may be a test seam, but it receives the
60
74
  // exact same already-validated request and redaction declaration.
61
75
  const lowered = prepareWorkflowExecution(request);
62
- const sensitiveValues = collectWorkflowDispatchSensitiveValues({ runner: target.runner }, undefined);
76
+ const sensitiveValues = collectWorkflowDispatchSensitiveValues({ runner: target.runner });
63
77
  const outcome = await dispatch({
64
78
  ...request,
65
79
  ...(sensitiveValues.length > 0 ? { sensitiveValues } : {}),