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
@@ -21,9 +21,8 @@
21
21
  * document fails closed with `TASK_SCHEMA_VERSION_UNSUPPORTED`, naming
22
22
  * `akm migrate apply`, which converts it). That parser owns the closed key sets,
23
23
  * the executable-selector XOR, hostile YAML policy, the `akm/command`
24
- * builtin, bounds, and physical `working-directory` containment. The
25
- * adapter only translates a parser failure into the format-family
26
- * diagnostic shape.
24
+ * builtin, and bounds. The adapter only translates a parser failure into
25
+ * the format-family diagnostic shape.
27
26
  *
28
27
  * Conformance oracle (authored, DO NOT modify): fixture
29
28
  * `tests/fixtures/bundles/akm-task/` + goldens
@@ -59,7 +58,7 @@ function recognize(c, file) {
59
58
  content: raw,
60
59
  };
61
60
  }
62
- async function validate(c, changes, ctx) {
61
+ async function validate(_c, changes, ctx) {
63
62
  const diagnostics = [];
64
63
  for (const change of changes) {
65
64
  if (change.op === "delete")
@@ -84,7 +83,7 @@ async function validate(c, changes, ctx) {
84
83
  continue;
85
84
  }
86
85
  try {
87
- parseTaskSource({ yaml: raw, filePath: relPath, workspaceRoot: c.root });
86
+ parseTaskSource({ yaml: raw, filePath: relPath });
88
87
  }
89
88
  catch (cause) {
90
89
  diagnostics.push({
@@ -145,7 +144,7 @@ export const akmTaskAdapter = {
145
144
  continue;
146
145
  }
147
146
  try {
148
- parseTaskSource({ yaml: raw, filePath: entry.name, workspaceRoot: root });
147
+ parseTaskSource({ yaml: raw, filePath: entry.name });
149
148
  return true;
150
149
  }
151
150
  catch {
@@ -31,6 +31,7 @@ import fs from "node:fs";
31
31
  import path from "node:path";
32
32
  import { assetPathCandidatesForName, typeForStashDir } from "../../asset/asset-placement.js";
33
33
  import { scanEnvKeyNames, toPosix } from "../../common.js";
34
+ import { sensitiveMarkerPath } from "../../sensitive-marker-path.js";
34
35
  import { dangerousEnvKeyDiagnostics } from "./akm-lint.js";
35
36
  import { hashContent } from "./shared.js";
36
37
  /** A dotenv bundle is single-component; its one component is `main`. */
@@ -73,7 +74,7 @@ function classify(relPath) {
73
74
  * disagreed about a documented promise.
74
75
  */
75
76
  function hasSensitiveMarker(absPath, type) {
76
- const marker = type === "env" ? absPath.replace(/\.env$/i, ".sensitive") : `${absPath}.sensitive`;
77
+ const marker = sensitiveMarkerPath(absPath, type === "env" ? "env" : "secret");
77
78
  return marker !== absPath && fs.existsSync(marker);
78
79
  }
79
80
  function conceptIdForPath(type, relativePath) {
@@ -167,8 +168,8 @@ export const dotenvAdapter = {
167
168
  return [];
168
169
  const primaries = assetPathCandidatesForName(type, path.join(c.root, head), rest);
169
170
  const expanded = primaries.flatMap((primary) => type === "env"
170
- ? [primary, primary.replace(/\.env$/i, ".sensitive")]
171
- : [primary, `${primary}.sensitive`, `${primary}.lock`]);
171
+ ? [primary, sensitiveMarkerPath(primary, "env")]
172
+ : [primary, sensitiveMarkerPath(primary, "secret"), `${primary}.lock`]);
172
173
  return expanded.map((candidatePath) => ({ path: candidatePath, conceptId: posix }));
173
174
  },
174
175
  /** The dotenv bundle owns its `env/` + `secrets/` dirs. */
@@ -12,6 +12,11 @@
12
12
  * against an injected {@link RefContext}. This is the "the short
13
13
  * form is input sugar" rule (§11.1) made structural: only a
14
14
  * `ResolvedRef` ever crosses a storage boundary.
15
+ * It answers "which bundle" against an injected index snapshot;
16
+ * it does not find files. "Where is the file for this ref right
17
+ * now" has one answer, `resolveConceptOwner`
18
+ * (`src/indexer/lookup/adapter-concept-owner.ts`), which
19
+ * every resolver that needs a path calls.
15
20
  * - serialize — `bundleRefToString` (`asset-ref.ts`); serializing a
16
21
  * `ResolvedRef` always emits the fully-qualified form.
17
22
  *
@@ -6,7 +6,7 @@
6
6
  * bundle's key. A bundle id is a mass identity prefix: every durable ref this
7
7
  * tool minted (`entries.item_ref`/`bundle_id`, `proposals.ref`, a pending
8
8
  * proposal's `proposedTarget.source`, a workflow task_history row's
9
- * `target_ref`, `scheduler.enabled[].ref`) carries it, and a hand-edited
9
+ * `target_ref`, a `workflow_runs.workflow_ref`, `scheduler.enabled[].ref`) carries it, and a hand-edited
10
10
  * `bundles` key strands all of it (see `src/indexer/bundle-identity-guard.ts`
11
11
  * §11.5). This module is the rekey transaction that guard's docstring points
12
12
  * to.
@@ -32,6 +32,7 @@ import { closeDatabase, openIndexDatabase, openReadonlyExistingDatabase, } from
32
32
  import { getFilePathsByBundle, renameEntriesBundleId } from "../storage/repositories/index-entries-repository.js";
33
33
  import { countProposalsForBundleRename, renameProposalsBundleRef } from "../storage/repositories/proposals-repository.js";
34
34
  import { countTaskHistoryTargetRefs, renameTaskHistoryTargetRefs, } from "../storage/repositories/task-history-repository.js";
35
+ import { countWorkflowRunRefsForRename, renameWorkflowRunRefs } from "../storage/repositories/workflow-runs-repository.js";
35
36
  import { selectBackend } from "../tasks/backends/index.js";
36
37
  import { bundleRefToString, parseBundleRef } from "./asset/asset-ref.js";
37
38
  import { validateExplicitBundleName } from "./bundle-id.js";
@@ -152,12 +153,17 @@ async function buildPlan(config, oldId, newId, sched) {
152
153
  let proposalRefs = 0;
153
154
  let proposalTargets = 0;
154
155
  let taskHistoryRefs = 0;
156
+ let workflowRunRefs = 0;
157
+ let activeWorkflowRuns = [];
155
158
  if (fs.existsSync(getStateDbPath())) {
156
159
  withStateDb((db) => {
157
160
  const counts = countProposalsForBundleRename(db, oldId);
158
161
  proposalRefs = counts.refs;
159
162
  proposalTargets = counts.targets;
160
163
  taskHistoryRefs = countTaskHistoryTargetRefs(db, oldId);
164
+ const runs = countWorkflowRunRefsForRename(db, oldId);
165
+ workflowRunRefs = runs.refs;
166
+ activeWorkflowRuns = runs.active;
161
167
  });
162
168
  }
163
169
  return {
@@ -170,7 +176,8 @@ async function buildPlan(config, oldId, newId, sched) {
170
176
  },
171
177
  lock: { present: lockPresent },
172
178
  index: { entries: indexEntries },
173
- state: { proposalRefs, proposalTargets, taskHistoryRefs },
179
+ state: { proposalRefs, proposalTargets, taskHistoryRefs, workflowRunRefs },
180
+ activeWorkflowRuns,
174
181
  contentRefs,
175
182
  nativeSchedulerRows,
176
183
  };
@@ -254,12 +261,13 @@ export async function renameBundle(oldId, newId, options = {}, deps) {
254
261
  closeDatabase(writeIndexDb);
255
262
  }
256
263
  }
257
- // State: proposals.ref, a pending proposal's proposedTarget.source, and
258
- // workflow task_history.target_ref.
264
+ // State: proposals.ref, a pending proposal's proposedTarget.source,
265
+ // workflow task_history.target_ref, and workflow_runs.workflow_ref.
259
266
  if (fs.existsSync(getStateDbPath())) {
260
267
  withStateDb((db) => {
261
268
  renameProposalsBundleRef(db, oldId, newId);
262
269
  renameTaskHistoryTargetRefs(db, oldId, newId);
270
+ renameWorkflowRunRefs(db, oldId, newId);
263
271
  });
264
272
  }
265
273
  // Scheduler: remove native rows still naming `<old>//` (see
@@ -376,25 +376,6 @@ export function hasErrnoCode(error, code) {
376
376
  export function isChmodUnsupported(error) {
377
377
  return hasErrnoCode(error, "EPERM") || hasErrnoCode(error, "ENOTSUP") || hasErrnoCode(error, "EINVAL");
378
378
  }
379
- /**
380
- * True when `value` is a RELATIVE path that cannot leave its base directory:
381
- * no absolute form (POSIX `/`, Windows `\` or a `C:` drive prefix), no `~`
382
- * home expansion, and no `..` segment under either separator.
383
- *
384
- * This is the SYNTACTIC half of containment — cheap, string-only, usable at
385
- * authoring time before any directory exists. It is deliberately paired with
386
- * (never a substitute for) {@link isWithin}, which resolves symlinks against a
387
- * real base at use time. Workflow `exec` units run both: the parser and the
388
- * frozen-plan decoder reject uncontained spellings, and the executor re-checks
389
- * the resolved path before spawning.
390
- */
391
- export function isContainedRelativePath(value) {
392
- if (value === "" || value.startsWith("/") || value.startsWith("\\") || value.startsWith("~"))
393
- return false;
394
- if (/^[A-Za-z]:/.test(value))
395
- return false;
396
- return !value.split(/[/\\]+/).includes("..");
397
- }
398
379
  export function isWithin(candidate, root) {
399
380
  return isContainedResolvedPath(safeRealpath(candidate), safeRealpath(root));
400
381
  }
@@ -419,16 +400,11 @@ export function isAkmRegistryCachePath(filePath) {
419
400
  return isWithin(filePath, getRegistryCacheDir()) || isWithin(filePath, getRegistryIndexCacheDir());
420
401
  }
421
402
  /**
422
- * {@link isWithin} for callers that must not block the event loop (e.g. the
423
- * workflow exec dispatch path, which runs once per fan-out unit). Same
424
- * comparison, same normalization, same nearest-existing-ancestor fallback —
425
- * only the realpath syscalls are awaited.
403
+ * The containment comparison behind {@link isWithin}.
404
+ * Called directly with `path.resolve`d arguments it is a lexical check: it
405
+ * refuses `..` and absolute names without following symlinks.
426
406
  */
427
- export async function isWithinAsync(candidate, root) {
428
- return isContainedResolvedPath(await safeRealpathAsync(candidate), await safeRealpathAsync(root));
429
- }
430
- /** The containment comparison shared by {@link isWithin} and {@link isWithinAsync}. */
431
- function isContainedResolvedPath(resolvedCandidate, resolvedRoot) {
407
+ export function isContainedResolvedPath(resolvedCandidate, resolvedRoot) {
432
408
  const rel = path.relative(normalizeFsPathForComparison(resolvedRoot), normalizeFsPathForComparison(resolvedCandidate));
433
409
  if (rel === "")
434
410
  return true;
@@ -475,30 +451,6 @@ export function safeRealpath(p) {
475
451
  }
476
452
  }
477
453
  }
478
- /** {@link safeRealpath}'s async twin — awaited syscalls, identical walk-up. */
479
- export async function safeRealpathAsync(p) {
480
- const resolved = path.resolve(p);
481
- try {
482
- return await fs.promises.realpath(resolved);
483
- }
484
- catch {
485
- const suffix = [];
486
- let current = resolved;
487
- for (;;) {
488
- const parent = path.dirname(current);
489
- if (parent === current)
490
- return resolved;
491
- suffix.unshift(path.basename(current));
492
- current = parent;
493
- try {
494
- return path.join(await fs.promises.realpath(current), ...suffix);
495
- }
496
- catch {
497
- // parent also doesn't exist; keep walking up
498
- }
499
- }
500
- }
501
- }
502
454
  function normalizeFsPathForComparison(value) {
503
455
  return process.platform === "win32" ? value.toLowerCase() : value;
504
456
  }
@@ -219,7 +219,7 @@ export function configSet(config, dotted, raw) {
219
219
  ? { success: true, data: value }
220
220
  : {
221
221
  success: false,
222
- error: { issues: [{ path: [], message: "apiKey must be $VAR, ${VAR}, or secret://<name>" }] },
222
+ error: { issues: [{ path: [], message: "apiKey must be $VAR, ${VAR}, or ${secrets/<name>}" }] },
223
223
  }
224
224
  : isUnknownKey
225
225
  ? { success: true, data: value }
@@ -9,13 +9,14 @@ import { isBundleSlug } from "../asset/asset-ref.js";
9
9
  import { deriveBundleId } from "../bundle-id.js";
10
10
  import { isRecord, resolveStashDir } from "../common.js";
11
11
  import { ConfigError } from "../errors.js";
12
+ import { isRefToken, substituteRefTokens } from "../ref-token.js";
12
13
  import { formatRegistryLabel, hasRegistryUrlCredentials } from "../registry-url.js";
13
14
  import { acquireConfigLock, backupExistingConfig, parseConfigText, readConfigText, withConfigLock, writeConfigAtomic, } from "./config-io.js";
14
15
  import { AkmConfigSchema, CURRENT_CONFIG_VERSION } from "./config-schema.js";
15
16
  import { bundleComponentConfig, bundleContentRoot, bundleContentRoots, bundleSourceId, bundlesToSourceEntries, filesystemBundleSourceId, isBundleEnabled, } from "./config-sources.js";
16
17
  import { resolveSchemaAt } from "./config-walker.js";
17
18
  import { deepMergeConfig, isPlainObject } from "./deep-merge.js";
18
- import { isApiKeyReference, SECRET_STORE_REFERENCE_PATTERN } from "./schema/primitives.js";
19
+ import { isApiKeyReference } from "./schema/primitives.js";
19
20
  export { stripJsonComments } from "./config-io.js";
20
21
  import { getConfigPath } from "../paths.js";
21
22
  import { warn, warnOnce } from "../warn.js";
@@ -102,7 +103,43 @@ export function loadUserConfig() {
102
103
  * the file that extends it.
103
104
  */
104
105
  function runConfigFilePipeline(text, sourcePath) {
105
- return readConfigVersion(parseConfigText(text, sourcePath), sourcePath);
106
+ const raw = parseConfigText(text, sourcePath);
107
+ return readConfigVersion(readRetiredSecretRefs(raw, [], sourcePath), sourcePath);
108
+ }
109
+ /** The retired `secret://<name>` credential form; it survives only in {@link readRetiredSecretRefs}. */
110
+ const SECRET_STORE_REFERENCE_PATTERN = /^secret:\/\/(.+)$/;
111
+ /**
112
+ * The `${ref}` a retired `secret://<x>` value reads as: `${<x>}` when `<x>` is
113
+ * already a full ref, else `${secrets/<x>}`. `undefined` for any other string.
114
+ * Shared by the config reader and the frozen workflow runner decoder.
115
+ */
116
+ export function liftRetiredSecretRef(value) {
117
+ const name = SECRET_STORE_REFERENCE_PATTERN.exec(value)?.[1];
118
+ if (name === undefined)
119
+ return undefined;
120
+ return isRefToken(`\${${name}}`) ? `\${${name}}` : `\${secrets/${name}}`;
121
+ }
122
+ /**
123
+ * `secret://<x>` is retired in favour of `${ref}`. A value in that form is read
124
+ * as its {@link liftRetiredSecretRef} with one warning naming the key; every
125
+ * ordinary config write and `akm migrate apply`'s `configFile` step then
126
+ * persist the new form.
127
+ */
128
+ function readRetiredSecretRefs(node, keyPath, sourcePath) {
129
+ if (typeof node === "string") {
130
+ const lifted = liftRetiredSecretRef(node);
131
+ if (lifted === undefined)
132
+ return node;
133
+ const dotted = keyPath.join(".");
134
+ warnOnce(`config:secret-scheme:${sourcePath ?? "inline"}:${dotted}`, `${dotted}${sourcePath ? ` at ${sourcePath}` : ""} uses the retired ${JSON.stringify(node)} form; reading it as ${JSON.stringify(lifted)}. \`akm migrate apply\` rewrites config.json.`);
135
+ return lifted;
136
+ }
137
+ if (Array.isArray(node))
138
+ return node.map((child, i) => readRetiredSecretRefs(child, [...keyPath, String(i)], sourcePath));
139
+ if (isPlainConfigObject(node)) {
140
+ return Object.fromEntries(Object.entries(node).map(([key, child]) => [key, readRetiredSecretRefs(child, [...keyPath, key], sourcePath)]));
141
+ }
142
+ return node;
106
143
  }
107
144
  /**
108
145
  * `configVersion` is read, never gated on. `"0.9.0"` is the only value akm
@@ -812,10 +849,10 @@ export function updateConfig(partial) {
812
849
  }
813
850
  /**
814
851
  * Resolve a single secret value: expand `${VAR}` / `$VAR` against
815
- * `process.env`, or look up `secret://<name>` via `resolveFromStore`. Use this
816
- * at apiKey / authorization-header consumption sites (LLM client, embedder,
817
- * agent SDK runner) — NOT on the load path. Non-string inputs pass through
818
- * unchanged.
852
+ * `process.env`, then substitute `${ref}` tokens (`${secrets/name}`) through
853
+ * `resolveFromStore`. Use this at apiKey / authorization-header consumption
854
+ * sites (LLM client, embedder, agent SDK runner) — NOT on the load path.
855
+ * Non-string inputs pass through unchanged.
819
856
  *
820
857
  * Returns the input unchanged when no substitution markers are present, so
821
858
  * literal API key strings (already-resolved secrets) are zero-cost.
@@ -824,26 +861,18 @@ export function updateConfig(partial) {
824
861
  * preserved verbatim on read — only fields explicitly routed through this
825
862
  * helper are expanded.
826
863
  *
827
- * A `secret://<name>` value that fails to resolve throws `ConfigError`
828
- * (naming the ref, never the secret) rather than silently sending an unusable
829
- * credential.
864
+ * A `${ref}` that fails to resolve throws `ConfigError` (naming the ref, never
865
+ * the secret) rather than silently sending an unusable credential.
830
866
  */
831
867
  export function resolveSecret(value, resolveFromStore) {
832
868
  if (value === undefined)
833
869
  return undefined;
834
870
  if (typeof value !== "string")
835
871
  return value;
836
- const storeRef = SECRET_STORE_REFERENCE_PATTERN.exec(value)?.[1];
837
- if (storeRef !== undefined) {
838
- const resolved = resolveFromStore?.(storeRef) ?? null;
839
- if (resolved === null) {
840
- throw new ConfigError(`Secret store reference "${value}" did not resolve to a stored value.`, "SECRET_REFERENCE_UNRESOLVED");
841
- }
842
- return resolved;
843
- }
844
872
  if (!value.includes("$"))
845
873
  return value;
846
- return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => {
874
+ // Env expansion runs first so a secret value containing `$` is never re-expanded.
875
+ const expanded = value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => {
847
876
  const name = (braced ?? bare);
848
877
  const resolved = process.env[name];
849
878
  if (!resolved) {
@@ -854,6 +883,14 @@ export function resolveSecret(value, resolveFromStore) {
854
883
  }
855
884
  return resolved ?? "";
856
885
  });
886
+ const { values, missing } = substituteRefTokens({ value: expanded }, (ref) => {
887
+ const stored = resolveFromStore?.(ref) ?? null;
888
+ return stored === null ? undefined : { kind: "secret", value: stored };
889
+ });
890
+ if (missing.length > 0) {
891
+ throw new ConfigError(`Secret store reference "\${${missing[0]}}" did not resolve to a stored value.`, "SECRET_REFERENCE_UNRESOLVED");
892
+ }
893
+ return values.value;
857
894
  }
858
895
  // Re-export source runtime helpers — implementation lives in config-sources.ts.
859
896
  export { bundleComponentConfig, bundleContentRoot, bundleContentRoots, bundleEntryToSourceEntry, bundleKeyForContentRoot, bundlePhysicalContentRoot, bundleSourceId, bundlesToSourceEntries, installedSourceDescriptor, isBundleEnabled, parseSourceSpec, primaryBundlePath, resolveActiveConfiguredSources, resolveConfiguredSources, } from "./config-sources.js";
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { z } from "zod";
10
10
  import { validateExtraParams } from "../../extra-params.js";
11
+ import { isRefToken, isSecretRefToken } from "../../ref-token.js";
11
12
  import { warnOnce } from "../../warn.js";
12
13
  import { ENGINE_NAME_PATTERN_SOURCE } from "../engine-semantics.js";
13
14
  /** Persisted config schema version. Package prerelease/patch versions do not change this value. */
@@ -28,21 +29,26 @@ export const httpUrl = z.string().refine((v) => v.startsWith("http://") || v.sta
28
29
  });
29
30
  const ENGINE_NAME_PATTERN = new RegExp(ENGINE_NAME_PATTERN_SOURCE);
30
31
  export const ENV_REFERENCE_PATTERN = /^\$[A-Za-z_][A-Za-z0-9_]*$|^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/;
31
- /** `secret://<name>` — an apiKey reference into the akm secret store, resolved via `resolveSecretFromStore`. */
32
- export const SECRET_STORE_REFERENCE_PATTERN = /^secret:\/\/(.+)$/;
33
32
  export const engineName = z
34
33
  .string()
35
34
  .max(63)
36
35
  .regex(ENGINE_NAME_PATTERN, "names must be lowercase kebab-case and must not begin with reserved akm-");
37
- /** The two symbolic apiKey forms akm accepts: an env-var reference or a secret-store reference. Never matches a literal key. */
36
+ /** The two symbolic credential forms akm accepts in config: an env-var reference (`$VAR`, `${VAR}`) or a `secrets/` ref (`${secrets/name}`). Never matches a literal key. */
38
37
  export function isApiKeyReference(value) {
39
- return ENV_REFERENCE_PATTERN.test(value) || SECRET_STORE_REFERENCE_PATTERN.test(value);
38
+ return ENV_REFERENCE_PATTERN.test(value) || isSecretRefToken(value);
40
39
  }
41
40
  export function symbolicOrWarnApiKey(label) {
42
- return z.string().superRefine((value) => {
41
+ return z.string().superRefine((value, ctx) => {
43
42
  if (isApiKeyReference(value))
44
43
  return;
45
- warnOnce(`config:literal-api-key:${label}`, `A ${label} in config.json is a literal API key, not a $VAR/\${VAR}/secret:// reference; using it as configured. Prefer \`akm config set ...apiKey '$VAR'\` (with the corresponding env var set) or \`secret://<name>\` (with \`akm secret set <name> ...\`) — see docs/reference/data-and-telemetry.md.`);
44
+ if (isRefToken(value)) {
45
+ ctx.addIssue({
46
+ code: z.ZodIssueCode.custom,
47
+ message: `a ${label} accepts only a \${secrets/<name>} ref (or a $VAR reference); "${value}" names another kind of asset`,
48
+ });
49
+ return;
50
+ }
51
+ warnOnce(`config:literal-api-key:${label}`, `A ${label} in config.json is a literal API key, not a $VAR/\${VAR} or \${secrets/<name>} reference; using it as configured. Prefer \`akm config set ...apiKey '$VAR'\` (with the corresponding env var set) or \`'\${secrets/<name>}'\` (with \`akm secret set secrets/<name> ...\`) — see docs/reference/data-and-telemetry.md.`);
46
52
  });
47
53
  }
48
54
  export const chatCompletionsEndpoint = z.string().superRefine((value, ctx) => {
@@ -64,7 +64,7 @@ export const SourceConfigEntrySchema = z
64
64
  ctx.addIssue({
65
65
  code: z.ZodIssueCode.custom,
66
66
  path: ["credential"],
67
- message: "git credential must be a $VAR or secret://<name> reference",
67
+ message: "git credential must be a $VAR, ${VAR} or ${secrets/<name>} reference",
68
68
  });
69
69
  }
70
70
  });
@@ -178,7 +178,7 @@ export const BundleConfigEntrySchema = z
178
178
  ctx.addIssue({
179
179
  code: z.ZodIssueCode.custom,
180
180
  path: ["credential"],
181
- message: "git credential must be a $VAR or secret://<name> reference",
181
+ message: "git credential must be a $VAR, ${VAR} or ${secrets/<name>} reference",
182
182
  });
183
183
  }
184
184
  const componentEntries = entry.components ? Object.entries(entry.components) : [];
@@ -13,11 +13,14 @@
13
13
  */
14
14
  import fs from "node:fs";
15
15
  import path from "node:path";
16
+ import { resolveConceptOwner } from "../indexer/lookup/adapter-concept-owner.js";
16
17
  import { resolveSourceEntries } from "../indexer/search/search-source.js";
18
+ import { isFileOrFileSymlink } from "../indexer/walk/walker.js";
17
19
  import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
18
20
  import { assertFlatAssetName, combineCreatePath, normalizeCreateSubPath } from "./asset/asset-create.js";
19
21
  import { assetPathForName, deriveCanonicalAssetName } from "./asset/asset-placement.js";
20
- import { displayRef, isFullRefInput, parseRefInput } from "./asset/resolve-ref.js";
22
+ import { makeBundleRef, parseBundleRef } from "./asset/asset-ref.js";
23
+ import { conceptIdFromTypeName, displayRef, parseRefInput, typeNameFromConceptId } from "./asset/resolve-ref.js";
21
24
  import { isWithin } from "./common.js";
22
25
  import { loadConfig } from "./config/config.js";
23
26
  import { NotFoundError, UsageError } from "./errors.js";
@@ -51,7 +54,7 @@ export function listEnvsRecursive(listKeysFn, config = loadConfig()) {
51
54
  walk(full);
52
55
  continue;
53
56
  }
54
- if (!entry.isFile())
57
+ if (!entry.isFile() && !(entry.isSymbolicLink() && isFileOrFileSymlink(full)))
55
58
  continue;
56
59
  if (entry.name !== ".env" && !entry.name.endsWith(".env"))
57
60
  continue;
@@ -71,33 +74,64 @@ export function listEnvsRecursive(listKeysFn, config = loadConfig()) {
71
74
  return result;
72
75
  }
73
76
  export function parseEnvRef(ref) {
74
- // Accept a bare env name (`prod`, `sub/prod`) or the new-grammar
75
- // `[bundle//]env/name` conceptId. A bare name's leading segment maps to no
76
- // asset type, so it is qualified with the `env/` conceptId prefix; anything
77
- // already a full new-grammar ref is parsed as-is.
78
- return parseRefInput(isFullRefInput(ref) ? ref : `env/${ref}`);
77
+ return parseTypedRef(ref, "env", "env/<name>");
79
78
  }
80
- export function findEnvSource(origin, type, name) {
81
- const sources = resolveSourceEntries(undefined, loadConfig());
82
- if (sources.length === 0) {
83
- throw new UsageError("No bundles configured. Run `akm bundle create` to create your working bundle.");
79
+ /**
80
+ * Parse `ref` as a `[bundle//]conceptId` of the given asset type. There is no
81
+ * bare-name form: `prod` is a usage error that also names the ref it probably
82
+ * meant (`env/prod`), as text only — the suggestion is never resolved or accepted.
83
+ */
84
+ function parseTypedRef(ref, type, form) {
85
+ let parsed;
86
+ try {
87
+ parsed = parseRefInput(ref);
84
88
  }
85
- const candidates = origin ? resolveSourcesForOrigin(origin, sources) : sources;
86
- const typeDir = type === "env" ? "env" : "secrets";
87
- const member = candidates.find((source) => fs.existsSync(assetPathForName(type, path.join(source.path, typeDir), name)));
88
- if (member)
89
- return member;
90
- if (!origin) {
91
- const fallback = candidates[0];
92
- if (fallback)
93
- return fallback;
94
- throw new UsageError("No bundles configured. Run `akm bundle create` to create your working bundle.");
89
+ catch (error) {
90
+ if (!(error instanceof NotFoundError))
91
+ throw error;
95
92
  }
96
- const named = candidates[0];
97
- if (!named) {
98
- throw new NotFoundError(`Source not found for origin: ${origin}`);
93
+ if (parsed?.type !== type) {
94
+ const bare = parseBundleRef(ref);
95
+ const hint = typeNameFromConceptId(bare.conceptId) === undefined
96
+ ? ` Did you mean "${makeBundleRef(bare.bundle, conceptIdFromTypeName(type, bare.conceptId))}"?`
97
+ : "";
98
+ throw new UsageError(`Expected ${type === "env" ? "an" : "a"} ${type} ref (${form} or <bundle>//${form}); got "${ref}". Write the full ref.${hint}`, "INVALID_FLAG_VALUE");
99
+ }
100
+ return parsed;
101
+ }
102
+ /** The bundle and file that own an env or secret ref right now, or undefined. Sensitive files resolve too. */
103
+ function findEnvSecretOwner(origin, conceptId) {
104
+ return resolveConceptOwner({ bundle: origin, conceptId }, resolveSourceEntries(undefined, loadConfig()), {
105
+ includeSensitive: true,
106
+ });
107
+ }
108
+ /**
109
+ * The file that owns any asset ref right now (the asset type its conceptId
110
+ * names, and the bundle-qualified spelling of the ref), or undefined when no
111
+ * bundle owns it. The lookup behind `${ref}` tokens.
112
+ */
113
+ export function resolveRefOwner(ref) {
114
+ const { bundle, conceptId } = parseBundleRef(ref);
115
+ const match = findEnvSecretOwner(bundle, conceptId);
116
+ return (match && {
117
+ absPath: match.owner.path,
118
+ type: typeNameFromConceptId(conceptId)?.type,
119
+ ref: makeBundleRef(match.bundleId, conceptId),
120
+ });
121
+ }
122
+ /**
123
+ * Is `bundle` the id of an installed bundle in the active source set? The predicate `${ref}` substitution uses to
124
+ * tell a qualified ref from shell text. A source set that cannot be read (for example `AKM_BUNDLE_DIR` naming a
125
+ * disabled bundle) answers "not installed", so shell text never raises a config error; a real ref then fails where
126
+ * it is resolved, with that error.
127
+ */
128
+ export function isInstalledBundle(bundle) {
129
+ try {
130
+ return resolveSourcesForOrigin(bundle, resolveSourceEntries(undefined, loadConfig())).length > 0;
131
+ }
132
+ catch {
133
+ return false;
99
134
  }
100
- return named;
101
135
  }
102
136
  /**
103
137
  * `config` defaults to the real `loadConfig()`, same as {@link displayDefaultBundle}.
@@ -112,31 +146,28 @@ export function makeEnvRef(name, source, config = loadConfig()) {
112
146
  return displayRef({ type: "env", name, bundleId: source?.registryId }, displayDefaultBundle(source, config));
113
147
  }
114
148
  /**
115
- * Resolve an env ref to an absolute `.env` path. Accepts the `env/<name>`
116
- * conceptId (or a bare name, auto-qualified into it). The path is returned
117
- * even when the file does not yet exist (so `create` writes under `env/`).
149
+ * Resolve an env ref to the absolute `.env` path that owns it right now.
150
+ * Accepts a full `[bundle//]env/<name>` ref.
151
+ * Throws `NotFoundError` when no bundle owns it; creating one goes through
152
+ * {@link resolveEnvWriteTarget}.
118
153
  */
119
154
  export function resolveEnvPath(ref) {
120
155
  const parsed = parseEnvRef(ref);
121
- if (parsed.type !== "env") {
122
- throw new UsageError(`Expected an env ref (env/<name>); got "${ref}".`);
123
- }
124
- const source = findEnvSource(parsed.origin, "env", parsed.name);
125
- const envRoot = path.join(source.path, "env");
126
- const envPath = assetPathForName("env", envRoot, parsed.name);
127
- // Defense-in-depth: ensure the resolved path stays inside the env directory.
128
- // validateName already rejects traversal patterns like "../../foo", but an
129
- // absolute-path override or symlink-based attack could still escape without
130
- // this second check.
131
- if (!isWithin(envPath, envRoot)) {
132
- throw new UsageError(`Env name "${parsed.name}" escapes the env directory.`);
133
- }
134
- return { name: parsed.name, absPath: envPath, source, parsedRef: parsed, dir: "env" };
156
+ const conceptId = conceptIdFromTypeName("env", parsed.name);
157
+ const match = findEnvSecretOwner(parsed.origin, conceptId);
158
+ if (!match)
159
+ throw new NotFoundError(`Env not found: ${makeBundleRef(parsed.origin, conceptId)}`);
160
+ return {
161
+ name: parsed.name,
162
+ absPath: match.owner.path,
163
+ source: match.source,
164
+ bundleId: match.bundleId,
165
+ parsedRef: parsed,
166
+ dir: "env",
167
+ };
135
168
  }
136
169
  export function parseSecretRef(ref) {
137
- // Same bare-name-vs-full-ref rule as parseEnvRef; a bare name is qualified
138
- // with the `secrets/` conceptId prefix (secret's stash subdir).
139
- return parseRefInput(isFullRefInput(ref) ? ref : `secrets/${ref}`);
170
+ return parseTypedRef(ref, "secret", "secrets/<name>");
140
171
  }
141
172
  export function makeSecretRef(name, source) {
142
173
  // F4b output-spelling flip: `secrets/name` in the primary stash,
@@ -149,39 +180,22 @@ function displayDefaultBundle(source, config = loadConfig()) {
149
180
  const primary = resolveSourceEntries(undefined, config)[0];
150
181
  return primary && path.resolve(primary.path) === path.resolve(source.path) ? source.registryId : undefined;
151
182
  }
152
- export function resolveSecretPath(ref,
153
- // Create-only (`secret set`): enforce a flat ref name and apply `--path` as
154
- // the subdirectory. Lookup callers omit this so nested refs keep resolving.
155
- create) {
183
+ export function resolveSecretPath(ref) {
156
184
  const parsed = parseSecretRef(ref);
157
- if (parsed.type !== "secret") {
158
- throw new UsageError(`Expected a secret ref (secrets/<name>); got "${ref}".`);
159
- }
160
- if (create) {
161
- assertFlatAssetName(parsed.name);
162
- parsed.name = combineCreatePath(normalizeCreateSubPath(create.subPath), parsed.name);
163
- }
164
- // Source resolution is identical for every asset type; reuse the env helper.
165
- const source = findEnvSource(parsed.origin, "secret", parsed.name);
166
- const typeRoot = path.join(source.path, "secrets");
167
- const absPath = assetPathForName("secret", typeRoot, parsed.name);
168
- // Defense-in-depth: ensure the resolved path stays inside the secrets dir.
169
- if (!isWithin(absPath, typeRoot)) {
170
- throw new UsageError(`Secret name "${parsed.name}" escapes the secrets directory.`);
171
- }
172
- return { name: parsed.name, absPath, source };
185
+ const conceptId = conceptIdFromTypeName("secret", parsed.name);
186
+ const match = findEnvSecretOwner(parsed.origin, conceptId);
187
+ if (!match)
188
+ throw new NotFoundError(`Secret not found: ${makeBundleRef(parsed.origin, conceptId)}`);
189
+ return { name: parsed.name, absPath: match.owner.path, source: match.source };
173
190
  }
174
191
  /**
175
192
  * Resolve the destination for an env mutation. Mirrors {@link resolveEnvPath}
176
193
  * but selects the source via {@link resolveWriteTarget} (writability-checked)
177
- * instead of the read-side {@link findEnvSource}. `create` enforces a flat ref
194
+ * instead of the read-side {@link resolveEnvPath}. `create` enforces a flat ref
178
195
  * name and applies `--path` as the subdirectory (matching `env create`).
179
196
  */
180
197
  export function resolveEnvWriteTarget(ref, writeTarget, create) {
181
198
  const parsed = parseEnvRef(ref);
182
- if (parsed.type !== "env") {
183
- throw new UsageError(`Expected an env ref (env/<name>); got "${ref}".`);
184
- }
185
199
  if (create) {
186
200
  assertFlatAssetName(parsed.name);
187
201
  parsed.name = combineCreatePath(normalizeCreateSubPath(create.subPath), parsed.name);
@@ -199,13 +213,10 @@ export function resolveEnvWriteTarget(ref, writeTarget, create) {
199
213
  * Resolve the destination for a secret mutation. Mirrors
200
214
  * {@link resolveSecretPath} but selects the source via
201
215
  * {@link resolveWriteTarget} (writability-checked) instead of the read-side
202
- * {@link findEnvSource}.
216
+ * {@link resolveSecretPath}.
203
217
  */
204
218
  export function resolveSecretWriteTarget(ref, writeTarget, create) {
205
219
  const parsed = parseSecretRef(ref);
206
- if (parsed.type !== "secret") {
207
- throw new UsageError(`Expected a secret ref (secrets/<name>); got "${ref}".`);
208
- }
209
220
  if (create) {
210
221
  assertFlatAssetName(parsed.name);
211
222
  parsed.name = combineCreatePath(normalizeCreateSubPath(create.subPath), parsed.name);
@@ -17,7 +17,7 @@ const CONFIG_HINTS = {
17
17
  UNSAFE_STASH_DIR: "Choose a path inside your home directory (e.g. ~/akm) or another empty workspace. The bundle directory cannot be the filesystem root, your home directory itself, or a sensitive system path like /etc, /var, ~/.config, or ~/.ssh.",
18
18
  UNKNOWN_IMPROVE_STRATEGY: "Pass one of the listed strategy names to `--strategy`, or define it under `improve.strategies`. Names are case-sensitive.",
19
19
  EXECUTION_NOT_AUTHORIZED: "Change the selected tools or update the machine/user execution policy, then retry.",
20
- SECRET_REFERENCE_UNRESOLVED: "Check the secret exists (`akm secret list`) and the name after `secret://` matches, or run `akm secret set <name> <value>` to store it.",
20
+ SECRET_REFERENCE_UNRESOLVED: "Check the secret exists (`akm secret list`) and the ref inside `${…}` matches it, or run `akm secret set secrets/<name>` to store it.",
21
21
  REGISTRY_URL_INVALID: "Registry URLs must be credential-free http(s) URLs. Fix the entry with `akm registry list` / `akm registry add`, or the AKM_REGISTRY_URL / AKM_NPM_REGISTRY variable that supplied it.",
22
22
  };
23
23
  // Code-review finding: COMPOSITION_INVALID covers several unrelated causes