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
package/dist/core/json-schema.js
CHANGED
|
@@ -14,22 +14,288 @@
|
|
|
14
14
|
* Supported: `type` (string | string[] — string, number, integer, boolean,
|
|
15
15
|
* object, array, null), `properties`, `required`, `items`,
|
|
16
16
|
* `additionalProperties: false`, `enum` (primitives), `minItems`,
|
|
17
|
-
* `maxItems`, `minLength`, `maxLength`, `minimum`, `maximum
|
|
17
|
+
* `maxItems`, `minLength`, `maxLength`, `minimum`, `maximum`, and the
|
|
18
|
+
* combinators `allOf`, `anyOf`, `oneOf`, `not`.
|
|
18
19
|
*
|
|
19
|
-
* Ignored (permissive): `$ref`, `
|
|
20
|
-
* `
|
|
21
|
-
*
|
|
22
|
-
* Schema semantics should
|
|
20
|
+
* Ignored (permissive): `$ref`, `pattern`, `format`, `patternProperties`,
|
|
21
|
+
* tuple-form `items`, schema-form `additionalProperties`, and every other
|
|
22
|
+
* keyword. Unknown keywords never throw — a schema using them simply
|
|
23
|
+
* constrains less. Callers needing full JSON Schema semantics should
|
|
24
|
+
* validate downstream.
|
|
25
|
+
*
|
|
26
|
+
* `pattern` is deliberately NOT evaluated. Matching an author-supplied regex
|
|
27
|
+
* with the platform `RegExp` inside a synchronous gate decision would need a
|
|
28
|
+
* static safety analysis to stay bounded, and any such analysis rejects
|
|
29
|
+
* legitimate patterns — machinery that makes authoring fail for no benefit
|
|
30
|
+
* the workflows here ask for. `pattern` is a recognized-but-unsupported
|
|
31
|
+
* keyword instead: {@link checkJsonSchemaDefinition} reports it loudly at
|
|
32
|
+
* authoring time, so nobody writes one believing it is enforced.
|
|
33
|
+
*
|
|
34
|
+
* ## Totality and bounds
|
|
35
|
+
*
|
|
36
|
+
* Evaluation is TOTAL and BOUNDED. Recursion is capped at
|
|
37
|
+
* {@link MAX_DEFINITION_DEPTH} and the whole evaluation shares a single
|
|
38
|
+
* node-visit budget ({@link MAX_VALIDATION_NODES}); exhausting either emits an
|
|
39
|
+
* explicit error rather than silently accepting the value — the subset never
|
|
40
|
+
* fails open. The schema tree is finite and acyclic (no `$ref`), so combinator
|
|
41
|
+
* branching multiplies work by schema size, never exponentially.
|
|
23
42
|
*
|
|
24
43
|
* Returns a flat list of human-readable error strings (empty = valid), each
|
|
25
44
|
* prefixed with a JSON-pointer-ish path — the shape `runStructured`'s
|
|
26
45
|
* corrective-feedback builder wants.
|
|
46
|
+
*
|
|
47
|
+
* {@link checkJsonSchemaDefinition} is the companion DEFINITION checker: it
|
|
48
|
+
* walks an author-declared schema OBJECT (not a value) and reports typo'd
|
|
49
|
+
* `type` names / structurally malformed keywords as `"malformed"` issues, and
|
|
50
|
+
* recognized JSON Schema keywords this subset silently ignores as
|
|
51
|
+
* `"unsupported"` issues — so a schema that would constrain nothing at
|
|
52
|
+
* runtime can be rejected loudly at authoring time (the workflow parser does
|
|
53
|
+
* exactly that for `output:` and `params` schemas). It deliberately does NOT
|
|
54
|
+
* change {@link validateJsonSchemaSubset}'s permissive evaluation semantics.
|
|
55
|
+
*/
|
|
56
|
+
/**
|
|
57
|
+
* Deepest schema nesting either walk descends into — {@link
|
|
58
|
+
* validateJsonSchemaSubset} evaluating a value, and {@link
|
|
59
|
+
* checkJsonSchemaDefinition} checking the schema object itself. Both walk the
|
|
60
|
+
* same schema tree, so they share one bound.
|
|
27
61
|
*/
|
|
62
|
+
const MAX_DEFINITION_DEPTH = 64;
|
|
63
|
+
/** Total (schema node × value node) visits one {@link validateJsonSchemaSubset} call may make. */
|
|
64
|
+
const MAX_VALIDATION_NODES = 100_000;
|
|
28
65
|
export function validateJsonSchemaSubset(value, schema) {
|
|
29
66
|
const errors = [];
|
|
30
|
-
|
|
67
|
+
const budget = { nodes: MAX_VALIDATION_NODES };
|
|
68
|
+
validateNode(value, schema, "$", { errors, budget, depth: 0 });
|
|
69
|
+
if (budget.nodes < 0) {
|
|
70
|
+
errors.push(`$: schema evaluation exceeded the limit of ${MAX_VALIDATION_NODES} checks and was stopped`);
|
|
71
|
+
}
|
|
31
72
|
return errors;
|
|
32
73
|
}
|
|
74
|
+
/** Human-readable list of the keywords {@link validateJsonSchemaSubset} enforces (for error messages). */
|
|
75
|
+
export const JSON_SCHEMA_SUBSET_SUPPORTED_KEYWORDS = "type, enum, properties, required, items, additionalProperties: false, minItems, maxItems, " +
|
|
76
|
+
"minLength, maxLength, minimum, maximum, allOf, anyOf, oneOf, not";
|
|
77
|
+
const KNOWN_TYPE_NAMES = new Set(["string", "number", "integer", "boolean", "object", "array", "null"]);
|
|
78
|
+
// Annotation keywords (`title`, `description`, `default`, `examples`,
|
|
79
|
+
// `$schema`, `$id`, `$comment`, `deprecated`, `readOnly`, `writeOnly`) are
|
|
80
|
+
// deliberately NOT in the unsupported set below: they constrain nothing in
|
|
81
|
+
// full JSON Schema either, so the subset ignoring them loses no semantics.
|
|
82
|
+
// Like any other unrecognized keyword (e.g. `x-…` extensions), they fall
|
|
83
|
+
// through the checker unreported — JSON Schema's own open-keyword behavior.
|
|
84
|
+
/**
|
|
85
|
+
* Recognized JSON Schema keywords {@link validateJsonSchemaSubset} silently
|
|
86
|
+
* ignores — a schema relying on one of these constrains LESS at runtime than
|
|
87
|
+
* its author intended, so definition checking reports each as `"unsupported"`.
|
|
88
|
+
*/
|
|
89
|
+
const UNSUPPORTED_KEYWORDS = new Set([
|
|
90
|
+
"$ref",
|
|
91
|
+
"$defs",
|
|
92
|
+
"definitions",
|
|
93
|
+
"$anchor",
|
|
94
|
+
"$dynamicRef",
|
|
95
|
+
"$dynamicAnchor",
|
|
96
|
+
"if",
|
|
97
|
+
"then",
|
|
98
|
+
"else",
|
|
99
|
+
"const",
|
|
100
|
+
"pattern",
|
|
101
|
+
"format",
|
|
102
|
+
"patternProperties",
|
|
103
|
+
"propertyNames",
|
|
104
|
+
"additionalItems",
|
|
105
|
+
"prefixItems",
|
|
106
|
+
"contains",
|
|
107
|
+
"minContains",
|
|
108
|
+
"maxContains",
|
|
109
|
+
"uniqueItems",
|
|
110
|
+
"multipleOf",
|
|
111
|
+
"exclusiveMinimum",
|
|
112
|
+
"exclusiveMaximum",
|
|
113
|
+
"minProperties",
|
|
114
|
+
"maxProperties",
|
|
115
|
+
"dependencies",
|
|
116
|
+
"dependentRequired",
|
|
117
|
+
"dependentSchemas",
|
|
118
|
+
"unevaluatedItems",
|
|
119
|
+
"unevaluatedProperties",
|
|
120
|
+
"contentEncoding",
|
|
121
|
+
"contentMediaType",
|
|
122
|
+
"contentSchema",
|
|
123
|
+
]);
|
|
124
|
+
/**
|
|
125
|
+
* Per-keyword follow-up for the `"unsupported"` message. Where the subset can
|
|
126
|
+
* express the same intent, name the keyword that does it; where it cannot, say
|
|
127
|
+
* so plainly rather than sending the author to another unsupported keyword.
|
|
128
|
+
*/
|
|
129
|
+
const UNSUPPORTED_KEYWORD_HINTS = new Map([
|
|
130
|
+
["$ref", `inline the referenced schema (the subset resolves no references, so it cannot follow "$ref")`],
|
|
131
|
+
["$defs", `inline the definitions at their use sites — "$ref" is not resolved, so "$defs" can never be reached`],
|
|
132
|
+
[
|
|
133
|
+
"definitions",
|
|
134
|
+
`inline the definitions at their use sites — "$ref" is not resolved, so "definitions" can never be reached`,
|
|
135
|
+
],
|
|
136
|
+
["const", `use a single-value "enum" (e.g. enum: [pass])`],
|
|
137
|
+
[
|
|
138
|
+
"pattern",
|
|
139
|
+
`use "enum" when the allowed strings can be listed, or "minLength"/"maxLength" for a size bound — ` +
|
|
140
|
+
`a regular-expression constraint is not expressible in the subset; check the shape in the step's gate rubric instead`,
|
|
141
|
+
],
|
|
142
|
+
[
|
|
143
|
+
"format",
|
|
144
|
+
`"format" is annotation-only in JSON Schema 2020-12 and the subset enforces no string-shape keyword — ` +
|
|
145
|
+
`use "enum" when the allowed values can be listed, otherwise check the shape in the step's gate rubric`,
|
|
146
|
+
],
|
|
147
|
+
["patternProperties", `declare the properties explicitly under "properties", or drop the constraint`],
|
|
148
|
+
["if", `use "anyOf"/"oneOf" to express the alternatives directly`],
|
|
149
|
+
["then", `use "anyOf"/"oneOf" to express the alternatives directly`],
|
|
150
|
+
["else", `use "anyOf"/"oneOf" to express the alternatives directly`],
|
|
151
|
+
["uniqueItems", `drop the constraint, or validate uniqueness in the step's gate rubric`],
|
|
152
|
+
]);
|
|
153
|
+
/**
|
|
154
|
+
* Check a JSON Schema DEFINITION (the schema object itself, not a value)
|
|
155
|
+
* against the subset {@link validateJsonSchemaSubset} enforces. Returns
|
|
156
|
+
* accumulated issues (empty = the schema is a well-formed subset schema).
|
|
157
|
+
* Keywords that are neither subset-enforced, unsupported-but-recognized, nor
|
|
158
|
+
* annotations are ignored, matching JSON Schema's own open-keyword behavior.
|
|
159
|
+
*/
|
|
160
|
+
export function checkJsonSchemaDefinition(schema) {
|
|
161
|
+
const issues = [];
|
|
162
|
+
checkDefinitionNode(schema, [], issues, 0);
|
|
163
|
+
return issues;
|
|
164
|
+
}
|
|
165
|
+
function pointerFor(path) {
|
|
166
|
+
return path.length === 0 ? "$" : `$.${path.map(String).join(".")}`;
|
|
167
|
+
}
|
|
168
|
+
function pushIssue(issues, path, keyword, kind, message) {
|
|
169
|
+
issues.push({ path: [...path], pointer: pointerFor(path), keyword, kind, message });
|
|
170
|
+
}
|
|
171
|
+
function isPlainObject(value) {
|
|
172
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
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
|
+
}
|
|
181
|
+
function checkDefinitionNode(schema, path, issues, depth) {
|
|
182
|
+
if (depth > MAX_DEFINITION_DEPTH) {
|
|
183
|
+
pushIssue(issues, path, "(depth)", "malformed", `schema nesting exceeds the depth limit of ${MAX_DEFINITION_DEPTH}`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
for (const keyword of Object.keys(schema)) {
|
|
187
|
+
if (UNSUPPORTED_KEYWORDS.has(keyword)) {
|
|
188
|
+
const hint = UNSUPPORTED_KEYWORD_HINTS.get(keyword);
|
|
189
|
+
pushIssue(issues, [...path, keyword], keyword, "unsupported", `keyword "${keyword}" is not enforced by the workflow schema subset — the schema would silently not ` +
|
|
190
|
+
`constrain what it looks like it constrains${hint ? `; ${hint}` : ""}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const declared = schema.type;
|
|
194
|
+
if (declared !== undefined) {
|
|
195
|
+
const names = Array.isArray(declared) ? declared : [declared];
|
|
196
|
+
if (names.length === 0) {
|
|
197
|
+
pushIssue(issues, [...path, "type"], "type", "malformed", `"type" must name at least one type`);
|
|
198
|
+
}
|
|
199
|
+
for (const [index, name] of names.entries()) {
|
|
200
|
+
const namePath = Array.isArray(declared) ? [...path, "type", index] : [...path, "type"];
|
|
201
|
+
if (typeof name !== "string") {
|
|
202
|
+
pushIssue(issues, namePath, "type", "malformed", `"type" must be a string or an array of strings`);
|
|
203
|
+
}
|
|
204
|
+
else if (!KNOWN_TYPE_NAMES.has(name)) {
|
|
205
|
+
pushIssue(issues, namePath, "type", "malformed", `unknown type ${JSON.stringify(name)} (valid types: ${[...KNOWN_TYPE_NAMES].join(", ")})`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
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
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const keyword of ["allOf", "anyOf", "oneOf"]) {
|
|
223
|
+
const branches = schema[keyword];
|
|
224
|
+
if (branches === undefined)
|
|
225
|
+
continue;
|
|
226
|
+
if (!Array.isArray(branches) || branches.length === 0) {
|
|
227
|
+
pushIssue(issues, [...path, keyword], keyword, "malformed", `"${keyword}" must be a non-empty array of schema objects`);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
branches.forEach((branch, index) => {
|
|
231
|
+
if (isPlainObject(branch)) {
|
|
232
|
+
checkDefinitionNode(branch, [...path, keyword, index], issues, depth + 1);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
pushIssue(issues, [...path, keyword, index], keyword, "malformed", `"${keyword}[${index}]" must be a schema object`);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (schema.not !== undefined) {
|
|
240
|
+
if (isPlainObject(schema.not)) {
|
|
241
|
+
checkDefinitionNode(schema.not, [...path, "not"], issues, depth + 1);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
pushIssue(issues, [...path, "not"], "not", "malformed", `"not" must be a schema object`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (schema.required !== undefined) {
|
|
248
|
+
if (!Array.isArray(schema.required) || !schema.required.every((key) => typeof key === "string")) {
|
|
249
|
+
pushIssue(issues, [...path, "required"], "required", "malformed", `"required" must be an array of property-name strings`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (schema.properties !== undefined) {
|
|
253
|
+
if (!isPlainObject(schema.properties)) {
|
|
254
|
+
pushIssue(issues, [...path, "properties"], "properties", "malformed", `"properties" must be an object mapping property names to schemas`);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
258
|
+
if (isPlainObject(propSchema)) {
|
|
259
|
+
checkDefinitionNode(propSchema, [...path, "properties", key], issues, depth + 1);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
pushIssue(issues, [...path, "properties", key], "properties", "malformed", `property ${JSON.stringify(key)} must be a schema object`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (schema.items !== undefined) {
|
|
268
|
+
if (isPlainObject(schema.items)) {
|
|
269
|
+
checkDefinitionNode(schema.items, [...path, "items"], issues, depth + 1);
|
|
270
|
+
}
|
|
271
|
+
else if (Array.isArray(schema.items)) {
|
|
272
|
+
pushIssue(issues, [...path, "items"], "items", "unsupported", `tuple-form "items" (an array of schemas) is not enforced by the workflow schema subset — use a single schema object`);
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
pushIssue(issues, [...path, "items"], "items", "malformed", `"items" must be a schema object`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== "boolean") {
|
|
279
|
+
if (isPlainObject(schema.additionalProperties)) {
|
|
280
|
+
pushIssue(issues, [...path, "additionalProperties"], "additionalProperties", "unsupported", `schema-form "additionalProperties" is not enforced by the workflow schema subset — only "additionalProperties: false" is`);
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
pushIssue(issues, [...path, "additionalProperties"], "additionalProperties", "malformed", `"additionalProperties" must be a boolean (only "false" is enforced)`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
for (const keyword of ["minItems", "maxItems", "minLength", "maxLength"]) {
|
|
287
|
+
const value = schema[keyword];
|
|
288
|
+
if (value !== undefined && (typeof value !== "number" || !Number.isInteger(value) || value < 0)) {
|
|
289
|
+
pushIssue(issues, [...path, keyword], keyword, "malformed", `"${keyword}" must be a non-negative integer`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
for (const keyword of ["minimum", "maximum"]) {
|
|
293
|
+
const value = schema[keyword];
|
|
294
|
+
if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value))) {
|
|
295
|
+
pushIssue(issues, [...path, keyword], keyword, "malformed", `"${keyword}" must be a finite number`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
33
299
|
function typeOf(value) {
|
|
34
300
|
if (value === null)
|
|
35
301
|
return "null";
|
|
@@ -52,7 +318,66 @@ function matchesType(actual, expected) {
|
|
|
52
318
|
// JSON Schema: every integer is also a number.
|
|
53
319
|
return expected === "number" && actual === "integer";
|
|
54
320
|
}
|
|
55
|
-
|
|
321
|
+
/** Evaluate `schema` against `value` in a scratch error list, sharing the caller's budget. */
|
|
322
|
+
function branchErrors(value, schema, path, ctx) {
|
|
323
|
+
const errors = [];
|
|
324
|
+
validateNode(value, schema, path, { ...ctx, errors, depth: ctx.depth + 1 });
|
|
325
|
+
return errors;
|
|
326
|
+
}
|
|
327
|
+
/** The schemas of a combinator keyword, or `[]` when the keyword is absent/malformed (permissive). */
|
|
328
|
+
function combinatorBranches(schema, keyword) {
|
|
329
|
+
const raw = schema[keyword];
|
|
330
|
+
if (!Array.isArray(raw))
|
|
331
|
+
return [];
|
|
332
|
+
return raw.filter(isPlainObject);
|
|
333
|
+
}
|
|
334
|
+
/** First error of each failing branch, truncated — enough to act on without dumping every branch. */
|
|
335
|
+
function summarizeBranchFailures(failures) {
|
|
336
|
+
const shown = failures.slice(0, 3).map((f) => `${f.index + 1}: ${f.errors[0] ?? "no match"}`);
|
|
337
|
+
if (failures.length > shown.length)
|
|
338
|
+
shown.push(`…${failures.length - shown.length} more`);
|
|
339
|
+
return shown.join("; ");
|
|
340
|
+
}
|
|
341
|
+
function validateCombinators(value, schema, path, ctx) {
|
|
342
|
+
for (const branch of combinatorBranches(schema, "allOf")) {
|
|
343
|
+
// `allOf` failures ARE the value's failures — surface them verbatim.
|
|
344
|
+
ctx.errors.push(...branchErrors(value, branch, path, ctx));
|
|
345
|
+
}
|
|
346
|
+
for (const keyword of ["anyOf", "oneOf"]) {
|
|
347
|
+
const branches = combinatorBranches(schema, keyword);
|
|
348
|
+
if (branches.length === 0)
|
|
349
|
+
continue;
|
|
350
|
+
const failures = [];
|
|
351
|
+
const matched = [];
|
|
352
|
+
branches.forEach((branch, index) => {
|
|
353
|
+
const errors = branchErrors(value, branch, path, ctx);
|
|
354
|
+
if (errors.length === 0)
|
|
355
|
+
matched.push(index + 1);
|
|
356
|
+
else
|
|
357
|
+
failures.push({ index, errors });
|
|
358
|
+
});
|
|
359
|
+
if (matched.length === 0) {
|
|
360
|
+
ctx.errors.push(`${path}: value matches none of the ${branches.length} "${keyword}" schemas (${summarizeBranchFailures(failures)})`);
|
|
361
|
+
}
|
|
362
|
+
else if (keyword === "oneOf" && matched.length > 1) {
|
|
363
|
+
ctx.errors.push(`${path}: value matches ${matched.length} "oneOf" schemas (branches ${matched.join(", ")}); exactly one must match`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const not = schema.not;
|
|
367
|
+
if (isPlainObject(not) && branchErrors(value, not, path, ctx).length === 0) {
|
|
368
|
+
ctx.errors.push(`${path}: value must not match the "not" schema`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function validateNode(value, schema, path, ctx) {
|
|
372
|
+
const errors = ctx.errors;
|
|
373
|
+
if (ctx.depth > MAX_DEFINITION_DEPTH) {
|
|
374
|
+
errors.push(`${path}: schema nesting exceeds the depth limit of ${MAX_DEFINITION_DEPTH}`);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// Fail CLOSED: a truncated evaluation never returns "valid" — the counter
|
|
378
|
+
// going negative is what the wrapper turns into a top-level error.
|
|
379
|
+
if (--ctx.budget.nodes < 0)
|
|
380
|
+
return;
|
|
56
381
|
const actual = typeOf(value);
|
|
57
382
|
const declared = schema.type;
|
|
58
383
|
if (typeof declared === "string" || Array.isArray(declared)) {
|
|
@@ -69,6 +394,10 @@ function validateNode(value, schema, path, errors) {
|
|
|
69
394
|
return;
|
|
70
395
|
}
|
|
71
396
|
}
|
|
397
|
+
// Combinators are type-agnostic, so they run BEFORE the per-type branches
|
|
398
|
+
// below (each of which returns). A schema with no combinator keyword is
|
|
399
|
+
// untouched by this call.
|
|
400
|
+
validateCombinators(value, schema, path, ctx);
|
|
72
401
|
if (actual === "string" && typeof value === "string") {
|
|
73
402
|
if (typeof schema.minLength === "number" && value.length < schema.minLength) {
|
|
74
403
|
errors.push(`${path}: string shorter than minLength ${schema.minLength}`);
|
|
@@ -97,7 +426,10 @@ function validateNode(value, schema, path, errors) {
|
|
|
97
426
|
const items = schema.items;
|
|
98
427
|
if (items && typeof items === "object" && !Array.isArray(items)) {
|
|
99
428
|
value.forEach((element, index) => {
|
|
100
|
-
validateNode(element, items, `${path}[${index}]`,
|
|
429
|
+
validateNode(element, items, `${path}[${index}]`, {
|
|
430
|
+
...ctx,
|
|
431
|
+
depth: ctx.depth + 1,
|
|
432
|
+
});
|
|
101
433
|
});
|
|
102
434
|
}
|
|
103
435
|
return;
|
|
@@ -122,7 +454,10 @@ function validateNode(value, schema, path, errors) {
|
|
|
122
454
|
if (!Object.hasOwn(record, key))
|
|
123
455
|
continue;
|
|
124
456
|
if (propSchema && typeof propSchema === "object" && !Array.isArray(propSchema)) {
|
|
125
|
-
validateNode(record[key], propSchema, `${path}.${key}`,
|
|
457
|
+
validateNode(record[key], propSchema, `${path}.${key}`, {
|
|
458
|
+
...ctx,
|
|
459
|
+
depth: ctx.depth + 1,
|
|
460
|
+
});
|
|
126
461
|
}
|
|
127
462
|
}
|
|
128
463
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
* Loopback classification for LLM endpoints: does this endpoint name a model
|
|
6
|
+
* server running on THIS machine? A host is loopback when it is `localhost` or
|
|
7
|
+
* a `*.localhost` name (RFC 6761 §6.3), anywhere in `127.0.0.0/8`, an
|
|
8
|
+
* unspecified address (`0.0.0.0` / `::` — a client connecting there reaches
|
|
9
|
+
* local loopback), IPv6 `::1`, or the dotted IPv4-mapped spelling of a
|
|
10
|
+
* `127.0.0.0/8` address (`::ffff:127.0.0.1`). Address shapes are validated by
|
|
11
|
+
* `node:net`'s `isIP`, so a near-miss NAME like `127.0.0.1.evil.com` is remote.
|
|
12
|
+
*
|
|
13
|
+
* The check is purely syntactic — no DNS, no interface list — so the same
|
|
14
|
+
* config classifies the same on a laptop, on CI, and on a machine with no
|
|
15
|
+
* network. Ambiguity resolves toward LOOPBACK: misclassifying a local server
|
|
16
|
+
* as remote widens a concurrency pool onto a single-threaded local model
|
|
17
|
+
* (one loaded model, reload thrash, HTTP 500 — a hard failure); the reverse
|
|
18
|
+
* only costs throughput. The remedy in either direction is one line of config:
|
|
19
|
+
* `engines.<name>.concurrency`.
|
|
20
|
+
*
|
|
21
|
+
* The ONE local-vs-remote answer for concurrency defaults — the workflow
|
|
22
|
+
* engine's frozen engine concurrency (`workflows/concurrency-policy.ts`) and
|
|
23
|
+
* the indexer's LLM pool (`indexer/indexer.ts` `getDefaultLlmConcurrency`)
|
|
24
|
+
* both classify through here. NOT shared with the website snapshot fetcher's
|
|
25
|
+
* SSRF policy (`sources/snapshot-fetchers/host-guard.ts`), which deliberately
|
|
26
|
+
* draws different lines for a different threat model.
|
|
27
|
+
*
|
|
28
|
+
* @module core/loopback
|
|
29
|
+
*/
|
|
30
|
+
import { isIP } from "node:net";
|
|
31
|
+
/**
|
|
32
|
+
* The IPv4-mapped IPv6 spellings of `127.0.0.0/8`: the dotted form, whose
|
|
33
|
+
* capture is validated by `isIP`, and the hex form `::ffff:7fxx:xxxx`. Both are
|
|
34
|
+
* needed because WHATWG `URL` re-serializes `[::ffff:127.0.0.1]` to the hex
|
|
35
|
+
* form, so an endpoint written the dotted way arrives here as hex.
|
|
36
|
+
*/
|
|
37
|
+
const IPV4_MAPPED_IPV6 = /^::ffff:(?:([\d.]+)|7f[\da-f]{2}:[\da-f]{1,4})$/;
|
|
38
|
+
/** IPv6 groups before the last one, all of which must be zero (or elided). */
|
|
39
|
+
const ZERO_GROUP = /^0+$/;
|
|
40
|
+
/** The last IPv6 group of `::` / `::1`, in any zero-padding. */
|
|
41
|
+
const LOOPBACK_LAST_GROUP = /^0*[01]?$/;
|
|
42
|
+
/**
|
|
43
|
+
* True when `host` — a URL host component, with or without IPv6 brackets —
|
|
44
|
+
* names this machine WITHOUT resolving anything.
|
|
45
|
+
*
|
|
46
|
+
* Not recognized: the IPv4-compatible form (`::127.0.0.1`), and any NAME that
|
|
47
|
+
* merely resolves to loopback.
|
|
48
|
+
*
|
|
49
|
+
* Exported for the boundary-case table in
|
|
50
|
+
* `tests/workflows/concurrency-defaults.test.ts`, which has to reach the host
|
|
51
|
+
* predicate directly: several of its cases (a bare `""`, a malformed literal
|
|
52
|
+
* like `1::2::3`) cannot round-trip through a URL without changing meaning.
|
|
53
|
+
*/
|
|
54
|
+
export function isLoopbackHost(host) {
|
|
55
|
+
// Strip IPv6 brackets (`URL.hostname` keeps them) and the DNS root label. An
|
|
56
|
+
// empty host (`new URL("localhost:1234")` parses as scheme `localhost:` with
|
|
57
|
+
// no host) has nothing to judge, so it fails safe like an unparseable one.
|
|
58
|
+
const name = host.trim().toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "");
|
|
59
|
+
if (name === "" || name === "localhost" || name.endsWith(".localhost"))
|
|
60
|
+
return true;
|
|
61
|
+
const mapped = IPV4_MAPPED_IPV6.exec(name);
|
|
62
|
+
if (mapped) {
|
|
63
|
+
const quad = mapped[1];
|
|
64
|
+
// The hex branch already matched 127.x in the pattern; the dotted one still
|
|
65
|
+
// needs `isIP` so `::ffff:127.0.0.256` stays remote.
|
|
66
|
+
return quad === undefined || (isIP(quad) === 4 && quad.startsWith("127."));
|
|
67
|
+
}
|
|
68
|
+
const version = isIP(name);
|
|
69
|
+
if (version === 4)
|
|
70
|
+
return name.startsWith("127.") || name === "0.0.0.0";
|
|
71
|
+
if (version !== 6)
|
|
72
|
+
return false;
|
|
73
|
+
const groups = name.split(":");
|
|
74
|
+
const last = groups.pop() ?? "";
|
|
75
|
+
return groups.every((group) => group === "" || ZERO_GROUP.test(group)) && LOOPBACK_LAST_GROUP.test(last);
|
|
76
|
+
}
|
|
77
|
+
/** True when `endpoint` points at this machine (see {@link isLoopbackHost}). */
|
|
78
|
+
export function isLoopbackEndpoint(endpoint) {
|
|
79
|
+
if (!endpoint)
|
|
80
|
+
return true;
|
|
81
|
+
try {
|
|
82
|
+
return isLoopbackHost(new URL(endpoint).hostname);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// An unparseable endpoint is treated as local: guessing "remote" here would
|
|
86
|
+
// widen the pool on exactly the configs we understand least.
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -2,9 +2,9 @@
|
|
|
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 crypto from "node:crypto";
|
|
5
|
-
import fs from "node:fs";
|
|
6
5
|
import path from "node:path";
|
|
7
6
|
import { ConfigError } from "./errors.js";
|
|
7
|
+
import { classifyPathAccess, describeInaccessiblePath } from "./path-access.js";
|
|
8
8
|
import { getConfigPath, getDataDir } from "./paths.js";
|
|
9
9
|
let afterPendingCheckHook;
|
|
10
10
|
/** TEST-ONLY: run once after a clear pending-operation check. */
|
|
@@ -45,12 +45,27 @@ export function getMigrationApplyJournalPath() {
|
|
|
45
45
|
export function getMigrationGeneratedConfigPath() {
|
|
46
46
|
return path.join(getMigrationOperationRoot(), "generated-config.json");
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Refuse canonical config/database access while a migration or restore is
|
|
50
|
+
* mid-flight.
|
|
51
|
+
*
|
|
52
|
+
* This is a SAFETY gate, so "I could not tell" must fail the same way "yes"
|
|
53
|
+
* does. `fs.existsSync` answered `false` for an unreadable journal exactly as
|
|
54
|
+
* for an absent one (#791), which meant a permission fault on the journal
|
|
55
|
+
* silently CLEARED the gate and let akm open the canonical databases on top of
|
|
56
|
+
* a half-applied migration. Absence is the only answer that may open the door.
|
|
57
|
+
*/
|
|
48
58
|
export function assertNoPendingMigrationOperation() {
|
|
49
59
|
for (const [kind, journalPath] of [
|
|
50
60
|
["restore", getMigrationRestoreJournalPath()],
|
|
51
61
|
["migration apply", getMigrationApplyJournalPath()],
|
|
52
62
|
]) {
|
|
53
|
-
|
|
63
|
+
const { access, code } = classifyPathAccess(journalPath);
|
|
64
|
+
if (access === "inaccessible") {
|
|
65
|
+
throw new ConfigError(`Cannot determine whether an AKM ${kind} recovery is pending: ${describeInaccessiblePath(journalPath, code)}. ` +
|
|
66
|
+
"Refusing canonical config/database access — proceeding could write on top of a half-applied migration.", "DATA_DIR_UNREADABLE");
|
|
67
|
+
}
|
|
68
|
+
if (access === "present") {
|
|
54
69
|
throw new ConfigError(`AKM ${kind} recovery is pending at ${journalPath}; refusing canonical config/database access until recovery completes.`, "INVALID_CONFIG_FILE");
|
|
55
70
|
}
|
|
56
71
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
* Tell "this path is not there" apart from "I am not allowed to look at it"
|
|
6
|
+
* (issue #791).
|
|
7
|
+
*
|
|
8
|
+
* # Why this exists
|
|
9
|
+
*
|
|
10
|
+
* `fs.existsSync()` answers `false` for BOTH cases: it swallows every error,
|
|
11
|
+
* so `ENOENT` (genuinely absent) and `EACCES` (present, unreadable) are
|
|
12
|
+
* indistinguishable. akm used `existsSync` as its "is there an index?" gate on
|
|
13
|
+
* the read path, so an index the caller could not read was reported as an index
|
|
14
|
+
* that did not exist — `akm search` and `akm curate` returned
|
|
15
|
+
* `hits: []` with the tip *"No search index available. Run 'akm index' to build
|
|
16
|
+
* one."* at exit 0, for a populated index sitting right there on disk.
|
|
17
|
+
*
|
|
18
|
+
* That is the worst possible shape for a failure. A non-zero exit is a problem
|
|
19
|
+
* the operator can see; an empty-but-successful result is a lie that a
|
|
20
|
+
* consuming agent will confidently relay to its user. In the report that
|
|
21
|
+
* prompted this module, exactly that happened: an agent told its user akm's
|
|
22
|
+
* "vector service is unavailable and its maintenance lock is read-only" — a
|
|
23
|
+
* story it invented to explain results that claimed to be fine.
|
|
24
|
+
*
|
|
25
|
+
* # Contract
|
|
26
|
+
*
|
|
27
|
+
* `absent` means the path (or a parent component) genuinely is not there, which
|
|
28
|
+
* is a legitimate first-run state every caller already handles. `inaccessible`
|
|
29
|
+
* means the path may well exist and this process cannot determine that or read
|
|
30
|
+
* it — a caller must NEVER degrade that to an empty-but-successful result.
|
|
31
|
+
*/
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
function errnoOf(error) {
|
|
34
|
+
const code = error?.code;
|
|
35
|
+
return typeof code === "string" ? code : undefined;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Classify `target` as {@link PathAccess}.
|
|
39
|
+
*
|
|
40
|
+
* `ENOENT`/`ENOTDIR` are `absent` — the path cannot exist as named. Everything
|
|
41
|
+
* else that fails is `inaccessible`, carrying its errno: a permission error is
|
|
42
|
+
* the common case, but a symlink loop or an I/O error are equally "present as
|
|
43
|
+
* far as anyone knows, and unusable", and silently treating them as "no index"
|
|
44
|
+
* is the bug this module exists to prevent.
|
|
45
|
+
*
|
|
46
|
+
* Statting is not enough — a file can be `stat`-able through a searchable
|
|
47
|
+
* parent while being unreadable itself — so a successful stat is confirmed with
|
|
48
|
+
* an `R_OK` access check.
|
|
49
|
+
*/
|
|
50
|
+
export function classifyPathAccess(target) {
|
|
51
|
+
try {
|
|
52
|
+
fs.statSync(target);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
const code = errnoOf(error);
|
|
56
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
57
|
+
return { access: "absent" };
|
|
58
|
+
return { access: "inaccessible", ...(code ? { code } : {}) };
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
fs.accessSync(target, fs.constants.R_OK);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
const code = errnoOf(error);
|
|
65
|
+
return { access: "inaccessible", ...(code ? { code } : {}) };
|
|
66
|
+
}
|
|
67
|
+
return { access: "present" };
|
|
68
|
+
}
|
|
69
|
+
/** True when the path is genuinely absent — the ordinary "not built yet" state. */
|
|
70
|
+
export function isPathAbsent(target) {
|
|
71
|
+
return classifyPathAccess(target).access === "absent";
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A diagnostic line naming everything an operator needs to fix a permission
|
|
75
|
+
* problem without a second round trip: the path, the errno, the mode and owner
|
|
76
|
+
* of whatever akm *could* stat along the way, and the uid actually running.
|
|
77
|
+
*
|
|
78
|
+
* Deliberately best-effort — this runs on an error path, so a failure to gather
|
|
79
|
+
* detail must never mask the error being described.
|
|
80
|
+
*/
|
|
81
|
+
export function describeInaccessiblePath(target, code) {
|
|
82
|
+
const parts = [target];
|
|
83
|
+
if (code)
|
|
84
|
+
parts.push(`(${code})`);
|
|
85
|
+
try {
|
|
86
|
+
const stat = fs.statSync(target);
|
|
87
|
+
parts.push(`mode ${(stat.mode & 0o777).toString(8).padStart(3, "0")}, owner uid ${stat.uid}`);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Cannot stat the file itself — describe the closest parent we CAN see,
|
|
91
|
+
// which is usually where the missing permission actually is.
|
|
92
|
+
const parent = target.slice(0, Math.max(0, target.lastIndexOf("/")));
|
|
93
|
+
if (parent) {
|
|
94
|
+
try {
|
|
95
|
+
const stat = fs.statSync(parent);
|
|
96
|
+
parts.push(`parent ${parent} is mode ${(stat.mode & 0o777).toString(8).padStart(3, "0")}, owner uid ${stat.uid}`);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Nothing further to say.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
104
|
+
if (uid !== undefined)
|
|
105
|
+
parts.push(`running as uid ${uid}`);
|
|
106
|
+
return parts.join("; ");
|
|
107
|
+
}
|
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) {
|