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.
- package/CHANGELOG.md +60 -0
- package/STABILITY.md +22 -14
- package/dist/commands/health/checks.js +23 -7
- package/dist/commands/health/improve-metrics.js +12 -0
- package/dist/commands/improve/distill/quality-gate.js +13 -5
- package/dist/commands/improve/eval-cases.js +9 -2
- package/dist/commands/improve/improve.js +19 -4
- package/dist/commands/improve/loop-stages.js +13 -3
- package/dist/commands/tasks/tasks-cli.js +32 -0
- package/dist/commands/tasks/validate.js +186 -0
- package/dist/commands/url-checker.js +75 -16
- package/dist/core/bundle-id.js +7 -1
- package/dist/core/config/schema/engines.js +17 -0
- package/dist/core/improve-result.js +8 -0
- package/dist/core/paths.js +112 -0
- package/dist/indexer/search/search-source.js +3 -2
- package/dist/integrations/agent/engine-resolution.js +92 -3
- package/dist/integrations/agent/execution-lowering.js +15 -2
- package/dist/integrations/agent/runner-dispatch.js +16 -3
- package/dist/integrations/agent/runner.js +2 -0
- package/dist/output/shapes/passthrough.js +1 -0
- package/dist/scripts/akm-migrate-node.js +1043 -822
- package/dist/scripts/akm-migrate.js +1043 -822
- package/dist/tasks/scheduler-sync.js +51 -25
- package/dist/workflows/exec/dispatch-redaction.js +21 -7
- package/docs/integration/bundling-akm.md +1 -1
- package/docs/migration/v0.8-to-v0.9.md +32 -0
- package/docs/reference/cli.md +31 -5
- package/docs/reference/configuration.md +12 -2
- package/docs/reference/data-and-telemetry.md +1 -1
- package/docs/reference/tasks.md +8 -0
- package/package.json +1 -1
- 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
|
-
/**
|
|
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
|
-
/**
|
|
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,
|
|
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, [
|
|
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,
|
|
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"
|
|
135
|
-
|
|
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
|
}
|