akm-cli 0.9.10 → 0.9.11

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 (33) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/STABILITY.md +22 -14
  3. package/dist/commands/health/checks.js +23 -7
  4. package/dist/commands/health/improve-metrics.js +12 -0
  5. package/dist/commands/improve/distill/quality-gate.js +13 -5
  6. package/dist/commands/improve/eval-cases.js +9 -2
  7. package/dist/commands/improve/improve.js +19 -4
  8. package/dist/commands/improve/loop-stages.js +13 -3
  9. package/dist/commands/tasks/tasks-cli.js +32 -0
  10. package/dist/commands/tasks/validate.js +186 -0
  11. package/dist/commands/url-checker.js +75 -16
  12. package/dist/core/bundle-id.js +7 -1
  13. package/dist/core/config/schema/engines.js +17 -0
  14. package/dist/core/improve-result.js +8 -0
  15. package/dist/core/paths.js +112 -0
  16. package/dist/indexer/search/search-source.js +3 -2
  17. package/dist/integrations/agent/engine-resolution.js +92 -3
  18. package/dist/integrations/agent/execution-lowering.js +15 -2
  19. package/dist/integrations/agent/runner-dispatch.js +16 -3
  20. package/dist/integrations/agent/runner.js +2 -0
  21. package/dist/output/shapes/passthrough.js +1 -0
  22. package/dist/scripts/akm-migrate-node.js +1043 -822
  23. package/dist/scripts/akm-migrate.js +1043 -822
  24. package/dist/tasks/scheduler-sync.js +51 -25
  25. package/dist/workflows/exec/dispatch-redaction.js +21 -7
  26. package/docs/integration/bundling-akm.md +1 -1
  27. package/docs/migration/v0.8-to-v0.9.md +32 -0
  28. package/docs/reference/cli.md +31 -5
  29. package/docs/reference/configuration.md +12 -2
  30. package/docs/reference/data-and-telemetry.md +1 -1
  31. package/docs/reference/tasks.md +8 -0
  32. package/package.json +1 -1
  33. package/schemas/akm-config.json +8 -0
@@ -1,6 +1,8 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import fs from "node:fs";
5
+ import os from "node:os";
4
6
  import path from "node:path";
5
7
  import { deepMergeConfig } from "../../core/config/deep-merge.js";
6
8
  import { ConfigError } from "../../core/errors.js";
@@ -22,6 +24,51 @@ function envName(reference) {
22
24
  const match = /^\$(?:\{)?([A-Za-z_][A-Za-z0-9_]*)(?:\})?$/.exec(reference);
23
25
  return match?.[1];
24
26
  }
27
+ /** Expand a leading `~` the same way `loadSetupConfigFromFile` does for `--from <file>`. */
28
+ function expandHomePath(filePath) {
29
+ return filePath.startsWith("~") ? path.join(os.homedir(), filePath.slice(1)) : filePath;
30
+ }
31
+ /** Trim exactly one trailing newline (`\n` or `\r\n`) — never interior whitespace. */
32
+ function trimTrailingNewline(raw) {
33
+ return raw.replace(/\r?\n$/, "");
34
+ }
35
+ /**
36
+ * Read a file-backed credential (#905) at the dispatch boundary. Never
37
+ * includes the file's content in a thrown message — only the engine name and
38
+ * path, so a misconfigured `apiKeyFile` cannot leak its (partial) contents
39
+ * into a log or error report.
40
+ */
41
+ function readApiKeyFile(engineName, filePath) {
42
+ let raw;
43
+ try {
44
+ raw = fs.readFileSync(filePath, "utf8");
45
+ }
46
+ catch (err) {
47
+ const code = err instanceof Error ? err.code : undefined;
48
+ const reason = code === "ENOENT" ? "does not exist" : "could not be read";
49
+ throw new ConfigError(`Engine "${engineName}" apiKeyFile ${reason}: ${filePath}`, "INVALID_CONFIG_FILE");
50
+ }
51
+ const value = trimTrailingNewline(raw);
52
+ if (value.length === 0) {
53
+ throw new ConfigError(`Engine "${engineName}" apiKeyFile is empty: ${filePath}`, "INVALID_CONFIG_FILE");
54
+ }
55
+ return value;
56
+ }
57
+ /**
58
+ * Best-effort, non-throwing read of a file-backed credential's current value
59
+ * (#905), for redaction inventories and health probes that must never fail
60
+ * just because a value collector ran ahead of the real dispatch — a missing
61
+ * or empty file is reported by {@link readApiKeyFile} at the actual call.
62
+ */
63
+ export function lookupApiKeyFileValue(filePath) {
64
+ try {
65
+ const value = trimTrailingNewline(fs.readFileSync(filePath, "utf8"));
66
+ return value.length > 0 ? value : undefined;
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
25
72
  function selectedEngineName(config, layers, llmOnly) {
26
73
  for (let index = layers.length - 1; index >= 0; index--) {
27
74
  const layer = layers[index];
@@ -50,6 +97,11 @@ function resolveCredential(name, engine, config) {
50
97
  throw new ConfigError(`Engine "${name}" has an invalid symbolic apiKey reference.`, "INVALID_CONFIG_FILE");
51
98
  return { names: [explicit], required: true };
52
99
  }
100
+ // #905: an explicit apiKeyFile is its own credential source — resolved
101
+ // separately onto `ResolvedLlmUse.apiKeyFile` — so it does not also fall
102
+ // through to the implicit AKM_ENGINE_<NAME>_API_KEY convention below.
103
+ if (ownValue(engine, "apiKeyFile") !== undefined)
104
+ return undefined;
53
105
  const specific = `AKM_ENGINE_${name.toUpperCase().replaceAll("-", "_")}_API_KEY`;
54
106
  const defaults = ownValue(config, "defaults");
55
107
  return (defaults ? ownValue(defaults, "llmEngine") : undefined) === name
@@ -81,6 +133,20 @@ export function resolveCredentialFromEnv(credential, envSource = process.env) {
81
133
  }
82
134
  return undefined;
83
135
  }
136
+ /**
137
+ * The enforcing credential seam for one resolved LLM engine or SDK fallback
138
+ * (#905): the symbolic env-var descriptor first (throws if a required one is
139
+ * missing), then the file-backed alternative when no env descriptor applies.
140
+ * Call this once per operation — lease acquisition, or direct materialize —
141
+ * so a whole operation observes one stable credential value instead of
142
+ * re-reading the file on every dispatch within it.
143
+ */
144
+ export function resolveLlmCredentialValue(engine, credential, apiKeyFile, envSource = process.env) {
145
+ const envValue = resolveCredentialFromEnv(credential, envSource);
146
+ if (envValue !== undefined)
147
+ return envValue;
148
+ return apiKeyFile !== undefined ? readApiKeyFile(engine, apiKeyFile) : undefined;
149
+ }
84
150
  /** Collect materialized engine credentials for output and persistence redaction. */
85
151
  export function collectEngineCredentialValues(config, envSource = process.env) {
86
152
  const values = new Set();
@@ -92,6 +158,15 @@ export function collectEngineCredentialValues(config, envSource = process.env) {
92
158
  if (value)
93
159
  values.add(value);
94
160
  }
161
+ // #905: file-backed credential — best-effort, so a broken apiKeyFile on
162
+ // one engine never stops redaction from collecting every other engine's
163
+ // credential too.
164
+ const apiKeyFile = ownValue(engine, "apiKeyFile");
165
+ if (apiKeyFile !== undefined) {
166
+ const value = lookupApiKeyFileValue(expandHomePath(apiKeyFile));
167
+ if (value)
168
+ values.add(value);
169
+ }
95
170
  }
96
171
  return collectSensitiveValues(values);
97
172
  }
@@ -145,14 +220,22 @@ export function resolveLlmEngineUse(config, layers, options = {}) {
145
220
  if (connection[key] === undefined)
146
221
  delete connection[key];
147
222
  }
223
+ const apiKeyFile = ownValue(engine, "apiKeyFile");
148
224
  return {
149
225
  engine: name,
150
226
  connection: sterileRecord(connection),
151
227
  credential: resolveCredential(name, engine, config),
228
+ ...(apiKeyFile !== undefined ? { apiKeyFile: expandHomePath(apiKeyFile) } : {}),
152
229
  timeoutMs: effectiveTimeout(engine, layers, DEFAULT_LLM_TIMEOUT_MS),
153
230
  };
154
231
  }
155
- /** Read a resolved symbolic credential only at the runtime dispatch boundary. */
232
+ /**
233
+ * Inject an already-resolved credential value into a connection. Callers
234
+ * resolve the value themselves via {@link resolveLlmCredentialValue} (or its
235
+ * lease-cached equivalent) — this function never reads env or disk itself, so
236
+ * a frozen `ResolvedLlmUse`/`RunnerSpec` plan object can be materialized
237
+ * repeatedly without re-triggering I/O per call.
238
+ */
156
239
  export function materializeLlmConnectionWithCredential(resolved, credentialValue) {
157
240
  const extraParams = ownValue(resolved.connection, "extraParams");
158
241
  if (extraParams !== undefined) {
@@ -167,9 +250,13 @@ export function materializeLlmConnectionWithCredential(resolved, credentialValue
167
250
  timeoutMs: resolved.timeoutMs,
168
251
  });
169
252
  }
170
- /** Read and inject one resolved symbolic credential at the runtime boundary. */
253
+ /**
254
+ * Read and inject one resolved credential at the runtime boundary: the
255
+ * symbolic `$VAR` reference, or the file-backed alternative (#905) when the
256
+ * engine has no env descriptor.
257
+ */
171
258
  export function materializeLlmConnection(resolved, envSource = process.env) {
172
- return materializeLlmConnectionWithCredential(resolved, resolveCredentialFromEnv(resolved.credential, envSource));
259
+ return materializeLlmConnectionWithCredential(resolved, resolveLlmCredentialValue(resolved.engine, resolved.credential, resolved.apiKeyFile, envSource));
173
260
  }
174
261
  function lowerAgentEngine(name, engine, config) {
175
262
  const harness = getHarness(engine.platform);
@@ -217,6 +304,7 @@ function lowerAgentEngine(name, engine, config) {
217
304
  ? {
218
305
  fallbackConnection: fallback.connection,
219
306
  ...(fallback.credential ? { fallbackCredential: fallback.credential } : {}),
307
+ ...(fallback.apiKeyFile ? { fallbackApiKeyFile: fallback.apiKeyFile } : {}),
220
308
  fallbackTimeoutMs: fallback.timeoutMs,
221
309
  }
222
310
  : {}),
@@ -237,6 +325,7 @@ export function resolveEngine(name, config) {
237
325
  engine: name,
238
326
  connection: resolved.connection,
239
327
  ...(resolved.credential ? { credential: resolved.credential } : {}),
328
+ ...(resolved.apiKeyFile ? { apiKeyFile: resolved.apiKeyFile } : {}),
240
329
  timeoutMs: resolved.timeoutMs,
241
330
  };
242
331
  }
@@ -210,17 +210,29 @@ function snapshotRunnerSpec(input, options = {}) {
210
210
  if (own(cloned, "timeoutMs"))
211
211
  validateTimeout(cloned.timeoutMs, "execution runner material.timeoutMs");
212
212
  if (kind === "llm") {
213
- assertKeys(cloned, ["kind", "engine", "connection", "credential", "timeoutMs"], "execution runner material");
213
+ assertKeys(cloned, ["kind", "engine", "connection", "credential", "apiKeyFile", "timeoutMs"], "execution runner material");
214
214
  validateConnection(cloned.connection, "execution runner material.connection", !options.allowMissingLlmModel);
215
215
  if (own(cloned, "credential"))
216
216
  validateCredential(cloned.credential, "execution runner material.credential");
217
+ // #905: a path, not the secret itself — as safe to freeze as `credential`'s
218
+ // env-var name.
219
+ validateOptionalString(cloned, "apiKeyFile", "execution runner material");
217
220
  }
218
221
  else if (kind === "agent") {
219
222
  assertKeys(cloned, ["kind", "engine", "profile", "timeoutMs"], "execution runner material");
220
223
  validateProfile(cloned.profile, "execution runner material.profile");
221
224
  }
222
225
  else if (kind === "sdk") {
223
- assertKeys(cloned, ["kind", "engine", "profile", "fallbackConnection", "fallbackCredential", "fallbackTimeoutMs", "timeoutMs"], "execution runner material");
226
+ assertKeys(cloned, [
227
+ "kind",
228
+ "engine",
229
+ "profile",
230
+ "fallbackConnection",
231
+ "fallbackCredential",
232
+ "fallbackApiKeyFile",
233
+ "fallbackTimeoutMs",
234
+ "timeoutMs",
235
+ ], "execution runner material");
224
236
  validateProfile(cloned.profile, "execution runner material.profile");
225
237
  if (own(cloned, "fallbackConnection")) {
226
238
  validateConnection(cloned.fallbackConnection, "execution runner material.fallbackConnection", !options.allowMissingSdkFallbackModel);
@@ -228,6 +240,7 @@ function snapshotRunnerSpec(input, options = {}) {
228
240
  if (own(cloned, "fallbackCredential")) {
229
241
  validateCredential(cloned.fallbackCredential, "execution runner material.fallbackCredential");
230
242
  }
243
+ validateOptionalString(cloned, "fallbackApiKeyFile", "execution runner material");
231
244
  if (own(cloned, "fallbackTimeoutMs")) {
232
245
  validateTimeout(cloned.fallbackTimeoutMs, "execution runner material.fallbackTimeoutMs");
233
246
  }
@@ -20,7 +20,7 @@ import { assertNever } from "../../core/assert.js";
20
20
  import { collectSensitiveValues, isEnvPassthroughValueSafeToExpose, redactSensitiveText, redactSensitiveValue, } from "../../core/redaction.js";
21
21
  import { spawnEnvNamesFor } from "../../core/spawn-env.js";
22
22
  import { closeServer as disposeOpencodeSdkServers, opencodeSdkServerEnvironmentNames, runOpencodeSdk, } from "../harnesses/opencode-sdk/sdk-runner.js";
23
- import { lookupCredentialFromEnv, materializeLlmConnection, materializeLlmConnectionWithCredential, resolveCredentialFromEnv, } from "./engine-resolution.js";
23
+ import { lookupApiKeyFileValue, lookupCredentialFromEnv, materializeLlmConnection, materializeLlmConnectionWithCredential, resolveLlmCredentialValue, } from "./engine-resolution.js";
24
24
  import { materializeLlmRunnerConnection, materializeLlmRunnerConnectionWithCredential, } from "./runner.js";
25
25
  import { runAgent } from "./spawn.js";
26
26
  const liveRunnerDispatchLeases = new WeakMap();
@@ -78,6 +78,7 @@ function runnerLeaseBinding(spec) {
78
78
  endpoint: spec.connection.endpoint,
79
79
  provider: spec.connection.provider ?? null,
80
80
  credential: credentialBinding(spec.credential),
81
+ apiKeyFile: spec.apiKeyFile ?? null,
81
82
  });
82
83
  case "agent":
83
84
  return JSON.stringify({
@@ -99,6 +100,7 @@ function runnerLeaseBinding(spec) {
99
100
  fallbackEndpoint: spec.fallbackConnection?.endpoint ?? null,
100
101
  fallbackProvider: spec.fallbackConnection?.provider ?? null,
101
102
  fallbackCredential: credentialBinding(spec.fallbackCredential),
103
+ fallbackApiKeyFile: spec.fallbackApiKeyFile ?? null,
102
104
  });
103
105
  default:
104
106
  return assertNever(spec);
@@ -131,8 +133,12 @@ export function acquireRunnerDispatchLease(spec, envSource = process.env) {
131
133
  return typeof property === "string" ? environment.read(property) : undefined;
132
134
  },
133
135
  });
134
- const primaryCredential = spec.kind === "llm" ? resolveCredentialFromEnv(spec.credential, credentialSource) : undefined;
135
- const fallbackCredential = spec.kind === "sdk" ? resolveCredentialFromEnv(spec.fallbackCredential, credentialSource) : undefined;
136
+ const primaryCredential = spec.kind === "llm"
137
+ ? resolveLlmCredentialValue(spec.engine, spec.credential, spec.apiKeyFile, credentialSource)
138
+ : undefined;
139
+ const fallbackCredential = spec.kind === "sdk"
140
+ ? resolveLlmCredentialValue(spec.engine, spec.fallbackCredential, spec.fallbackApiKeyFile, credentialSource)
141
+ : undefined;
136
142
  const handle = Object.create(null);
137
143
  Object.defineProperty(handle, "toJSON", {
138
144
  configurable: false,
@@ -217,6 +223,12 @@ export function collectDispatchSensitiveValues(spec, opts, envSource = opts.envS
217
223
  add(lookupCredentialFromEnv(spec.credential, envSource));
218
224
  if (spec.kind === "sdk")
219
225
  add(lookupCredentialFromEnv(spec.fallbackCredential, envSource));
226
+ // #905: a file-backed credential is read at dispatch, so it must be in the
227
+ // scrub set for the same reason the env-backed one is.
228
+ if (spec.kind === "llm" && spec.apiKeyFile)
229
+ add(lookupApiKeyFileValue(spec.apiKeyFile));
230
+ if (spec.kind === "sdk" && spec.fallbackApiKeyFile)
231
+ add(lookupApiKeyFileValue(spec.fallbackApiKeyFile));
220
232
  if (spec.kind !== "llm") {
221
233
  for (const value of Object.values(spec.profile.env ?? {}))
222
234
  add(value);
@@ -315,6 +327,7 @@ export async function executeRunner(spec, prompt, opts, seams = {}, lease) {
315
327
  engine: spec.engine ?? "unnamed-sdk-fallback",
316
328
  connection: spec.fallbackConnection,
317
329
  ...(spec.fallbackCredential ? { credential: spec.fallbackCredential } : {}),
330
+ ...(spec.fallbackApiKeyFile ? { apiKeyFile: spec.fallbackApiKeyFile } : {}),
318
331
  timeoutMs: spec.fallbackTimeoutMs !== undefined
319
332
  ? spec.fallbackTimeoutMs
320
333
  : Object.hasOwn(spec.fallbackConnection, "timeoutMs")
@@ -8,6 +8,7 @@ export function materializeLlmRunnerConnection(runner) {
8
8
  engine: runner.engine,
9
9
  connection: runner.connection,
10
10
  ...(runner.credential ? { credential: runner.credential } : {}),
11
+ ...(runner.apiKeyFile ? { apiKeyFile: runner.apiKeyFile } : {}),
11
12
  timeoutMs: runner.timeoutMs ?? null,
12
13
  });
13
14
  }
@@ -17,6 +18,7 @@ export function materializeLlmRunnerConnectionWithCredential(runner, credentialV
17
18
  engine: runner.engine,
18
19
  connection: runner.connection,
19
20
  ...(runner.credential ? { credential: runner.credential } : {}),
21
+ ...(runner.apiKeyFile ? { apiKeyFile: runner.apiKeyFile } : {}),
20
22
  timeoutMs: runner.timeoutMs ?? null,
21
23
  }, credentialValue);
22
24
  }
@@ -70,6 +70,7 @@ const PASSTHROUGH_COMMANDS = [
70
70
  "task-run",
71
71
  "task-sync",
72
72
  "task-sync-dry-run",
73
+ "task-validate",
73
74
  "update",
74
75
  "upgrade",
75
76
  "workflow-abandon",