akm-cli 0.9.1-beta.1 → 0.9.1-beta.3
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 +34 -1
- package/dist/cli/parse-args.js +7 -1
- package/dist/commands/env/child-env.js +14 -0
- package/dist/commands/health/advisories.js +5 -5
- package/dist/commands/health/html-report.js +2 -2
- package/dist/commands/health/metrics.js +38 -22
- package/dist/commands/health/report-view-model.js +1 -1
- package/dist/commands/improve/consolidate.js +61 -9
- package/dist/commands/improve/eval-cases.js +2 -0
- package/dist/commands/improve/memory/memory-improve.js +1 -0
- package/dist/commands/lint/base-linter.js +93 -20
- package/dist/commands/lint/index.js +5 -1
- package/dist/commands/sources/add-cli.js +8 -2
- package/dist/commands/sources/migration-help.js +12 -3
- package/dist/commands/sources/self-update.js +9 -1
- package/dist/core/adapter/adapters/agent-skills-adapter.js +32 -16
- package/dist/core/adapter/adapters/akm-lint.js +6 -2
- package/dist/core/adapter/adapters/akm-task-adapter.js +4 -2
- package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
- package/dist/core/asset/frontmatter.js +6 -1
- package/dist/core/common.js +81 -3
- package/dist/core/config/config-io.js +5 -45
- package/dist/core/config/schema/engines.js +14 -3
- package/dist/core/extra-params.js +11 -0
- package/dist/core/fs-txn.js +15 -2
- package/dist/core/json-schema.js +19 -2
- package/dist/core/paths.js +16 -2
- package/dist/core/redaction.js +22 -1
- package/dist/core/state-db.js +1 -0
- package/dist/core/write-source.js +26 -2
- package/dist/indexer/indexer.js +48 -9
- package/dist/indexer/search/db-search.js +17 -2
- package/dist/indexer/walk/walker.js +6 -1
- package/dist/integrations/agent/detect.js +13 -1
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
- package/dist/integrations/lockfile.js +10 -0
- 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/runtime.js +23 -1
- package/dist/scripts/akm-migrate-node.js +303 -107
- package/dist/scripts/akm-migrate.js +303 -107
- package/dist/setup/setup.js +22 -7
- package/dist/sources/providers/git-install.js +25 -2
- package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
- package/dist/storage/database.js +71 -12
- package/dist/storage/engines/sqlite-migrations.js +61 -2
- package/dist/storage/repositories/index-connection.js +11 -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/salience-repository.js +13 -12
- package/dist/storage/sqlite-pragmas.js +12 -1
- package/dist/tasks/runner.js +84 -7
- package/dist/tasks/scheduler-invocation.js +19 -0
- package/dist/tasks/schema.js +21 -1
- package/dist/text-import-hook.mjs +1 -1
- package/dist/workflows/exec/native-executor.js +8 -0
- package/dist/workflows/exec/step-work.js +10 -2
- package/dist/workflows/parser.js +26 -1
- package/package.json +1 -1
- package/schemas/akm-config.json +10 -5
- package/schemas/akm-workflow.json +7 -3
|
@@ -138,13 +138,25 @@ function skillFieldDiagnostics(relPath, dirName, data) {
|
|
|
138
138
|
* sweep into a full recursive walk.
|
|
139
139
|
*/
|
|
140
140
|
const MAX_PACKAGE_PROBE_DEPTH = 3;
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
function missingManifestDiagnostic(dir) {
|
|
142
|
+
return { file: dir, issue: "missing-skill-md", detail: `no SKILL.md in ${dir}/`, fixed: false };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Classify one directory as a package, a grouping directory, or one broken
|
|
146
|
+
* package candidate. Once a real package root is found, its resource
|
|
147
|
+
* directories are never descended into. If manifests exist only below this
|
|
148
|
+
* directory, it is a group and each sibling candidate is checked independently.
|
|
149
|
+
*/
|
|
150
|
+
async function scanPackageCandidate(dir, entries, ctx, depth) {
|
|
143
151
|
if (entries.includes(SKILL_MANIFEST))
|
|
144
|
-
return true;
|
|
145
|
-
if (depth >= MAX_PACKAGE_PROBE_DEPTH)
|
|
146
|
-
return false;
|
|
152
|
+
return { containsManifest: true, diagnostics: [] };
|
|
153
|
+
if (depth >= MAX_PACKAGE_PROBE_DEPTH) {
|
|
154
|
+
return { containsManifest: false, diagnostics: [missingManifestDiagnostic(dir)] };
|
|
155
|
+
}
|
|
156
|
+
const children = [];
|
|
147
157
|
for (const entry of entries) {
|
|
158
|
+
if (entry.startsWith("."))
|
|
159
|
+
continue;
|
|
148
160
|
const child = `${dir}/${entry}`;
|
|
149
161
|
// `list` on a FILE yields `[]` (the read throws and is swallowed), so an
|
|
150
162
|
// empty listing is the "not a directory worth descending" signal — no
|
|
@@ -152,10 +164,17 @@ async function subtreeHasManifest(dir, entries, ctx, depth) {
|
|
|
152
164
|
const childEntries = await ctx.list(child);
|
|
153
165
|
if (childEntries.length === 0)
|
|
154
166
|
continue;
|
|
155
|
-
|
|
156
|
-
|
|
167
|
+
children.push(await scanPackageCandidate(child, childEntries, ctx, depth + 1));
|
|
168
|
+
}
|
|
169
|
+
if (children.some((child) => child.containsManifest)) {
|
|
170
|
+
return {
|
|
171
|
+
containsManifest: true,
|
|
172
|
+
diagnostics: children.flatMap((child) => child.diagnostics),
|
|
173
|
+
};
|
|
157
174
|
}
|
|
158
|
-
|
|
175
|
+
// No descendant establishes this as a grouping directory. Diagnose the
|
|
176
|
+
// candidate itself, not its resource subdirectories.
|
|
177
|
+
return { containsManifest: false, diagnostics: [missingManifestDiagnostic(dir)] };
|
|
159
178
|
}
|
|
160
179
|
/**
|
|
161
180
|
* The directory-level `missing-skill-md` check (issue #774).
|
|
@@ -167,11 +186,10 @@ async function subtreeHasManifest(dir, entries, ctx, depth) {
|
|
|
167
186
|
* component root through {@link ValidateContext.list} instead, so the case is
|
|
168
187
|
* actually reported.
|
|
169
188
|
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* package, so it is left alone too.
|
|
189
|
+
* A package's own resource dirs (`pdf-processing/reference/`) are part of the
|
|
190
|
+
* item, not candidate packages. Grouping directories are supported too, but
|
|
191
|
+
* their children are classified independently so one valid package cannot hide
|
|
192
|
+
* a manifest-less sibling.
|
|
175
193
|
*/
|
|
176
194
|
async function missingManifestDiagnostics(ctx) {
|
|
177
195
|
const diagnostics = [];
|
|
@@ -181,9 +199,7 @@ async function missingManifestDiagnostics(ctx) {
|
|
|
181
199
|
const entries = await ctx.list(name);
|
|
182
200
|
if (entries.length === 0)
|
|
183
201
|
continue; // a root file (README.md), or an untrackable empty dir
|
|
184
|
-
|
|
185
|
-
continue;
|
|
186
|
-
diagnostics.push({ file: name, issue: "missing-skill-md", detail: `no SKILL.md in ${name}/`, fixed: false });
|
|
202
|
+
diagnostics.push(...(await scanPackageCandidate(name, entries, ctx, 1)).diagnostics);
|
|
187
203
|
}
|
|
188
204
|
return diagnostics;
|
|
189
205
|
}
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
*/
|
|
53
53
|
import path from "node:path";
|
|
54
54
|
import { isDangerousEnvKey } from "../../../commands/lint/env-key-rules.js";
|
|
55
|
-
import { taskFieldProblems } from "../../../tasks/schema.js";
|
|
55
|
+
import { isPresentTarget, taskFieldProblems } from "../../../tasks/schema.js";
|
|
56
56
|
import { compileWorkflowPlan } from "../../../workflows/ir/compile.js";
|
|
57
57
|
import { parseWorkflow } from "../../../workflows/parser.js";
|
|
58
58
|
import { conceptIdForStashFile } from "../../asset/resolve-ref.js";
|
|
@@ -283,7 +283,11 @@ export function taskDiagnostics(relPath, data) {
|
|
|
283
283
|
if (data === null || Object.keys(data).length === 0)
|
|
284
284
|
return [];
|
|
285
285
|
const missing = taskFieldProblems(data);
|
|
286
|
-
|
|
286
|
+
// Presence, matching the runtime parser's rule (src/tasks/parser.ts): an
|
|
287
|
+
// empty string is NOT a target there, so a `workflow: ""` that linted clean
|
|
288
|
+
// here failed at run time with MISSING_REQUIRED_ARGUMENT — a file the linter
|
|
289
|
+
// called valid but that could never run.
|
|
290
|
+
const hasTarget = ["prompt", "workflow", "command"].some((key) => isPresentTarget(data[key]));
|
|
287
291
|
if (!hasTarget)
|
|
288
292
|
missing.push("prompt, workflow, or command");
|
|
289
293
|
if (missing.length > 0) {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
import fs from "node:fs";
|
|
32
32
|
import path from "node:path";
|
|
33
|
-
import { parseTaskYaml, TASK_EXTENSION, TASK_NEAR_MISS_EXTENSION, taskExtensionDetail, taskFieldProblems, taskYamlParseDetail, } from "../../../tasks/schema.js";
|
|
33
|
+
import { isPresentTarget, parseTaskYaml, TASK_EXTENSION, TASK_NEAR_MISS_EXTENSION, taskExtensionDetail, taskFieldProblems, taskYamlParseDetail, } from "../../../tasks/schema.js";
|
|
34
34
|
import { hashContent } from "./shared.js";
|
|
35
35
|
/** A native task bundle is single-component; its one component is `main`. */
|
|
36
36
|
const COMPONENT_ID = "main";
|
|
@@ -71,7 +71,9 @@ function taskDiagnostics(relPath, data) {
|
|
|
71
71
|
if (Object.keys(data).length === 0)
|
|
72
72
|
return [];
|
|
73
73
|
const problems = taskFieldProblems(data);
|
|
74
|
-
|
|
74
|
+
// Shared presence rule (src/tasks/schema.ts): an empty string or empty array
|
|
75
|
+
// is not a target, matching the runtime parser.
|
|
76
|
+
const targets = TARGET_KEYS.filter((k) => isPresentTarget(data[k]));
|
|
75
77
|
if (targets.length === 0)
|
|
76
78
|
problems.push("exactly one target (prompt, workflow, or command)");
|
|
77
79
|
else if (targets.length > 1)
|
|
@@ -61,6 +61,25 @@ function classify(relPath) {
|
|
|
61
61
|
}
|
|
62
62
|
return null;
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* True when `--sensitive` marked this asset, via the sibling marker file that
|
|
66
|
+
* `akm env create --sensitive` / `akm secret create --sensitive` writes:
|
|
67
|
+
* `env/<name>.sensitive` for `env/<name>.env`, `secrets/<name>.sensitive` for
|
|
68
|
+
* `secrets/<name>`.
|
|
69
|
+
*
|
|
70
|
+
* The flag documents itself as excluding the asset from BOTH `env list` output
|
|
71
|
+
* and the search index. Indexing filters are adapter-owned (the walk no longer
|
|
72
|
+
* pre-filters), and the akm adapter abstains on the marker — but this adapter
|
|
73
|
+
* only skipped files whose OWN name ended in `.sensitive`. A dotenv bundle is a
|
|
74
|
+
* legal env/secret write target, so a marked `env/prod.env` there was still
|
|
75
|
+
* indexed with every KEY NAME as a hint and a marked secret still indexed by
|
|
76
|
+
* name, while `env list` / `secret list` correctly hid them. The two surfaces
|
|
77
|
+
* disagreed about a documented promise.
|
|
78
|
+
*/
|
|
79
|
+
function hasSensitiveMarker(absPath, type) {
|
|
80
|
+
const marker = type === "env" ? absPath.replace(/\.env$/i, ".sensitive") : `${absPath}.sensitive`;
|
|
81
|
+
return marker !== absPath && fs.existsSync(marker);
|
|
82
|
+
}
|
|
64
83
|
/** Extract KEY NAMES (never values) from an env file's raw content, first-appearance order, deduped. */
|
|
65
84
|
function scanKeyNames(raw) {
|
|
66
85
|
const keys = [];
|
|
@@ -81,6 +100,8 @@ function recognize(c, file) {
|
|
|
81
100
|
const type = classify(file.relPath);
|
|
82
101
|
if (type === null)
|
|
83
102
|
return null;
|
|
103
|
+
if (hasSensitiveMarker(file.absPath, type))
|
|
104
|
+
return null;
|
|
84
105
|
const posix = toPosix(file.relPath);
|
|
85
106
|
const raw = file.content();
|
|
86
107
|
if (type === "env") {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
|
|
13
|
+
import { existingFileMode, writeFileAtomic } from "../common.js";
|
|
13
14
|
import { recordWrittenPath } from "../write-provenance.js";
|
|
14
15
|
import { assembleAsset, serializeFrontmatter } from "./asset-serialize.js";
|
|
15
16
|
/**
|
|
@@ -143,7 +144,11 @@ export function mutateFrontmatter(filePath, mutator) {
|
|
|
143
144
|
const next = parsed.frontmatter !== null
|
|
144
145
|
? `---\n${serializeFrontmatter(nextFrontmatter)}\n---\n${parsed.content}`
|
|
145
146
|
: assembleAsset(nextFrontmatter, parsed.content);
|
|
146
|
-
|
|
147
|
+
// Atomic, like the canonical asset write: this rewrites a file the user
|
|
148
|
+
// authored, and a truncate-in-place left a window where a crash or a
|
|
149
|
+
// concurrent reader saw a half-written or empty asset. The existing mode is
|
|
150
|
+
// preserved so stamping frontmatter never changes an asset's permissions.
|
|
151
|
+
writeFileAtomic(filePath, next, existingFileMode(filePath));
|
|
147
152
|
// #652: in-place frontmatter stamps (belief state, contradiction markers,
|
|
148
153
|
// salience) are real asset mutations — journal them for the run's sync.
|
|
149
154
|
recordWrittenPath(filePath);
|
package/dist/core/common.js
CHANGED
|
@@ -83,6 +83,75 @@ export function readTextFileWithLimit(filePath, maxBytes, label = "File") {
|
|
|
83
83
|
* don't fail on those mounts. Windows does not support opening a
|
|
84
84
|
* directory for fsync, so the directory-sync step is skipped there.
|
|
85
85
|
*/
|
|
86
|
+
/**
|
|
87
|
+
* Strip JavaScript-style comments from a JSON string (JSONC support).
|
|
88
|
+
* Handles `//` line comments and `/* */` block comments while preserving
|
|
89
|
+
* comment-like sequences inside quoted strings.
|
|
90
|
+
*/
|
|
91
|
+
export function stripJsonComments(text) {
|
|
92
|
+
let result = "";
|
|
93
|
+
let i = 0;
|
|
94
|
+
let inString = false;
|
|
95
|
+
while (i < text.length) {
|
|
96
|
+
if (inString) {
|
|
97
|
+
if (text[i] === "\\") {
|
|
98
|
+
result += text[i] + (text[i + 1] ?? "");
|
|
99
|
+
i += 2;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (text[i] === '"') {
|
|
103
|
+
inString = false;
|
|
104
|
+
}
|
|
105
|
+
result += text[i];
|
|
106
|
+
i++;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (text[i] === '"') {
|
|
110
|
+
inString = true;
|
|
111
|
+
result += text[i];
|
|
112
|
+
i++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (text[i] === "/" && text[i + 1] === "/") {
|
|
116
|
+
while (i < text.length && text[i] !== "\n")
|
|
117
|
+
i++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (text[i] === "/" && text[i + 1] === "*") {
|
|
121
|
+
i += 2;
|
|
122
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
123
|
+
i++;
|
|
124
|
+
i += 2;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
result += text[i];
|
|
128
|
+
i++;
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The mode to rewrite an existing USER-owned file with.
|
|
134
|
+
*
|
|
135
|
+
* {@link writeFileAtomic} creates its temp file with an explicit mode and
|
|
136
|
+
* chmods it, so it needs one — and its 0600 default is right for akm's own
|
|
137
|
+
* state but wrong for a user's asset, where rewriting must never change
|
|
138
|
+
* permissions. Returns the file's current mode, or the umask-derived default
|
|
139
|
+
* `fs.writeFileSync` would have produced for a new file.
|
|
140
|
+
*/
|
|
141
|
+
export function existingFileMode(filePath) {
|
|
142
|
+
try {
|
|
143
|
+
return fs.statSync(filePath).mode & 0o777;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Absent (a new asset) or unreadable: fall back to the default create mode.
|
|
147
|
+
try {
|
|
148
|
+
return 0o666 & ~process.umask();
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return 0o644;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
86
155
|
export function writeFileAtomic(target, content, mode) {
|
|
87
156
|
const tmp = `${target}.tmp.${process.pid}.${crypto.randomBytes(8).toString("hex")}`;
|
|
88
157
|
const data = typeof content === "string" ? Buffer.from(content) : content;
|
|
@@ -226,7 +295,11 @@ function readStashDirFromConfig() {
|
|
|
226
295
|
try {
|
|
227
296
|
const configPath = getConfigPath();
|
|
228
297
|
const text = readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file");
|
|
229
|
-
|
|
298
|
+
// The config loader accepts JSONC, so a commented config.json is valid and
|
|
299
|
+
// in use. Parsing it raw here threw, the catch swallowed it, and every
|
|
300
|
+
// caller silently fell back — operating on the wrong bundle or failing with
|
|
301
|
+
// STASH_DIR_NOT_FOUND despite a perfectly good config.
|
|
302
|
+
const raw = JSON.parse(stripJsonComments(text));
|
|
230
303
|
if (typeof raw !== "object" || raw === null)
|
|
231
304
|
return undefined;
|
|
232
305
|
// 0.9.0 config-shape cutover (spec §10.1): the primary stash is the
|
|
@@ -778,14 +851,19 @@ export function stringArray(value) {
|
|
|
778
851
|
* Return true if a process with the given PID is currently alive.
|
|
779
852
|
* Uses `process.kill(pid, 0)` which does not deliver a signal but
|
|
780
853
|
* throws ESRCH when the process does not exist.
|
|
854
|
+
*
|
|
855
|
+
* EPERM means the process EXISTS but belongs to another uid, so it must be
|
|
856
|
+
* reported alive. Treating it as dead let a lock held by a live process in a
|
|
857
|
+
* shared data dir (agent sandboxes, containers, service accounts — a
|
|
858
|
+
* configuration managed-db.ts explicitly supports) be reclaimed as stale.
|
|
781
859
|
*/
|
|
782
860
|
export function isProcessAlive(pid) {
|
|
783
861
|
try {
|
|
784
862
|
process.kill(pid, 0);
|
|
785
863
|
return true;
|
|
786
864
|
}
|
|
787
|
-
catch {
|
|
788
|
-
return
|
|
865
|
+
catch (err) {
|
|
866
|
+
return err?.code === "EPERM";
|
|
789
867
|
}
|
|
790
868
|
}
|
|
791
869
|
/**
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import fs from "node:fs";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { sleepSync } from "../../runtime.js";
|
|
18
|
-
import { MAX_CONFIG_FILE_BYTES, readTextFileWithLimit, writeFileAtomic } from "../common.js";
|
|
18
|
+
import { MAX_CONFIG_FILE_BYTES, readTextFileWithLimit, stripJsonComments, writeFileAtomic } from "../common.js";
|
|
19
19
|
import { ConfigError } from "../errors.js";
|
|
20
20
|
import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "../file-lock.js";
|
|
21
21
|
import { getCacheDir, getConfigDir } from "../paths.js";
|
|
@@ -233,48 +233,8 @@ export function withConfigLock(fn) {
|
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
235
|
/**
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
236
|
+
* Re-exported from core/common.ts, where it now lives so `resolveStashDir`
|
|
237
|
+
* can strip comments too without importing this module (common cannot depend
|
|
238
|
+
* on config-io — config-io already depends on common).
|
|
239
239
|
*/
|
|
240
|
-
export
|
|
241
|
-
let result = "";
|
|
242
|
-
let i = 0;
|
|
243
|
-
let inString = false;
|
|
244
|
-
while (i < text.length) {
|
|
245
|
-
if (inString) {
|
|
246
|
-
if (text[i] === "\\") {
|
|
247
|
-
result += text[i] + (text[i + 1] ?? "");
|
|
248
|
-
i += 2;
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
if (text[i] === '"') {
|
|
252
|
-
inString = false;
|
|
253
|
-
}
|
|
254
|
-
result += text[i];
|
|
255
|
-
i++;
|
|
256
|
-
continue;
|
|
257
|
-
}
|
|
258
|
-
if (text[i] === '"') {
|
|
259
|
-
inString = true;
|
|
260
|
-
result += text[i];
|
|
261
|
-
i++;
|
|
262
|
-
continue;
|
|
263
|
-
}
|
|
264
|
-
if (text[i] === "/" && text[i + 1] === "/") {
|
|
265
|
-
while (i < text.length && text[i] !== "\n")
|
|
266
|
-
i++;
|
|
267
|
-
continue;
|
|
268
|
-
}
|
|
269
|
-
if (text[i] === "/" && text[i + 1] === "*") {
|
|
270
|
-
i += 2;
|
|
271
|
-
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
272
|
-
i++;
|
|
273
|
-
i += 2;
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
result += text[i];
|
|
277
|
-
i++;
|
|
278
|
-
}
|
|
279
|
-
return result;
|
|
280
|
-
}
|
|
240
|
+
export { stripJsonComments } from "../common.js";
|
|
@@ -14,7 +14,18 @@ import { z } from "zod";
|
|
|
14
14
|
// `typeof import("./config-schema")` — routing through config-types would mint
|
|
15
15
|
// a config-schema ↔ config-types type cycle that collapses inference.
|
|
16
16
|
import { HARNESS_AGENT_DISPATCH_IDS, VALID_HARNESS_IDS } from "../../../integrations/harnesses/ids.js";
|
|
17
|
+
import { WORKFLOW_MAX_TIMEOUT_MS } from "../../../workflows/resource-limits.js";
|
|
17
18
|
import { chatCompletionsEndpoint, ENV_REFERENCE_PATTERN, ExtraParamsSchema, engineName, LlmCapabilitiesSchema, ModelAliasMapSchema, nonEmptyString, positiveInt, } from "./primitives.js";
|
|
19
|
+
/**
|
|
20
|
+
* Engine-config timeouts share the workflow ceiling.
|
|
21
|
+
*
|
|
22
|
+
* 0.9.1 bounded workflow-authored timeouts (parser) and frozen invocations
|
|
23
|
+
* (decoder) at 2^31-1, but the third source — `engines.<name>.timeoutMs` — was
|
|
24
|
+
* validated only as a positive integer. A larger value passed config validation
|
|
25
|
+
* and then failed EVERY run of that engine with an unlocated "Invalid frozen
|
|
26
|
+
* workflow plan: invocation is invalid". Reject it where the value is written.
|
|
27
|
+
*/
|
|
28
|
+
const timeoutMsField = z.union([positiveInt.max(WORKFLOW_MAX_TIMEOUT_MS), z.null()]).optional();
|
|
18
29
|
// ── Connection configs (LLM) ────────────────────────────────────────────────
|
|
19
30
|
/**
|
|
20
31
|
* OpenAI-compatible connection fields shared by named LLM engines and bounded
|
|
@@ -32,7 +43,7 @@ export const LlmConnectionConfigSchema = z
|
|
|
32
43
|
apiKey: z.string().optional(),
|
|
33
44
|
temperature: z.number().finite().optional(),
|
|
34
45
|
maxTokens: positiveInt.optional(),
|
|
35
|
-
timeoutMs:
|
|
46
|
+
timeoutMs: timeoutMsField,
|
|
36
47
|
concurrency: positiveInt.optional(),
|
|
37
48
|
capabilities: LlmCapabilitiesSchema.optional(),
|
|
38
49
|
extraParams: ExtraParamsSchema.optional(),
|
|
@@ -56,7 +67,7 @@ const LlmEngineSchema = z
|
|
|
56
67
|
apiKey: z.string().regex(ENV_REFERENCE_PATTERN, `apiKey must be $VAR or \${VAR}`).optional(),
|
|
57
68
|
temperature: z.number().finite().optional(),
|
|
58
69
|
maxTokens: positiveInt.optional(),
|
|
59
|
-
timeoutMs:
|
|
70
|
+
timeoutMs: timeoutMsField,
|
|
60
71
|
concurrency: positiveInt.optional(),
|
|
61
72
|
supportsJsonSchema: z.boolean().optional(),
|
|
62
73
|
extraParams: ExtraParamsSchema.optional(),
|
|
@@ -80,7 +91,7 @@ const AgentEngineSchema = z
|
|
|
80
91
|
args: z.array(z.string()).optional(),
|
|
81
92
|
workspace: nonEmptyString.optional(),
|
|
82
93
|
model: nonEmptyString.optional(),
|
|
83
|
-
timeoutMs:
|
|
94
|
+
timeoutMs: timeoutMsField,
|
|
84
95
|
modelAliases: ModelAliasMapSchema.optional(),
|
|
85
96
|
llmEngine: engineName.optional(),
|
|
86
97
|
})
|
|
@@ -33,8 +33,16 @@ export function validateExtraParams(value) {
|
|
|
33
33
|
return [{ path: [], message: "must be an object" }];
|
|
34
34
|
}
|
|
35
35
|
const issues = [];
|
|
36
|
+
// A self-referential YAML anchor (`extraParams: &a { nested: *a }`) resolves
|
|
37
|
+
// to a genuinely cyclic object — the yaml package's alias-count guard does not
|
|
38
|
+
// catch cycles — so an unguarded walk overflowed the stack with a RangeError
|
|
39
|
+
// that escaped task parsing. Track visited containers and stop at a revisit.
|
|
40
|
+
const seen = new WeakSet();
|
|
36
41
|
const visit = (entry, path) => {
|
|
37
42
|
if (Array.isArray(entry)) {
|
|
43
|
+
if (seen.has(entry))
|
|
44
|
+
return;
|
|
45
|
+
seen.add(entry);
|
|
38
46
|
entry.forEach((child, index) => {
|
|
39
47
|
visit(child, [...path, index]);
|
|
40
48
|
});
|
|
@@ -42,6 +50,9 @@ export function validateExtraParams(value) {
|
|
|
42
50
|
}
|
|
43
51
|
if (!entry || typeof entry !== "object")
|
|
44
52
|
return;
|
|
53
|
+
if (seen.has(entry))
|
|
54
|
+
return;
|
|
55
|
+
seen.add(entry);
|
|
45
56
|
for (const [key, child] of Object.entries(entry)) {
|
|
46
57
|
const normalized = normalizeExtraParamKey(key);
|
|
47
58
|
if (path.length === 0 && PROTECTED_TOP_LEVEL_KEYS.has(normalized)) {
|
package/dist/core/fs-txn.js
CHANGED
|
@@ -81,7 +81,11 @@ export function txnFileHash(filePath) {
|
|
|
81
81
|
return txnHash(fs.readFileSync(filePath));
|
|
82
82
|
}
|
|
83
83
|
export function fsyncTxnFile(filePath) {
|
|
84
|
-
|
|
84
|
+
// Open for WRITE. Windows implements fsync as FlushFileBuffers, which
|
|
85
|
+
// requires write access on the handle — a read-only descriptor fails with
|
|
86
|
+
// EACCES/EPERM, so every proposal accept and reject failed on that platform.
|
|
87
|
+
// POSIX accepts "r+" here just as readily as "r".
|
|
88
|
+
const fd = fs.openSync(filePath, "r+");
|
|
85
89
|
try {
|
|
86
90
|
fs.fsyncSync(fd);
|
|
87
91
|
}
|
|
@@ -91,7 +95,16 @@ export function fsyncTxnFile(filePath) {
|
|
|
91
95
|
}
|
|
92
96
|
export function fsyncTxnDir(dirPath) {
|
|
93
97
|
try {
|
|
94
|
-
fsyncTxnFile
|
|
98
|
+
// Read-only, unlike {@link fsyncTxnFile}: a directory cannot be opened for
|
|
99
|
+
// write on POSIX (EISDIR), and on Windows this whole operation is
|
|
100
|
+
// unsupported anyway and falls into the catch.
|
|
101
|
+
const fd = fs.openSync(dirPath, "r");
|
|
102
|
+
try {
|
|
103
|
+
fs.fsyncSync(fd);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
fs.closeSync(fd);
|
|
107
|
+
}
|
|
95
108
|
}
|
|
96
109
|
catch {
|
|
97
110
|
// Directory fsync is unavailable on some platforms.
|
package/dist/core/json-schema.js
CHANGED
|
@@ -171,6 +171,13 @@ function pushIssue(issues, path, keyword, kind, message) {
|
|
|
171
171
|
function isPlainObject(value) {
|
|
172
172
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
173
173
|
}
|
|
174
|
+
/** Values the runtime's reference-equality `enum` check can enforce correctly. */
|
|
175
|
+
function isSupportedEnumValue(value) {
|
|
176
|
+
return (value === null ||
|
|
177
|
+
typeof value === "string" ||
|
|
178
|
+
typeof value === "boolean" ||
|
|
179
|
+
(typeof value === "number" && Number.isFinite(value)));
|
|
180
|
+
}
|
|
174
181
|
function checkDefinitionNode(schema, path, issues, depth) {
|
|
175
182
|
if (depth > MAX_DEFINITION_DEPTH) {
|
|
176
183
|
pushIssue(issues, path, "(depth)", "malformed", `schema nesting exceeds the depth limit of ${MAX_DEFINITION_DEPTH}`);
|
|
@@ -199,8 +206,18 @@ function checkDefinitionNode(schema, path, issues, depth) {
|
|
|
199
206
|
}
|
|
200
207
|
}
|
|
201
208
|
}
|
|
202
|
-
if (schema.enum !== undefined
|
|
203
|
-
|
|
209
|
+
if (schema.enum !== undefined) {
|
|
210
|
+
if (!Array.isArray(schema.enum) || schema.enum.length === 0) {
|
|
211
|
+
pushIssue(issues, [...path, "enum"], "enum", "malformed", `"enum" must be a non-empty array of allowed values`);
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
schema.enum.forEach((value, index) => {
|
|
215
|
+
if (isSupportedEnumValue(value))
|
|
216
|
+
return;
|
|
217
|
+
pushIssue(issues, [...path, "enum", index], "enum", "unsupported", `"enum" values must be JSON primitives (string, finite number, boolean, or null) in the workflow ` +
|
|
218
|
+
`schema subset — object and array enum members cannot be matched by the runtime subset`);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
204
221
|
}
|
|
205
222
|
for (const keyword of ["allOf", "anyOf", "oneOf"]) {
|
|
206
223
|
const branches = schema[keyword];
|
package/dist/core/paths.js
CHANGED
|
@@ -160,9 +160,23 @@ export function getCacheDir(env = process.env) {
|
|
|
160
160
|
}
|
|
161
161
|
const home = env.HOME?.trim();
|
|
162
162
|
if (!home)
|
|
163
|
-
return
|
|
163
|
+
return homelessFallbackDir("akm-cache");
|
|
164
164
|
return path.join(home, ".cache", "akm");
|
|
165
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Last-resort directory when neither the XDG variable nor HOME is set.
|
|
168
|
+
*
|
|
169
|
+
* Scoped by uid. A fixed `/tmp/akm-<kind>` path is world-shared and entirely
|
|
170
|
+
* predictable: on a multi-user host the first uid to run akm owns the
|
|
171
|
+
* directory and every other user then reads and writes the same databases, and
|
|
172
|
+
* any local user can pre-create the path (or a symlink at it) and wait. Adding
|
|
173
|
+
* the uid gives each account its own path; the caller still creates it with
|
|
174
|
+
* restrictive permissions.
|
|
175
|
+
*/
|
|
176
|
+
function homelessFallbackDir(kind) {
|
|
177
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
178
|
+
return path.join(os.tmpdir(), uid === undefined ? kind : `${kind}-${uid}`);
|
|
179
|
+
}
|
|
166
180
|
// ── Data directory ───────────────────────────────────────────────────────────
|
|
167
181
|
/**
|
|
168
182
|
* Returns the XDG data directory for akm (`~/.local/share/akm` on Linux/macOS,
|
|
@@ -209,7 +223,7 @@ export function getDataDir(env = process.env, platform = process.platform) {
|
|
|
209
223
|
return path.join(xdgDataHome, "akm");
|
|
210
224
|
const home = env.HOME?.trim();
|
|
211
225
|
if (!home)
|
|
212
|
-
return
|
|
226
|
+
return homelessFallbackDir("akm-data");
|
|
213
227
|
return path.join(home, ".local", "share", "akm");
|
|
214
228
|
}
|
|
215
229
|
export function getDbPath(env = process.env) {
|
package/dist/core/redaction.js
CHANGED
|
@@ -322,7 +322,7 @@ function addPlainMatches(coverageDelta, text, needle) {
|
|
|
322
322
|
* unlike {@link redactSensitiveText}, which requires the exact secret value
|
|
323
323
|
* up front, this catches credentials no caller ever knew to list. No
|
|
324
324
|
* truncation is applied; callers that need a length cap (e.g.
|
|
325
|
-
* {@link redactErrorBody}
|
|
325
|
+
* {@link redactErrorBody}) apply it themselves.
|
|
326
326
|
*
|
|
327
327
|
* Targets:
|
|
328
328
|
* - `Bearer <token>` headers echoed back by a provider
|
|
@@ -367,6 +367,27 @@ export function redactCredentialPatterns(input) {
|
|
|
367
367
|
* that is a memory-exhaustion hazard reachable from ordinary command output.
|
|
368
368
|
* The encoded-form path never had the bug because it always worked this way.
|
|
369
369
|
*/
|
|
370
|
+
/** Max characters of a provider error body worth surfacing in a message. */
|
|
371
|
+
const ERROR_BODY_MAX_LEN = 200;
|
|
372
|
+
/**
|
|
373
|
+
* Make an HTTP error body safe to put in an error message: pattern-redact
|
|
374
|
+
* credential shapes, then clip. Provider bodies can echo the credential that
|
|
375
|
+
* was sent and can be megabytes of HTML, and these messages travel — into
|
|
376
|
+
* persisted status files, `--json` output, and agent transcripts.
|
|
377
|
+
*
|
|
378
|
+
* Lives here rather than beside one transport because every HTTP client in the
|
|
379
|
+
* codebase needs it; the embeddings transport originally lacked it and leaked
|
|
380
|
+
* raw 10 MB bodies into `semantic-status.json`.
|
|
381
|
+
*/
|
|
382
|
+
export function redactErrorBody(input) {
|
|
383
|
+
if (!input)
|
|
384
|
+
return "";
|
|
385
|
+
let out = redactCredentialPatterns(input);
|
|
386
|
+
if (out.length > ERROR_BODY_MAX_LEN) {
|
|
387
|
+
out = `${out.slice(0, ERROR_BODY_MAX_LEN)}…`;
|
|
388
|
+
}
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
370
391
|
export function redactSensitiveText(text, sensitiveValues) {
|
|
371
392
|
const values = [...new Set(sensitiveValues)]
|
|
372
393
|
.filter((value) => value.length > 0)
|
package/dist/core/state-db.js
CHANGED
|
@@ -33,7 +33,7 @@ import { ensureAkmMarkdownType } from "./asset/akm-markdown.js";
|
|
|
33
33
|
import { assetPathForName, stashDirFor } from "./asset/asset-placement.js";
|
|
34
34
|
import { conceptIdFromTypeName, displayRef } from "./asset/resolve-ref.js";
|
|
35
35
|
import { deriveBundleId } from "./bundle-id.js";
|
|
36
|
-
import { isWithin, resolveStashDir } from "./common.js";
|
|
36
|
+
import { existingFileMode, isWithin, resolveStashDir, writeFileAtomic } from "./common.js";
|
|
37
37
|
import { resolveConfiguredSources } from "./config/config.js";
|
|
38
38
|
import { ConfigError, UsageError } from "./errors.js";
|
|
39
39
|
import { sanitizeCommitMessage } from "./git-message.js";
|
|
@@ -333,7 +333,11 @@ export async function writeAssetToSource(source, config, ref, content) {
|
|
|
333
333
|
const preflight = preflightGitPathMutation(source, filePath);
|
|
334
334
|
try {
|
|
335
335
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
336
|
-
|
|
336
|
+
// Atomic: truncate-and-rewrite left a window in which a crash, a full disk,
|
|
337
|
+
// or a concurrent reader saw a half-written or empty asset — destroying user
|
|
338
|
+
// content that was fine a moment earlier. writeFileAtomic writes a sibling
|
|
339
|
+
// temp file, fdatasyncs it, and renames over the target.
|
|
340
|
+
writeFileAtomic(filePath, normalized, existingFileMode(filePath));
|
|
337
341
|
recordWriteTargetPath(source, filePath);
|
|
338
342
|
// #652: run-scoped write provenance — the canonical asset write is the
|
|
339
343
|
// single largest contributor to an improve run's written-path set.
|
|
@@ -1003,11 +1007,31 @@ function ensureWritable(source, config) {
|
|
|
1003
1007
|
throw new UsageError(`Source "${source.name}" is not writable. Set \`writable: true\` on the source config entry to enable writes.`, "INVALID_FLAG_VALUE");
|
|
1004
1008
|
}
|
|
1005
1009
|
}
|
|
1010
|
+
/**
|
|
1011
|
+
* MS-DOS device names Windows still reserves in every directory, with or
|
|
1012
|
+
* without an extension (CON, PRN, AUX, NUL, COM1-9, LPT1-9).
|
|
1013
|
+
*/
|
|
1014
|
+
const WINDOWS_RESERVED_DEVICE_NAMES = new Set([
|
|
1015
|
+
"con",
|
|
1016
|
+
"prn",
|
|
1017
|
+
"aux",
|
|
1018
|
+
"nul",
|
|
1019
|
+
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
|
|
1020
|
+
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
|
|
1021
|
+
]);
|
|
1006
1022
|
function resolveAssetFilePath(source, ref) {
|
|
1007
1023
|
const basename = path.posix.basename(ref.name.replaceAll("\\", "/")).replace(/\.md$/i, "").toLowerCase();
|
|
1008
1024
|
if (basename === "index" || basename === "log") {
|
|
1009
1025
|
throw new UsageError(`Reserved concept name "${basename}" cannot be written.`, "INVALID_FLAG_VALUE");
|
|
1010
1026
|
}
|
|
1027
|
+
// Windows resolves these names as DEVICES no matter the directory or the
|
|
1028
|
+
// extension, so `CON.md` is not a file — a write goes to the console and a
|
|
1029
|
+
// read blocks on console input. Rejected on every platform so a stash stays
|
|
1030
|
+
// portable: an asset authored on Linux must not become unopenable when the
|
|
1031
|
+
// same bundle is used on Windows.
|
|
1032
|
+
if (WINDOWS_RESERVED_DEVICE_NAMES.has(basename)) {
|
|
1033
|
+
throw new UsageError(`Asset name "${basename}" is a reserved Windows device name and cannot be written.`, "INVALID_FLAG_VALUE");
|
|
1034
|
+
}
|
|
1011
1035
|
const typeDir = stashDirFor(ref.type);
|
|
1012
1036
|
if (!typeDir) {
|
|
1013
1037
|
throw new UsageError(`Unknown asset type "${ref.type}". Cannot resolve a write path.`, "INVALID_FLAG_VALUE");
|