akm-cli 0.9.0 → 0.9.1-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +724 -0
- package/README.md +28 -63
- package/STABILITY.md +4 -2
- package/dist/cli/parse-args.js +7 -1
- package/dist/commands/agent/contribute-cli.js +1 -1
- package/dist/commands/env/child-env.js +14 -0
- package/dist/commands/feedback-cli.js +7 -1
- package/dist/commands/health/llm-usage.js +2 -1
- package/dist/commands/health/surfaces.js +4 -77
- package/dist/commands/health.js +65 -11
- package/dist/commands/improve/distill/quality-gate.js +6 -1
- package/dist/commands/improve/eligibility.js +7 -1
- package/dist/commands/improve/eval-cases.js +2 -0
- package/dist/commands/improve/improve.js +126 -10
- package/dist/commands/improve/locks.js +7 -0
- package/dist/commands/improve/memory/memory-improve.js +9 -0
- package/dist/commands/improve/run-context.js +5 -0
- package/dist/commands/improve/session-asset.js +4 -0
- package/dist/commands/lint/base-linter.js +31 -7
- package/dist/commands/lint/index.js +205 -51
- package/dist/commands/lint/types.js +22 -1
- package/dist/commands/proposal/repository.js +17 -1
- package/dist/commands/sources/add-cli.js +8 -2
- package/dist/commands/sources/info.js +12 -2
- package/dist/commands/sources/installed-stashes.js +6 -1
- package/dist/commands/sources/migration-help.js +12 -3
- package/dist/commands/sources/self-update.js +9 -1
- package/dist/commands/tasks/tasks.js +8 -2
- package/dist/commands/workflow-cli.js +17 -11
- package/dist/core/abort-deadline.js +28 -0
- package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
- package/dist/core/adapter/adapters/akm-adapter.js +13 -10
- package/dist/core/adapter/adapters/akm-lint.js +78 -22
- package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
- package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
- package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
- package/dist/core/asset/frontmatter.js +10 -1
- package/dist/core/common.js +147 -9
- package/dist/core/concurrent.js +32 -0
- package/dist/core/config/config-io.js +5 -45
- package/dist/core/config/schema/engines.js +14 -3
- package/dist/core/config/schema/workflow.js +11 -0
- package/dist/core/errors.js +25 -0
- package/dist/core/events.js +30 -24
- package/dist/core/extra-params.js +11 -0
- package/dist/core/file-lock.js +7 -1
- package/dist/core/fs-txn.js +15 -2
- package/dist/core/improve-result.js +5 -0
- package/dist/core/json-schema.js +344 -9
- package/dist/core/loopback.js +89 -0
- package/dist/core/migration-operation.js +17 -2
- package/dist/core/path-access.js +107 -0
- package/dist/core/paths.js +16 -2
- package/dist/core/redaction.js +86 -18
- package/dist/core/spawn-env.js +234 -0
- package/dist/core/state-db-scope.js +134 -0
- package/dist/core/state-db.js +1 -0
- package/dist/core/subprocess.js +181 -37
- package/dist/core/write-provenance.js +85 -0
- package/dist/core/write-source.js +33 -2
- package/dist/indexer/db/graph-db.js +17 -6
- package/dist/indexer/ensure-index.js +10 -3
- package/dist/indexer/index-written-assets.js +17 -2
- package/dist/indexer/indexer.js +86 -21
- package/dist/indexer/passes/memory-inference.js +4 -0
- package/dist/indexer/search/db-search.js +25 -17
- package/dist/indexer/walk/walker.js +6 -1
- package/dist/integrations/agent/detect.js +13 -1
- package/dist/integrations/agent/engine-resolution.js +24 -11
- package/dist/integrations/agent/model-aliases.js +1 -1
- package/dist/integrations/agent/profiles.js +9 -1
- package/dist/integrations/agent/spawn.js +15 -87
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
- package/dist/integrations/lockfile.js +55 -2
- package/dist/llm/client.js +14 -19
- package/dist/llm/embedder.js +23 -3
- package/dist/llm/embedders/remote.js +27 -2
- package/dist/output/html-render.js +40 -1
- package/dist/output/text/lint-format.js +17 -4
- package/dist/runtime.js +23 -1
- package/dist/scripts/akm-migrate-node.js +1714 -836
- package/dist/scripts/akm-migrate.js +1682 -804
- package/dist/setup/setup.js +22 -7
- package/dist/sources/providers/git-install.js +25 -2
- package/dist/sources/providers/git-stash.js +19 -0
- package/dist/sources/providers/git.js +1 -1
- package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
- package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
- package/dist/storage/database.js +71 -7
- package/dist/storage/engines/sqlite-migrations.js +61 -2
- package/dist/storage/managed-db.js +19 -0
- package/dist/storage/repositories/index-connection.js +39 -4
- package/dist/storage/repositories/index-entries-repository.js +6 -1
- package/dist/storage/repositories/index-meta-repository.js +11 -0
- package/dist/storage/repositories/index-schema.js +17 -2
- package/dist/storage/repositories/index-vec-repository.js +43 -5
- package/dist/storage/repositories/workflow-runs-repository.js +66 -13
- package/dist/storage/sqlite-pragmas.js +12 -1
- package/dist/tasks/log-redaction.js +156 -0
- package/dist/tasks/parser.js +82 -5
- package/dist/tasks/runner.js +222 -17
- package/dist/tasks/scheduler-invocation.js +19 -0
- package/dist/tasks/schema.js +86 -1
- package/dist/text-import-hook.mjs +1 -1
- package/dist/workflows/concurrency-policy.js +95 -1
- package/dist/workflows/exec/dispatch-redaction.js +114 -0
- package/dist/workflows/exec/exec-unit.js +542 -0
- package/dist/workflows/exec/frozen-judge.js +114 -42
- package/dist/workflows/exec/native-executor.js +465 -238
- package/dist/workflows/exec/param-secrets.js +4 -3
- package/dist/workflows/exec/run-workflow.js +424 -219
- package/dist/workflows/exec/step-work.js +506 -167
- package/dist/workflows/exec/unit-dispatch.js +31 -1
- package/dist/workflows/exec/unit-writer.js +53 -13
- package/dist/workflows/exec/worktree.js +454 -41
- package/dist/workflows/ir/compile.js +26 -2
- package/dist/workflows/ir/freeze.js +82 -15
- package/dist/workflows/ir/schema.js +105 -20
- package/dist/workflows/parser.js +242 -19
- package/dist/workflows/program/schema.js +24 -0
- package/dist/workflows/renderer.js +32 -4
- package/dist/workflows/resource-limits.js +182 -0
- package/dist/workflows/runtime/runs.js +146 -6
- package/dist/workflows/validate-summary.js +17 -2
- package/docs/README.md +74 -32
- package/docs/migration/release-notes/0.9.0.md +2 -1
- package/docs/migration/v0.7-to-v0.8.md +2 -1
- package/docs/migration/v0.8-to-v0.9.md +3 -1
- package/docs/reference/README.md +11 -4
- package/docs/reference/bundle-types.md +19 -0
- package/docs/reference/cli.md +105 -16
- package/docs/reference/configuration.md +15 -2
- package/docs/reference/data-and-telemetry.md +30 -10
- package/docs/reference/supported-formats.md +50 -0
- package/docs/reference/workflow-schema.md +1014 -0
- package/docs/reference/workflows.md +37 -633
- package/package.json +13 -6
- package/schemas/akm-config.json +18 -5
- package/schemas/akm-task.json +27 -5
- package/schemas/akm-workflow.json +92 -13
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
* import their opener from a sibling here instead of reaching up into the
|
|
11
11
|
* indexer — inverting the old storage→indexer arrow.
|
|
12
12
|
*/
|
|
13
|
-
import fs from "node:fs";
|
|
14
13
|
import { createRequire } from "node:module";
|
|
14
|
+
import { ConfigError } from "../../core/errors.js";
|
|
15
|
+
import { classifyPathAccess, describeInaccessiblePath } from "../../core/path-access.js";
|
|
15
16
|
import { getDbPath } from "../../core/paths.js";
|
|
16
17
|
import { openDatabase } from "../database.js";
|
|
17
18
|
import { openManagedDatabase } from "../managed-db.js";
|
|
19
|
+
import { SQLITE_BUSY_TIMEOUT_MS } from "../sqlite-pragmas.js";
|
|
18
20
|
import { ensureSchema } from "./index-schema.js";
|
|
19
21
|
import { loadVecExtension, warnIfVecMissing } from "./index-vec-repository.js";
|
|
20
22
|
export function openIndexDatabase(dbPath, options) {
|
|
@@ -71,20 +73,53 @@ export function openExistingDatabase(dbPath) {
|
|
|
71
73
|
// tests/storage/open-existing-database-no-create.test.ts. `create: false`
|
|
72
74
|
// below is the race-free backstop for this pre-check.
|
|
73
75
|
const resolvedPath = dbPath ?? getDbPath();
|
|
74
|
-
|
|
76
|
+
assertIndexPathReadable(resolvedPath);
|
|
77
|
+
if (classifyPathAccess(resolvedPath).access === "absent") {
|
|
75
78
|
throw new Error(`Index database not found at ${resolvedPath}. Run 'akm index' to build it.`);
|
|
76
79
|
}
|
|
77
80
|
return openManagedDatabase({ path: resolvedPath, init: loadVecExtension, create: false });
|
|
78
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Refuse to treat an UNREADABLE index as a missing one (#791).
|
|
84
|
+
*
|
|
85
|
+
* `fs.existsSync()` — which every one of these gates used to call — returns
|
|
86
|
+
* `false` for `EACCES` exactly as for `ENOENT`, so an index this process cannot
|
|
87
|
+
* read looked identical to one that had never been built. Callers then took
|
|
88
|
+
* their "no index yet" branch: `search`/`curate` returned no hits at exit 0 and
|
|
89
|
+
* told the user to run `akm index`, which would not have helped and which they
|
|
90
|
+
* may not have permission to do either.
|
|
91
|
+
*
|
|
92
|
+
* A `ConfigError` here exits 78 through the standard `{ok:false, error, code}`
|
|
93
|
+
* envelope, so both a human and a machine caller can tell "nothing indexed"
|
|
94
|
+
* from "I cannot see the index".
|
|
95
|
+
*/
|
|
96
|
+
export function assertIndexPathReadable(resolvedPath) {
|
|
97
|
+
const { access, code } = classifyPathAccess(resolvedPath);
|
|
98
|
+
if (access !== "inaccessible")
|
|
99
|
+
return;
|
|
100
|
+
throw new ConfigError(`Index database exists but is not readable: ${describeInaccessiblePath(resolvedPath, code)}.`, "DATA_DIR_UNREADABLE");
|
|
101
|
+
}
|
|
79
102
|
/**
|
|
80
103
|
* Open an existing index for queries without creating directories, a database
|
|
81
104
|
* file, journals, or running write-capable pragmas/schema initialization.
|
|
82
105
|
*/
|
|
83
106
|
export function openReadonlyExistingDatabase(dbPath) {
|
|
84
107
|
const resolvedPath = dbPath ?? getDbPath();
|
|
85
|
-
|
|
108
|
+
// `undefined` means "no index" — reserve it for a genuinely absent one, and
|
|
109
|
+
// let an unreadable index raise instead of masquerading as absent (#791).
|
|
110
|
+
assertIndexPathReadable(resolvedPath);
|
|
111
|
+
if (classifyPathAccess(resolvedPath).access === "absent")
|
|
86
112
|
return undefined;
|
|
87
|
-
|
|
113
|
+
const db = openDatabase(resolvedPath, { readonly: true, create: false });
|
|
114
|
+
// This opener bypasses openManagedDatabase/applyStandardPragmas by design (no
|
|
115
|
+
// journal or schema work on a read-only handle), but that also left
|
|
116
|
+
// busy_timeout at SQLite's default of 0. In WAL that is harmless — readers
|
|
117
|
+
// never block — but in the DELETE/TRUNCATE modes the network-FS fallback and
|
|
118
|
+
// AKM_SQLITE_JOURNAL_MODE can select, a concurrent writer makes every read
|
|
119
|
+
// fail instantly with SQLITE_BUSY. busy_timeout is legal on a read-only
|
|
120
|
+
// connection, so apply just that one.
|
|
121
|
+
db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
|
|
122
|
+
return db;
|
|
88
123
|
}
|
|
89
124
|
export function closeDatabase(db) {
|
|
90
125
|
db.close();
|
|
@@ -13,6 +13,7 @@ import path from "node:path";
|
|
|
13
13
|
import { parseBundleRef } from "../../core/asset/asset-ref.js";
|
|
14
14
|
import { conceptIdFromTypeName } from "../../core/asset/resolve-ref.js";
|
|
15
15
|
import { bestEffort } from "../../core/best-effort.js";
|
|
16
|
+
import { isPathAbsent } from "../../core/path-access.js";
|
|
16
17
|
import { getStateDbPath, withStateDb } from "../../core/state-db.js";
|
|
17
18
|
import { warn } from "../../core/warn.js";
|
|
18
19
|
import { buildSearchText } from "../../indexer/search/search-fields.js";
|
|
@@ -278,7 +279,11 @@ export function rekeyEntryInPlace(db, opts) {
|
|
|
278
279
|
* history (live-asset-wins). Best-effort + guarded on state.db's existence.
|
|
279
280
|
*/
|
|
280
281
|
function rewriteUsageEventRefForMove(opts) {
|
|
281
|
-
|
|
282
|
+
// Every other failure in here throws (see the catch below) precisely because
|
|
283
|
+
// a move that quietly drops its usage history is a wrong answer wearing a
|
|
284
|
+
// success. An unreadable state.db must not be the one silent exception —
|
|
285
|
+
// only a state.db that was never created skips (#791).
|
|
286
|
+
if (isPathAbsent(getStateDbPath()))
|
|
282
287
|
return;
|
|
283
288
|
// `usage_events.entry_ref` is the fully-qualified item_ref
|
|
284
289
|
// (`<bundle>//<conceptId>`).
|
|
@@ -17,6 +17,17 @@ export function getMeta(db, key) {
|
|
|
17
17
|
export function setMeta(db, key, value) {
|
|
18
18
|
db.prepare("INSERT OR REPLACE INTO index_meta (key, value) VALUES (?, ?)").run(key, value);
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Remove a meta key entirely.
|
|
22
|
+
*
|
|
23
|
+
* Distinct from writing an empty string: absence is what callers test for
|
|
24
|
+
* (`getMeta(...) === undefined`), and it is what lets a value be re-derived —
|
|
25
|
+
* clearing `embeddingDim` after a model change is how the vec table gets
|
|
26
|
+
* rebuilt at the new width.
|
|
27
|
+
*/
|
|
28
|
+
export function deleteMeta(db, key) {
|
|
29
|
+
db.prepare("DELETE FROM index_meta WHERE key = ?").run(key);
|
|
30
|
+
}
|
|
20
31
|
// ── Per-directory index state ───────────────────────────────────────────────
|
|
21
32
|
export function getIndexDirState(db, dirPath) {
|
|
22
33
|
const row = db
|
|
@@ -533,8 +533,23 @@ function ensureBundleRefColumns(db) {
|
|
|
533
533
|
* fallback.
|
|
534
534
|
*/
|
|
535
535
|
function ensureUniqueItemRefIndex(db) {
|
|
536
|
-
|
|
537
|
-
|
|
536
|
+
// Probe before mutating. This ran unconditionally on EVERY open as two
|
|
537
|
+
// separate autocommit statements, so there was always a window in which the
|
|
538
|
+
// index did not exist — a concurrent open (registry-cache search, indexer,
|
|
539
|
+
// improve) could DROP between the other's DROP and CREATE and then fail with
|
|
540
|
+
// "index idx_entries_item_ref already exists", or serve a query with no index
|
|
541
|
+
// at all. A DB whose index is already UNIQUE needs no work.
|
|
542
|
+
const existing = db
|
|
543
|
+
.prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_entries_item_ref'")
|
|
544
|
+
.get();
|
|
545
|
+
if (existing?.sql && /\bUNIQUE\b/i.test(existing.sql))
|
|
546
|
+
return;
|
|
547
|
+
// Pre-v19 (non-unique index) or absent: convert atomically so a racing open
|
|
548
|
+
// sees either the old index or the new one, never neither.
|
|
549
|
+
db.transaction(() => {
|
|
550
|
+
db.exec("DROP INDEX IF EXISTS idx_entries_item_ref");
|
|
551
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_item_ref ON entries(item_ref)");
|
|
552
|
+
})();
|
|
538
553
|
}
|
|
539
554
|
/**
|
|
540
555
|
* Returns true when a table exists in the current database.
|
|
@@ -22,10 +22,10 @@ export function loadVecExtension(db) {
|
|
|
22
22
|
try {
|
|
23
23
|
const esmRequire = createRequire(import.meta.url);
|
|
24
24
|
const sqliteVec = esmRequire("sqlite-vec");
|
|
25
|
-
// `db`
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// so
|
|
25
|
+
// `db` is the storage boundary's handle. On Bun that IS the bun:sqlite
|
|
26
|
+
// handle; on Node it is a wrapper, which must forward `loadExtension` for
|
|
27
|
+
// this call to work at all (see openNodeDatabase in storage/database.ts —
|
|
28
|
+
// it did not, so vec could never load on the entire npm distribution).
|
|
29
29
|
sqliteVec.load(db);
|
|
30
30
|
vecStatus.set(db, true);
|
|
31
31
|
}
|
|
@@ -56,7 +56,45 @@ export function setVecFastPathReady(db, ready) {
|
|
|
56
56
|
* rather than silently returning partial fast-path results.
|
|
57
57
|
*/
|
|
58
58
|
export function isVecFastPathReady(db) {
|
|
59
|
-
|
|
59
|
+
if (getMeta(db, VEC_FAST_PATH_READY_META) === "0")
|
|
60
|
+
return false;
|
|
61
|
+
// The meta flag alone is not sufficient. An index built while sqlite-vec was
|
|
62
|
+
// unavailable wrote only BLOB rows, and because "unavailable" outcomes were
|
|
63
|
+
// not counted as failures the flag was still set to "1" against a table that
|
|
64
|
+
// is empty or absent. If sqlite-vec later becomes loadable — the user installs
|
|
65
|
+
// it, or the same index is opened under the other runtime — the fast path
|
|
66
|
+
// would then be trusted and return zero neighbours while the BLOB table holds
|
|
67
|
+
// every embedding. Indexes written by earlier versions still carry that stale
|
|
68
|
+
// flag, so the read path has to verify the table really exists.
|
|
69
|
+
return hasVecTable(db);
|
|
70
|
+
}
|
|
71
|
+
const vecTablePresent = new WeakMap();
|
|
72
|
+
/**
|
|
73
|
+
* Whether `entries_vec` exists on this connection, memoized per handle.
|
|
74
|
+
*
|
|
75
|
+
* openExistingDatabase loads the vec extension but deliberately does not run
|
|
76
|
+
* ensureSchema, so the table is not created on read paths — its absence is a
|
|
77
|
+
* normal state, not an error.
|
|
78
|
+
*/
|
|
79
|
+
function hasVecTable(db) {
|
|
80
|
+
// Only a POSITIVE result is memoized. The table cannot vanish from a live
|
|
81
|
+
// connection, but it CAN appear — ensureSchema creates it partway through an
|
|
82
|
+
// index run — so caching "absent" would pin a stale answer for the rest of
|
|
83
|
+
// the handle's life.
|
|
84
|
+
if (vecTablePresent.get(db) === true)
|
|
85
|
+
return true;
|
|
86
|
+
let present = false;
|
|
87
|
+
try {
|
|
88
|
+
present =
|
|
89
|
+
db.prepare("SELECT 1 FROM sqlite_master WHERE type IN ('table','view') AND name = 'entries_vec'").get() !==
|
|
90
|
+
undefined;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
present = false;
|
|
94
|
+
}
|
|
95
|
+
if (present)
|
|
96
|
+
vecTablePresent.set(db, true);
|
|
97
|
+
return present;
|
|
60
98
|
}
|
|
61
99
|
/** Remove both vector representations for an entry whose embedding input changed. */
|
|
62
100
|
export function deleteEntryVectors(db, id) {
|
|
@@ -2,6 +2,7 @@
|
|
|
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
4
|
import { openStateDatabase, withImmediateTransaction } from "../../core/state-db.js";
|
|
5
|
+
import { borrowScopedStateDb, withStateDbScope } from "../../core/state-db-scope.js";
|
|
5
6
|
import { resolveStorageLocations } from "../locations.js";
|
|
6
7
|
/**
|
|
7
8
|
* Repository owning every raw SQL statement against `workflow_runs` and
|
|
@@ -214,8 +215,10 @@ export class WorkflowRunsRepository {
|
|
|
214
215
|
//
|
|
215
216
|
// Writes to `workflow_run_units` should go through the serialized writer
|
|
216
217
|
// queue (`src/workflows/exec/unit-writer.ts`) when N units may complete
|
|
217
|
-
// concurrently — SQLite has a single writer and
|
|
218
|
-
//
|
|
218
|
+
// concurrently — SQLite has a single writer per database FILE, and outside a
|
|
219
|
+
// {@link withWorkflowRunsConnection} scope `withWorkflowRunsRepo` opens a
|
|
220
|
+
// fresh connection per call (so N concurrent writers would contend against
|
|
221
|
+
// each other for the write lock).
|
|
219
222
|
getUnitsForRun(runId) {
|
|
220
223
|
return this.db
|
|
221
224
|
.prepare("SELECT * FROM workflow_run_units WHERE run_id = ? ORDER BY started_at ASC, unit_id ASC")
|
|
@@ -323,22 +326,55 @@ export class WorkflowRunsRepository {
|
|
|
323
326
|
`recoverable state.`);
|
|
324
327
|
}
|
|
325
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* Finish a unit row ONLY while it is still the exact row a specific dispatch
|
|
331
|
+
* inserted: `running`, with that dispatch's `started_at`. The native
|
|
332
|
+
* executor's guarded finish (single-driver invariant): a run stolen by
|
|
333
|
+
* another engine re-dispatches the unit through {@link insertUnit}, which
|
|
334
|
+
* REPLACES the row (fresh `started_at`, bumped `attempts`) — the stale
|
|
335
|
+
* driver's finish then matches NOTHING instead of clobbering the new
|
|
336
|
+
* driver's live dispatch. Returns whether the row was finished; a zero-row
|
|
337
|
+
* match is a caller-classified outcome here (the executor distinguishes
|
|
338
|
+
* "replaced by another driver" from "row vanished"), unlike
|
|
339
|
+
* {@link finishUnit}'s loud throw, whose callers guarantee their row exists.
|
|
340
|
+
*/
|
|
341
|
+
finishUnitFromDispatch(input) {
|
|
342
|
+
const result = this.db
|
|
343
|
+
.prepare(`UPDATE workflow_run_units
|
|
344
|
+
SET status = ?, result_json = ?, tokens = ?, failure_reason = ?, session_id = ?, finished_at = ?
|
|
345
|
+
WHERE run_id = ? AND unit_id = ? AND status = 'running' AND started_at = ?`)
|
|
346
|
+
.run(input.status, input.resultJson, input.tokens, input.failureReason, input.sessionId ?? null, input.finishedAt, input.runId, input.unitId, input.dispatchStartedAt);
|
|
347
|
+
return Number(result.changes) === 1;
|
|
348
|
+
}
|
|
326
349
|
}
|
|
327
350
|
/**
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
* connection exactly once when `fn` settles.
|
|
351
|
+
* Run `fn` against a {@link WorkflowRunsRepository} bound to state.db
|
|
352
|
+
* ({@link StorageLocations.stateDb}, the post-cutover home of the
|
|
353
|
+
* `workflow_runs` / `workflow_run_steps` / `workflow_run_units` tables).
|
|
332
354
|
*
|
|
333
|
-
*
|
|
334
|
-
* `
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
355
|
+
* Connection lifetime — BORROW-OR-OWN (mirrors `withStateDb`'s `borrowed`
|
|
356
|
+
* option and `appendEvent`'s `ctx.db` seam):
|
|
357
|
+
*
|
|
358
|
+
* - Inside a {@link withWorkflowRunsConnection} scope, the ambient handle is
|
|
359
|
+
* BORROWED and left open for the rest of the scope. A wide `map` fan-out
|
|
360
|
+
* therefore opens ONE connection for the whole step instead of two per unit
|
|
361
|
+
* (insert + finish) — `openStateDatabase` registers a maintenance activity
|
|
362
|
+
* lockfile and opens a read-only ledger-preflight handle on every call, so
|
|
363
|
+
* the per-call cost is milliseconds, not microseconds.
|
|
364
|
+
* - Outside a scope the behaviour is unchanged: open a fresh connection, run
|
|
365
|
+
* `fn`, close it in a `finally`.
|
|
366
|
+
*
|
|
367
|
+
* Repository read methods fully materialise their results, so closing an owned
|
|
368
|
+
* handle here never truncates lazy iteration (WS5 connection-lifetime rule).
|
|
369
|
+
* The signature and semantics are identical in both modes — reuse is purely an
|
|
370
|
+
* internal optimisation and no caller needs to know which mode it is in.
|
|
339
371
|
*/
|
|
340
372
|
export async function withWorkflowRunsRepo(fn) {
|
|
341
|
-
const
|
|
373
|
+
const stateDb = resolveStorageLocations().stateDb;
|
|
374
|
+
const borrowed = borrowScopedStateDb(stateDb);
|
|
375
|
+
if (borrowed)
|
|
376
|
+
return await Promise.resolve(fn(new WorkflowRunsRepository(borrowed)));
|
|
377
|
+
const db = openStateDatabase(stateDb);
|
|
342
378
|
try {
|
|
343
379
|
return await Promise.resolve(fn(new WorkflowRunsRepository(db)));
|
|
344
380
|
}
|
|
@@ -346,3 +382,20 @@ export async function withWorkflowRunsRepo(fn) {
|
|
|
346
382
|
db.close();
|
|
347
383
|
}
|
|
348
384
|
}
|
|
385
|
+
/**
|
|
386
|
+
* Run `fn` with ONE state.db connection shared by every `withWorkflowRunsRepo`
|
|
387
|
+
* call (and every {@link import("../../core/events.js").appendEvent}) inside its
|
|
388
|
+
* async extent. The handle opens on first use and closes when `fn` settles;
|
|
389
|
+
* nesting joins the outer scope.
|
|
390
|
+
*
|
|
391
|
+
* Correctness under concurrency: `bun:sqlite` statements and
|
|
392
|
+
* `withImmediateTransaction` bodies run synchronously to completion, so
|
|
393
|
+
* logically concurrent units cannot interleave statements on the shared handle
|
|
394
|
+
* in a single-threaded event loop — sharing REMOVES in-process writer
|
|
395
|
+
* contention instead of creating it. Cross-process arbitration (WAL,
|
|
396
|
+
* `busy_timeout`, the run lease) is untouched. See `core/state-db-scope.ts` for
|
|
397
|
+
* the escaped-async-work guard.
|
|
398
|
+
*/
|
|
399
|
+
export function withWorkflowRunsConnection(fn) {
|
|
400
|
+
return withStateDbScope(fn, { path: resolveStorageLocations().stateDb });
|
|
401
|
+
}
|
|
@@ -95,6 +95,17 @@ export function isNetworkFilesystem(fsType) {
|
|
|
95
95
|
return false;
|
|
96
96
|
return NETWORK_FS_MAGICS.has(fsType);
|
|
97
97
|
}
|
|
98
|
+
/** Options for {@link applyStandardPragmas}. */
|
|
99
|
+
/**
|
|
100
|
+
* How long a statement waits for a lock before failing with SQLITE_BUSY.
|
|
101
|
+
*
|
|
102
|
+
* Exported so read-only openers can apply it too. They cannot run the rest of
|
|
103
|
+
* the standard set (journal_mode and foreign_keys are write operations), but
|
|
104
|
+
* the default of 0 makes reads fail INSTANTLY under writer contention — which
|
|
105
|
+
* matters in the DELETE/TRUNCATE journal modes 0.9.1's network-filesystem
|
|
106
|
+
* fallback and `AKM_SQLITE_JOURNAL_MODE` can select, where readers do block.
|
|
107
|
+
*/
|
|
108
|
+
export const SQLITE_BUSY_TIMEOUT_MS = 30_000;
|
|
98
109
|
/**
|
|
99
110
|
* Apply AKM's standard opening PRAGMAs to `db`, in order:
|
|
100
111
|
* 1. `journal_mode` = the configured mode (with WAL→DELETE network-FS fallback)
|
|
@@ -128,7 +139,7 @@ export function applyStandardPragmas(db, opts = {}) {
|
|
|
128
139
|
// lock instead of failing immediately with SQLITE_BUSY. For the WAL default
|
|
129
140
|
// this is a no-op (WAL→WAL changes nothing), so byte-identical behaviour is
|
|
130
141
|
// preserved.
|
|
131
|
-
db.exec(
|
|
142
|
+
db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
|
|
132
143
|
db.exec(`PRAGMA journal_mode = ${mode}`);
|
|
133
144
|
if (opts.foreignKeys !== false) {
|
|
134
145
|
db.exec("PRAGMA foreign_keys = ON");
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* Decide which exact values must be scrubbed from a task's persisted run log
|
|
6
|
+
* (issue #755).
|
|
7
|
+
*
|
|
8
|
+
* # The gap
|
|
9
|
+
*
|
|
10
|
+
* All three task target kinds funnel through `persistRunLog`, which applied
|
|
11
|
+
* only `redactCredentialPatterns` — credential *shapes* (`Bearer …`, `sk-…`,
|
|
12
|
+
* webhook URLs). Prompt- and workflow-target runs additionally redact the exact
|
|
13
|
+
* secret values reachable by the run before their output ever reaches the log.
|
|
14
|
+
* Command-target runs did not: a scheduled command that echoed a configured
|
|
15
|
+
* secret whose value is not credential-shaped persisted it verbatim, into both
|
|
16
|
+
* the `.log` file and `logs.db`, for the whole retention window.
|
|
17
|
+
*
|
|
18
|
+
* # Why the obvious fix is wrong
|
|
19
|
+
*
|
|
20
|
+
* The issue proposed reusing {@link isEnvPassthroughValueSafeToExpose} — the
|
|
21
|
+
* filter the prompt path uses — over the env handed to the child. That filter
|
|
22
|
+
* fails CLOSED for any name outside a 22-entry allowlist, which is right where
|
|
23
|
+
* it currently runs: the prompt path filters `envPassthrough`, a short list the
|
|
24
|
+
* operator explicitly declared. A command task inherits the WHOLE ambient
|
|
25
|
+
* environment, so the same rule classifies essentially everything as secret.
|
|
26
|
+
* Measured on a developer machine: 127 of 132 variables, 25 of them with
|
|
27
|
+
* one-character values (`SHLVL=1`, `OLDPWD=/`, `GIT_TERMINAL_PROMPT=0`).
|
|
28
|
+
* Redaction is substring replacement, so those become live needles:
|
|
29
|
+
*
|
|
30
|
+
* Build finished in 12.4s -> Build finished in [REDACTED]2.[REDACTED]s
|
|
31
|
+
* 3 tests passed, 0 failed -> [REDACTED] tests passed, [REDACTED] failed
|
|
32
|
+
* wrote dist/index.js (48 KB) -> wrote dist[REDACTED]index.js ([REDACTED]8 KB)
|
|
33
|
+
*
|
|
34
|
+
* A fix that destroys every command log is not a fix.
|
|
35
|
+
*
|
|
36
|
+
* # What this does instead
|
|
37
|
+
*
|
|
38
|
+
* Three sources, and the distinction between them is the whole design:
|
|
39
|
+
*
|
|
40
|
+
* 1. **Declared by config** — the config names which variables hold
|
|
41
|
+
* credentials (`engines.<n>.apiKey: ${VAR}`, `embedding.apiKey`, and the
|
|
42
|
+
* implicit `AKM_ENGINE_<NAME>_API_KEY` / `AKM_LLM_API_KEY` /
|
|
43
|
+
* `AKM_EMBED_API_KEY` recipes). akm KNOWS these are secret.
|
|
44
|
+
* 2. **Declared by the task** — the `redact:` list, names only.
|
|
45
|
+
* 3. **Inferred** — a name-shape heuristic over the remaining environment, for
|
|
46
|
+
* the ambient credential akm was never told about.
|
|
47
|
+
*
|
|
48
|
+
* Only (3) is a guess, so only (3) carries {@link MIN_INFERRED_SECRET_LENGTH}.
|
|
49
|
+
* A declared secret is redacted at ANY length, because the operator told us
|
|
50
|
+
* what it is; applying a floor to declared values would silently stop redacting
|
|
51
|
+
* short secrets that are scrubbed today. The floor exists solely to stop a
|
|
52
|
+
* *guess* from mangling a log, and 8 clears every real credential format (AWS
|
|
53
|
+
* key id 20, GitHub PAT 40, `sk-…` 40+) while excluding the flags and counters
|
|
54
|
+
* that a name heuristic occasionally catches.
|
|
55
|
+
*
|
|
56
|
+
* Note what is deliberately NOT collected: the akm secret store on disk. A
|
|
57
|
+
* spawned command sees `process.env`, not akm's stores — a stored secret can
|
|
58
|
+
* only be echoed if it is already in the environment, where the rules above
|
|
59
|
+
* catch it by name. Walking every bundle's `secrets/` on each task firing would
|
|
60
|
+
* cost a recursive readdir plus an unbounded read for values the child cannot
|
|
61
|
+
* reach anyway. `redact:` is the escape hatch for a secret injected under a
|
|
62
|
+
* name none of the rules recognise.
|
|
63
|
+
*/
|
|
64
|
+
import { collectSensitiveValues, isEnvPassthroughValueSafeToExpose } from "../core/redaction.js";
|
|
65
|
+
import { collectEngineCredentialValues } from "../integrations/agent/engine-resolution.js";
|
|
66
|
+
/**
|
|
67
|
+
* Shortest value an INFERRED (name-heuristic) match may contribute as a
|
|
68
|
+
* redaction needle. Declared secrets bypass this entirely.
|
|
69
|
+
*
|
|
70
|
+
* Redaction replaces substrings, so a short needle is not merely useless — it
|
|
71
|
+
* corrupts unrelated output. Below 8 the noise tier is fully intact (`1`, `0`,
|
|
72
|
+
* `/`, `80`, `true`, `xhigh`, `31999`); at 8 a chance collision with ordinary
|
|
73
|
+
* log vocabulary is negligible, and every credential format in real use is far
|
|
74
|
+
* longer. It also matches the conventional minimum password length, so a
|
|
75
|
+
* secret shorter than this is already outside normal policy.
|
|
76
|
+
*/
|
|
77
|
+
export const MIN_INFERRED_SECRET_LENGTH = 8;
|
|
78
|
+
/**
|
|
79
|
+
* Environment names whose VALUE is treated as a credential on shape alone.
|
|
80
|
+
*
|
|
81
|
+
* The keyword must be a whole `_`-delimited word. Anchoring on only one side
|
|
82
|
+
* would drag in ordinary configuration from whichever side is left open:
|
|
83
|
+
* a leading anchor alone matches `KEYBOARD_LAYOUT` and `AUTHOR`, a trailing one
|
|
84
|
+
* matches `MONKEY` and `BYPASS`. Whole-word matching gets `GH_TOKEN`,
|
|
85
|
+
* `NPM_AUTH_TOKEN`, `MY_API_KEY`, `DB_PASS` and `AWS_SECRET_ACCESS_KEY` right.
|
|
86
|
+
*/
|
|
87
|
+
const INFERRED_SECRET_NAME = /(?:^|_)(?:API_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|CREDENTIALS?|AUTH)(?:_|$)/i;
|
|
88
|
+
/**
|
|
89
|
+
* Credential variables whose name is a single glued word, which no
|
|
90
|
+
* word-boundary rule can see. Enumerated rather than matched: loosening the
|
|
91
|
+
* pattern enough to catch `PGPASSWORD` also catches `MONKEY`.
|
|
92
|
+
*
|
|
93
|
+
* `PWD` cannot be a keyword above for the same reason it appears here as part
|
|
94
|
+
* of `MYSQL_PWD` — on its own it is the working directory.
|
|
95
|
+
*/
|
|
96
|
+
const KNOWN_SECRET_NAMES = new Set(["PGPASSWORD", "MYSQL_PWD"]);
|
|
97
|
+
/** True when the NAME alone marks this variable as holding a credential. */
|
|
98
|
+
export function isInferredSecretName(name) {
|
|
99
|
+
return KNOWN_SECRET_NAMES.has(name.toUpperCase()) || INFERRED_SECRET_NAME.test(name);
|
|
100
|
+
}
|
|
101
|
+
/** Resolve `${VAR}` / `$VAR` to the variable NAME, or undefined for anything else. */
|
|
102
|
+
function envRefName(spec) {
|
|
103
|
+
if (!spec)
|
|
104
|
+
return undefined;
|
|
105
|
+
const match = /^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/.exec(spec.trim());
|
|
106
|
+
return match?.[1];
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Every value that must be scrubbed from one task's persisted output.
|
|
110
|
+
*
|
|
111
|
+
* `declaredNames` comes from the task's `redact:` list. A name that is unset in
|
|
112
|
+
* `env` simply contributes nothing — naming a variable you do not currently
|
|
113
|
+
* export is not an error.
|
|
114
|
+
*/
|
|
115
|
+
export function collectTaskLogSensitiveValues(input) {
|
|
116
|
+
const { env, config, declaredNames } = input;
|
|
117
|
+
const values = new Set();
|
|
118
|
+
const addDeclared = (value) => {
|
|
119
|
+
if (value === undefined)
|
|
120
|
+
return;
|
|
121
|
+
const trimmed = value.trim();
|
|
122
|
+
// Both spellings: `resolveSecret` does not trim but `resolveCredentialFromEnv`
|
|
123
|
+
// does, so a variable with trailing whitespace reaches an output boundary in
|
|
124
|
+
// either form depending on which path materialized it.
|
|
125
|
+
if (value.length > 0)
|
|
126
|
+
values.add(value);
|
|
127
|
+
if (trimmed.length > 0)
|
|
128
|
+
values.add(trimmed);
|
|
129
|
+
};
|
|
130
|
+
// (1) Declared by config — engine credentials, via the collector the prompt
|
|
131
|
+
// path already uses, plus the embedding key it does not cover.
|
|
132
|
+
if (config) {
|
|
133
|
+
for (const value of collectEngineCredentialValues(config, env))
|
|
134
|
+
values.add(value);
|
|
135
|
+
addDeclared(env[envRefName(config.embedding?.apiKey) ?? "AKM_EMBED_API_KEY"]);
|
|
136
|
+
}
|
|
137
|
+
// (2) Declared by the task's `redact:` list — names only, never values.
|
|
138
|
+
for (const name of declaredNames ?? [])
|
|
139
|
+
addDeclared(env[name]);
|
|
140
|
+
// (3) Inferred from the name shape. The only guessing tier, so the only one
|
|
141
|
+
// with a length floor — and still subject to the value-level check that keeps
|
|
142
|
+
// an allowlisted name from being treated as secret.
|
|
143
|
+
for (const [name, value] of Object.entries(env)) {
|
|
144
|
+
if (value === undefined || value.length < MIN_INFERRED_SECRET_LENGTH)
|
|
145
|
+
continue;
|
|
146
|
+
if (!isInferredSecretName(name))
|
|
147
|
+
continue;
|
|
148
|
+
if (isEnvPassthroughValueSafeToExpose(name, value))
|
|
149
|
+
continue;
|
|
150
|
+
values.add(value);
|
|
151
|
+
}
|
|
152
|
+
// Expands credential-bearing URLs into their embedded components. Can yield
|
|
153
|
+
// needles shorter than the floor (a URL's password), which is correct: the
|
|
154
|
+
// operator's own value implied them.
|
|
155
|
+
return collectSensitiveValues(values);
|
|
156
|
+
}
|
package/dist/tasks/parser.js
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
* workflow: workflows/daily-backup
|
|
13
13
|
* params:
|
|
14
14
|
* region: us-east-1
|
|
15
|
+
* timeoutMs: 3600000 # whole-run bound; omit for the unattended
|
|
16
|
+
* # default, `null` to opt out entirely
|
|
17
|
+
* maxSteps: 20 # optional run bounds, same as the
|
|
18
|
+
* maxRetries: 1 # `akm workflow run` flags
|
|
15
19
|
* # ...or:
|
|
16
20
|
* prompt: agents/my-agent # asset ref
|
|
17
21
|
* # ...or:
|
|
@@ -27,6 +31,8 @@
|
|
|
27
31
|
* description: …
|
|
28
32
|
* when_to_use: …
|
|
29
33
|
* tags: [scheduled, backup]
|
|
34
|
+
* redact: [ACME_DEPLOY_TOKEN] # optional: env var NAMES (never values) to
|
|
35
|
+
* # scrub from this task's persisted log
|
|
30
36
|
* ```
|
|
31
37
|
*
|
|
32
38
|
* Validation lives in {@link validateTaskDocument}. The parser enforces the
|
|
@@ -38,7 +44,8 @@ import { parse as parseYaml } from "yaml";
|
|
|
38
44
|
import { isFullRefInput } from "../core/asset/resolve-ref.js";
|
|
39
45
|
import { UsageError } from "../core/errors.js";
|
|
40
46
|
import { formatExtraParamsIssue, validateExtraParams } from "../core/extra-params.js";
|
|
41
|
-
import {
|
|
47
|
+
import { WORKFLOW_ENV_VAR_NAME_PATTERN, WORKFLOW_MAX_RETRIES } from "../workflows/resource-limits.js";
|
|
48
|
+
import { TASK_MAX_REDACT_NAMES, TASK_MAX_TIMEOUT_MS, TASK_SCHEMA_VERSION, } from "./schema.js";
|
|
42
49
|
import { validateTaskId } from "./task-id.js";
|
|
43
50
|
export function parseTaskDocument(input) {
|
|
44
51
|
const { yaml, filePath } = input;
|
|
@@ -79,15 +86,25 @@ export function parseTaskDocument(input) {
|
|
|
79
86
|
}
|
|
80
87
|
let target;
|
|
81
88
|
if (hasWorkflow) {
|
|
82
|
-
rejectTargetFields(data, ["params"], id, filePath);
|
|
89
|
+
rejectTargetFields(data, ["params", "timeoutMs", "maxSteps", "maxRetries"], id, filePath);
|
|
83
90
|
const ref = requireString(data.workflow, "workflow", filePath);
|
|
84
91
|
if (!ref) {
|
|
85
92
|
throw new UsageError(`Task "${id}" has empty \`workflow\`. File: ${filePath}`, "INVALID_FLAG_VALUE");
|
|
86
93
|
}
|
|
94
|
+
// The three run bounds `akm workflow run` takes as flags, declared in the
|
|
95
|
+
// task file instead: an unattended run gets the same abort path the
|
|
96
|
+
// interactive CLI has. `timeoutMs` left unset falls back to the runner's
|
|
97
|
+
// default (see DEFAULT_WORKFLOW_TASK_TIMEOUT_MS); `null` opts out.
|
|
98
|
+
const workflowTimeoutMs = readTimeout(data.timeoutMs, filePath);
|
|
99
|
+
const maxSteps = readBoundedInteger(data.maxSteps, "maxSteps", 1, undefined, filePath);
|
|
100
|
+
const maxRetries = readBoundedInteger(data.maxRetries, "maxRetries", 0, WORKFLOW_MAX_RETRIES, filePath);
|
|
87
101
|
target = {
|
|
88
102
|
kind: "workflow",
|
|
89
103
|
ref,
|
|
90
104
|
params: readParams(data.params, filePath),
|
|
105
|
+
...(workflowTimeoutMs !== undefined ? { timeoutMs: workflowTimeoutMs } : {}),
|
|
106
|
+
...(maxSteps !== undefined ? { maxSteps } : {}),
|
|
107
|
+
...(maxRetries !== undefined ? { maxRetries } : {}),
|
|
91
108
|
};
|
|
92
109
|
}
|
|
93
110
|
else if (hasCommand) {
|
|
@@ -114,6 +131,7 @@ export function parseTaskDocument(input) {
|
|
|
114
131
|
};
|
|
115
132
|
}
|
|
116
133
|
const timeoutMs = hasCommand ? readTimeout(data.timeoutMs, filePath) : undefined;
|
|
134
|
+
const redact = readRedactNames(data.redact, filePath);
|
|
117
135
|
return {
|
|
118
136
|
version: TASK_SCHEMA_VERSION,
|
|
119
137
|
schemaVersion: TASK_SCHEMA_VERSION,
|
|
@@ -127,6 +145,7 @@ export function parseTaskDocument(input) {
|
|
|
127
145
|
...(tags ? { tags } : {}),
|
|
128
146
|
source: { path: filePath },
|
|
129
147
|
timeoutMs,
|
|
148
|
+
...(redact ? { redact } : {}),
|
|
130
149
|
};
|
|
131
150
|
}
|
|
132
151
|
const TASK_KEYS = new Set([
|
|
@@ -144,9 +163,22 @@ const TASK_KEYS = new Set([
|
|
|
144
163
|
"engine",
|
|
145
164
|
"model",
|
|
146
165
|
"timeoutMs",
|
|
166
|
+
"maxSteps",
|
|
167
|
+
"maxRetries",
|
|
147
168
|
"llm",
|
|
169
|
+
"redact",
|
|
170
|
+
]);
|
|
171
|
+
const SHARED_KEYS = new Set([
|
|
172
|
+
"version",
|
|
173
|
+
"name",
|
|
174
|
+
"description",
|
|
175
|
+
"when_to_use",
|
|
176
|
+
"tags",
|
|
177
|
+
"schedule",
|
|
178
|
+
"enabled",
|
|
179
|
+
// `redact:` is target-agnostic: every kind funnels through the same log sink.
|
|
180
|
+
"redact",
|
|
148
181
|
]);
|
|
149
|
-
const SHARED_KEYS = new Set(["version", "name", "description", "when_to_use", "tags", "schedule", "enabled"]);
|
|
150
182
|
function requireVersion(data, id, filePath) {
|
|
151
183
|
if (data.version === TASK_SCHEMA_VERSION)
|
|
152
184
|
return;
|
|
@@ -258,9 +290,54 @@ function readTimeout(value, filePath) {
|
|
|
258
290
|
return undefined;
|
|
259
291
|
if (value === null)
|
|
260
292
|
return null;
|
|
261
|
-
|
|
293
|
+
// The ceiling is `setTimeout`'s, not a policy: a larger delay overflows and
|
|
294
|
+
// fires immediately, turning a generous timeout into an instant abort.
|
|
295
|
+
if (typeof value === "number" && Number.isInteger(value) && value > 0 && value <= TASK_MAX_TIMEOUT_MS)
|
|
262
296
|
return value;
|
|
263
|
-
throw new UsageError(`Key "timeoutMs" must be
|
|
297
|
+
throw new UsageError(`Key "timeoutMs" must be an integer from 1 through ${TASK_MAX_TIMEOUT_MS}, or null. File: ${filePath}`, "INVALID_FLAG_VALUE");
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Read the `redact:` opt-in list — environment variable NAMES whose values are
|
|
301
|
+
* scrubbed from this task's persisted log (#755).
|
|
302
|
+
*
|
|
303
|
+
* A value that looks like a secret rather than a name is rejected outright, not
|
|
304
|
+
* silently accepted: a literal in a task file would be indexed, searchable and
|
|
305
|
+
* printed verbatim by `akm show`, so accepting one would leak the secret
|
|
306
|
+
* through a wider channel than the redaction closes.
|
|
307
|
+
*/
|
|
308
|
+
function readRedactNames(value, filePath) {
|
|
309
|
+
if (value === undefined || value === null)
|
|
310
|
+
return undefined;
|
|
311
|
+
const invalid = (detail) => {
|
|
312
|
+
throw new UsageError(`Key "redact" ${detail}. File: ${filePath}`, "INVALID_FLAG_VALUE");
|
|
313
|
+
};
|
|
314
|
+
if (!Array.isArray(value))
|
|
315
|
+
return invalid("must be a list of environment variable names");
|
|
316
|
+
if (value.length > TASK_MAX_REDACT_NAMES) {
|
|
317
|
+
return invalid(`accepts at most ${TASK_MAX_REDACT_NAMES} names (got ${value.length})`);
|
|
318
|
+
}
|
|
319
|
+
const names = [];
|
|
320
|
+
for (const entry of value) {
|
|
321
|
+
if (typeof entry !== "string" || !WORKFLOW_ENV_VAR_NAME_PATTERN.test(entry)) {
|
|
322
|
+
return invalid(`takes environment variable NAMES only (matching ${WORKFLOW_ENV_VAR_NAME_PATTERN.source}), not values — ` +
|
|
323
|
+
`got ${JSON.stringify(entry)}. A secret written here would be indexed and printed by \`akm show\``);
|
|
324
|
+
}
|
|
325
|
+
if (!names.includes(entry))
|
|
326
|
+
names.push(entry);
|
|
327
|
+
}
|
|
328
|
+
return names.length > 0 ? names : undefined;
|
|
329
|
+
}
|
|
330
|
+
function readBoundedInteger(value, key, minimum, maximum, filePath) {
|
|
331
|
+
if (value === undefined || value === null)
|
|
332
|
+
return undefined;
|
|
333
|
+
if (typeof value === "number" &&
|
|
334
|
+
Number.isSafeInteger(value) &&
|
|
335
|
+
value >= minimum &&
|
|
336
|
+
(maximum === undefined || value <= maximum)) {
|
|
337
|
+
return value;
|
|
338
|
+
}
|
|
339
|
+
const range = maximum === undefined ? `at least ${minimum}` : `from ${minimum} through ${maximum}`;
|
|
340
|
+
throw new UsageError(`Key "${key}" must be an integer ${range}. File: ${filePath}`, "INVALID_FLAG_VALUE");
|
|
264
341
|
}
|
|
265
342
|
function readLlmOverrides(value, filePath) {
|
|
266
343
|
if (value === undefined)
|