akm-cli 0.9.1-beta.1 → 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 +18 -1
- package/dist/cli/parse-args.js +7 -1
- package/dist/commands/env/child-env.js +14 -0
- package/dist/commands/improve/eval-cases.js +2 -0
- package/dist/commands/improve/memory/memory-improve.js +1 -0
- 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 +31 -6
- 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/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
package/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
-
## [0.9.1-beta.
|
|
9
|
+
## [0.9.1-beta.2] - 2026-08-17
|
|
10
10
|
|
|
11
11
|
### Breaking changes & migration
|
|
12
12
|
|
|
@@ -361,6 +361,23 @@ what an upgrader reads first.
|
|
|
361
361
|
|
|
362
362
|
### Fixed
|
|
363
363
|
|
|
364
|
+
- **Upgrading Node after installing akm now explains itself.** A native binding
|
|
365
|
+
is built for the Node ABI present at install time, so upgrading Node major
|
|
366
|
+
versions afterwards leaves akm reporting a bare Node internals message —
|
|
367
|
+
*"The module … was compiled against a different Node.js version"*, or on a
|
|
368
|
+
second attempt the even less helpful *"Module did not self-register"*. akm now
|
|
369
|
+
recognises that failure and answers with the one command that fixes it
|
|
370
|
+
(`npm rebuild better-sqlite3`), names the ABI actually running, and says
|
|
371
|
+
plainly that this is not a broken install.
|
|
372
|
+
|
|
373
|
+
The diagnostic had to move to do this. It wrapped the `require`, but
|
|
374
|
+
`require("better-sqlite3")` **succeeds** against a mismatched binding — the
|
|
375
|
+
package resolves its `.node` file lazily — so the error lands at
|
|
376
|
+
`new Database(...)` and the loader's handler never saw it. The previous text
|
|
377
|
+
telling the user to look for a version mismatch "in the error below" was
|
|
378
|
+
unreachable. Found by installing the published build under Node 22 and running
|
|
379
|
+
it under Node 24.
|
|
380
|
+
|
|
364
381
|
- **akm's Node fallback no longer aborts at teardown on Node 24.** On Node
|
|
365
382
|
24.19.0 and later, any command that opened a database could intermittently
|
|
366
383
|
die with `node::RemoveEnvironmentCleanupHook … Assertion (env) != nullptr`
|
package/dist/cli/parse-args.js
CHANGED
|
@@ -45,8 +45,14 @@ export function parsePositiveIntFlag(raw, flagName = "--limit") {
|
|
|
45
45
|
const trimmed = raw.trim();
|
|
46
46
|
if (!trimmed)
|
|
47
47
|
return undefined;
|
|
48
|
+
// Strict digits, matching parseNonNegativeIntFlag below. parseInt stops at the
|
|
49
|
+
// first non-digit, so "10x" silently became 10, "3.5" became 3, and
|
|
50
|
+
// "5 apples" became 5 — accepted rather than rejected as invalid.
|
|
51
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
52
|
+
throw new UsageError(`Invalid ${flagName} value: "${raw}". Must be a positive integer.`, "INVALID_FLAG_VALUE");
|
|
53
|
+
}
|
|
48
54
|
const parsed = parseInt(trimmed, 10);
|
|
49
|
-
if (
|
|
55
|
+
if (parsed <= 0) {
|
|
50
56
|
throw new UsageError(`Invalid ${flagName} value: "${raw}". Must be a positive integer.`, "INVALID_FLAG_VALUE");
|
|
51
57
|
}
|
|
52
58
|
return parsed;
|
|
@@ -1,6 +1,7 @@
|
|
|
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 { WIN32_SPAWN_ENV_FLOOR } from "../../core/spawn-env.js";
|
|
4
5
|
const CLEAN_ENV_ALLOWLIST = [
|
|
5
6
|
"HOME",
|
|
6
7
|
"PATH",
|
|
@@ -38,6 +39,19 @@ export function buildChildEnv(parentEnv, options) {
|
|
|
38
39
|
if (parentEnv[key] !== undefined)
|
|
39
40
|
base[key] = parentEnv[key];
|
|
40
41
|
}
|
|
42
|
+
// The allowlist above is POSIX-shaped. On Windows a child started without
|
|
43
|
+
// SystemRoot/COMSPEC/PATHEXT and friends frequently cannot start at all —
|
|
44
|
+
// which is why every other spawn path in the codebase applies this floor
|
|
45
|
+
// (see spawnEnvNamesFor). `env run --clean` / `secret run --clean` did not,
|
|
46
|
+
// so clean-mode injection was unusable there. The floor is names the OS
|
|
47
|
+
// requires of any child, not user configuration, so it does not weaken what
|
|
48
|
+
// "clean" means about inherited secrets.
|
|
49
|
+
if (process.platform === "win32") {
|
|
50
|
+
for (const key of WIN32_SPAWN_ENV_FLOOR) {
|
|
51
|
+
if (parentEnv[key] !== undefined)
|
|
52
|
+
base[key] = parentEnv[key];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
41
55
|
}
|
|
42
56
|
for (const key of options.inherit) {
|
|
43
57
|
if (parentEnv[key] !== undefined)
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { writeFileAtomic } from "../../core/common.js";
|
|
7
|
+
import { recordWrittenPath } from "../../core/write-provenance.js";
|
|
7
8
|
export function writeEvalCase(stashDir, evalCase) {
|
|
8
9
|
const evalDir = path.join(stashDir, ".akm", "eval-cases");
|
|
9
10
|
fs.mkdirSync(evalDir, { recursive: true });
|
|
@@ -28,6 +29,7 @@ Use it as a regression test: future improve runs on this ref should not produce
|
|
|
28
29
|
output that would be rejected for the same reason.
|
|
29
30
|
`;
|
|
30
31
|
writeFileAtomic(filePath, content);
|
|
32
|
+
recordWrittenPath(filePath);
|
|
31
33
|
return filePath;
|
|
32
34
|
}
|
|
33
35
|
export function countEvalCases(stashDir) {
|
|
@@ -543,6 +543,7 @@ function appendBeliefStateTransitionLog(stashDir, transitions) {
|
|
|
543
543
|
}))
|
|
544
544
|
.join("\n");
|
|
545
545
|
fs.appendFileSync(logPath, `${lines}\n`, "utf8");
|
|
546
|
+
recordWrittenPath(logPath);
|
|
546
547
|
return logPath;
|
|
547
548
|
}
|
|
548
549
|
function priorBeliefStateForArchive(candidate) {
|
|
@@ -502,7 +502,11 @@ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
|
|
|
502
502
|
}
|
|
503
503
|
for (const filePath of assetFiles) {
|
|
504
504
|
// Skip registry-cached read-only files — --fix must not mutate them.
|
|
505
|
-
|
|
505
|
+
// Compare on a separator-normalized copy: on Windows these paths carry
|
|
506
|
+
// backslashes, so the forward-slash substring never matched and --fix
|
|
507
|
+
// rewrote files inside the registry cache.
|
|
508
|
+
const posixPath = filePath.replace(/\\/g, "/");
|
|
509
|
+
if (posixPath.includes("/.cache/") || posixPath.includes("/registry/"))
|
|
506
510
|
continue;
|
|
507
511
|
const relPath = path.relative(stashRoot, filePath);
|
|
508
512
|
let raw;
|
|
@@ -10,6 +10,7 @@ import { decideDangerousKeyInstall } from "../../core/activation-policy.js";
|
|
|
10
10
|
import { UsageError } from "../../core/errors.js";
|
|
11
11
|
import { appendEvent } from "../../core/events.js";
|
|
12
12
|
import { warn } from "../../core/warn.js";
|
|
13
|
+
import { sanitizeString } from "../../sources/providers/provider-utils.js";
|
|
13
14
|
import { akmRemove } from "./installed-stashes.js";
|
|
14
15
|
import { akmAdd } from "./source-add.js";
|
|
15
16
|
import { addStash } from "./source-manage.js";
|
|
@@ -180,9 +181,14 @@ export async function auditInstalledStashForDangerousKeys(opts) {
|
|
|
180
181
|
groupedByEnv.set(f.envRef, existing);
|
|
181
182
|
}
|
|
182
183
|
for (const [envRef, keys] of groupedByEnv) {
|
|
183
|
-
|
|
184
|
+
// envRef and keys come from filenames and KEY names inside a downloaded or
|
|
185
|
+
// cloned bundle, i.e. attacker-controllable. Tar validation rejects NUL but
|
|
186
|
+
// not ESC/CSI, and git checkout allows them in filenames on Linux/macOS —
|
|
187
|
+
// so printing them raw let a crafted bundle rewrite this security prompt
|
|
188
|
+
// with terminal escapes right before an "Install anyway?" confirmation.
|
|
189
|
+
warn(`[warn] Env "${sanitizeString(envRef)}" in stash "${sanitizeString(stashLabel)}" contains potentially dangerous keys:`);
|
|
184
190
|
for (const key of keys) {
|
|
185
|
-
warn(` - ${key}: can hijack process execution via \`akm env run\``);
|
|
191
|
+
warn(` - ${sanitizeString(key)}: can hijack process execution via \`akm env run\``);
|
|
186
192
|
}
|
|
187
193
|
}
|
|
188
194
|
const confirmed = await p.confirm({
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
|
+
import embeddedChangelog from "../../../CHANGELOG.md" with { type: "text" };
|
|
6
7
|
import { getDirname } from "../../runtime.js";
|
|
7
8
|
const CHANGELOG_URL = "https://github.com/itlackey/akm/blob/main/CHANGELOG.md";
|
|
8
9
|
const MIGRATION_DOC_URL = "https://github.com/itlackey/akm/blob/main/docs/migration/v0.5-to-v0.6.md";
|
|
@@ -24,9 +25,13 @@ function loadChangelog() {
|
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
catch {
|
|
27
|
-
// fall through to
|
|
28
|
+
// fall through to the embedded copy
|
|
28
29
|
}
|
|
29
|
-
|
|
30
|
+
// In the `bun build --compile` standalone binary, import.meta.url points into
|
|
31
|
+
// the virtual /$bunfs tree and every existsSync above misses, so `akm help
|
|
32
|
+
// migrate <version>` degraded to the generic "no dedicated note" message for
|
|
33
|
+
// EVERY version. Only assets imported `with { type: "text" }` are embedded.
|
|
34
|
+
return embeddedChangelog.length > 0 ? embeddedChangelog : undefined;
|
|
30
35
|
}
|
|
31
36
|
/**
|
|
32
37
|
* Load the bundled migration note for a specific version, if one exists.
|
|
@@ -87,7 +92,11 @@ function resolveLatestVersion(changelog) {
|
|
|
87
92
|
return undefined;
|
|
88
93
|
}
|
|
89
94
|
function extractChangelogSection(changelog, version) {
|
|
90
|
-
|
|
95
|
+
// `\Z` is not a JavaScript anchor — it matches a literal "Z", which truncated
|
|
96
|
+
// the section at the first capital Z in the body (and failed outright for the
|
|
97
|
+
// last entry). `$` with the `m` flag would stop at the first line end, so the
|
|
98
|
+
// end-of-input alternative has to be an explicit lookahead for the input end.
|
|
99
|
+
const pattern = new RegExp(`^## \\[${escapeRegexString(version)}\\][^\\n]*\\n([\\s\\S]*?)(?=^## \\[|$(?![\\s\\S]))`, "m");
|
|
91
100
|
const match = changelog.match(pattern);
|
|
92
101
|
if (!match)
|
|
93
102
|
return undefined;
|
|
@@ -10,6 +10,7 @@ import { ConfigError } from "../../core/errors.js";
|
|
|
10
10
|
import { warn } from "../../core/warn.js";
|
|
11
11
|
import { githubHeaders } from "../../integrations/github.js";
|
|
12
12
|
import { getDirname, mainPath, semverOrder } from "../../runtime.js";
|
|
13
|
+
import { resolveAkmInvocation } from "../../tasks/resolve-akm-bin.js";
|
|
13
14
|
const REPO = "itlackey/akm";
|
|
14
15
|
const DEFAULT_PACKAGE_NAME = "akm-cli";
|
|
15
16
|
const NODE_MODULES_SEGMENT = "/node_modules/";
|
|
@@ -474,7 +475,14 @@ function readInstalledCliVersion(akmBin) {
|
|
|
474
475
|
return match?.[0];
|
|
475
476
|
}
|
|
476
477
|
function runRequiredCommand(akmBin, args, label) {
|
|
477
|
-
|
|
478
|
+
// A bare "akm" is not spawnable on Windows: npm/pnpm/yarn install a global CLI
|
|
479
|
+
// as akm.cmd / akm.ps1 shims, and spawnSync without a shell does not apply
|
|
480
|
+
// PATHEXT — so the package-manager upgrade arm died with ENOENT before it ever
|
|
481
|
+
// ran. resolveAkmInvocation returns a concrete argv (launcher, runtime + main
|
|
482
|
+
// script, or a standalone binary) for however this install actually runs.
|
|
483
|
+
// An explicit path (the standalone arm passes one) is used as given.
|
|
484
|
+
const [command, ...prefixArgs] = path.isAbsolute(akmBin) ? [akmBin] : resolveAkmInvocation().argv;
|
|
485
|
+
const result = childProcess.spawnSync(command ?? akmBin, [...prefixArgs, ...args], {
|
|
478
486
|
encoding: "utf8",
|
|
479
487
|
env: process.env,
|
|
480
488
|
stdio: "pipe",
|
|
@@ -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)) {
|