agentic-workflow-manager 9.6.0 → 9.6.1
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/dist/src/commands/context/index.js +3 -1
- package/dist/src/core/discovery.js +79 -4
- package/dist/src/core/orchestrators.js +34 -10
- package/dist/src/core/text.js +7 -0
- package/dist/tests/commands/context.test.js +9 -1
- package/dist/tests/commands/hooks/install.test.js +4 -0
- package/dist/tests/commands/hooks/resync.test.js +2 -0
- package/dist/tests/core/context/orchestrator.test.js +2 -0
- package/dist/tests/core/discovery-multiroot.test.js +5 -0
- package/dist/tests/core/discovery-parent-race.test.js +95 -0
- package/dist/tests/core/discovery-safe-read.test.js +115 -0
- package/dist/tests/core/discovery.test.js +28 -1
- package/dist/tests/core/orchestrators.test.js +171 -14
- package/dist/tests/core/text.test.js +10 -0
- package/dist/tests/integration/context-orchestrators-e2e.test.js +57 -7
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
|
@@ -15,7 +15,9 @@ function runContextOrchestrators(collected, opts) {
|
|
|
15
15
|
// un usuario que tipee el nombre declarado exacto (con un caracter que el saneo
|
|
16
16
|
// quita, p.ej. `_` o un byte de control) se lleva un falso "not composed" (R3.5/R3.6).
|
|
17
17
|
const verifyNormalized = (0, text_1.sanitizeDeclaredField)(opts.verify);
|
|
18
|
-
|
|
18
|
+
// A dropped raw identity must not borrow a retained declaration's sanitized name.
|
|
19
|
+
const found = !collected.droppedNames.includes(opts.verify)
|
|
20
|
+
&& composed.some((o) => o.name === verifyNormalized);
|
|
19
21
|
if (!found) {
|
|
20
22
|
const available = composed.map((o) => o.name).join(', ') || '(none)';
|
|
21
23
|
return {
|
|
@@ -34,6 +34,73 @@ function readArtifactDescription(filePath) {
|
|
|
34
34
|
return '';
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
function inspectSkillDirectory(parent, expected) {
|
|
38
|
+
const stat = fs_1.default.lstatSync(parent, { bigint: true });
|
|
39
|
+
if (stat.isSymbolicLink())
|
|
40
|
+
throw new Error(`${parent}: must not be a symbolic link`);
|
|
41
|
+
if (!stat.isDirectory())
|
|
42
|
+
throw new Error(`${parent}: must be a directory`);
|
|
43
|
+
if (typeof stat.dev !== 'bigint' || typeof stat.ino !== 'bigint' || stat.dev < 0n || stat.ino <= 0n) {
|
|
44
|
+
throw new Error(`${parent}: directory identity is unobservable`);
|
|
45
|
+
}
|
|
46
|
+
if (expected && (stat.dev !== expected.dev || stat.ino !== expected.ino)) {
|
|
47
|
+
throw new Error(`${parent}: directory identity changed after inspection`);
|
|
48
|
+
}
|
|
49
|
+
return stat;
|
|
50
|
+
}
|
|
51
|
+
/** CTX-CONSTITUTION-052: bind the inspected leaf to its observable parent chain. */
|
|
52
|
+
function readSkillDescription(file) {
|
|
53
|
+
const absolute = path_1.default.resolve(file);
|
|
54
|
+
const parents = [];
|
|
55
|
+
for (let parent = path_1.default.dirname(absolute);; parent = path_1.default.dirname(parent)) {
|
|
56
|
+
parents.unshift(parent);
|
|
57
|
+
if (parent === path_1.default.dirname(parent))
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
const inspectedParents = parents.map(parent => ({ path: parent, stat: inspectSkillDirectory(parent) }));
|
|
61
|
+
const validateParents = () => {
|
|
62
|
+
for (const parent of inspectedParents)
|
|
63
|
+
inspectSkillDirectory(parent.path, parent.stat);
|
|
64
|
+
};
|
|
65
|
+
let inspected;
|
|
66
|
+
try {
|
|
67
|
+
inspected = fs_1.default.lstatSync(file, { bigint: true });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error?.code === 'ENOENT')
|
|
71
|
+
return null;
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
// A parent swapped before leaf lstat must not establish a new trusted identity.
|
|
75
|
+
validateParents();
|
|
76
|
+
if (inspected.isSymbolicLink())
|
|
77
|
+
throw new Error('must not be a symbolic link');
|
|
78
|
+
if (!inspected.isFile())
|
|
79
|
+
throw new Error('must be a regular file');
|
|
80
|
+
if (typeof inspected.dev !== 'bigint' || typeof inspected.ino !== 'bigint'
|
|
81
|
+
|| typeof inspected.size !== 'bigint' || inspected.dev < 0n
|
|
82
|
+
|| inspected.ino <= 0n || inspected.size < 0n) {
|
|
83
|
+
throw new Error('file identity or size is unobservable');
|
|
84
|
+
}
|
|
85
|
+
const flags = fs_1.default.constants.O_RDONLY | (fs_1.default.constants.O_NOFOLLOW ?? 0) | (fs_1.default.constants.O_NONBLOCK ?? 0);
|
|
86
|
+
const fd = fs_1.default.openSync(file, flags);
|
|
87
|
+
try {
|
|
88
|
+
const opened = fs_1.default.fstatSync(fd, { bigint: true });
|
|
89
|
+
if (!opened.isFile())
|
|
90
|
+
throw new Error('must be a regular file');
|
|
91
|
+
if (opened.dev !== inspected.dev || opened.ino !== inspected.ino || opened.size !== inspected.size) {
|
|
92
|
+
throw new Error('file identity or size changed after inspection');
|
|
93
|
+
}
|
|
94
|
+
// Reject substitutions observable after open, while retaining descriptor authority.
|
|
95
|
+
validateParents();
|
|
96
|
+
const raw = fs_1.default.readFileSync(fd, 'utf-8');
|
|
97
|
+
const frontmatter = (0, frontmatter_2.matchFrontmatterBlock)(raw);
|
|
98
|
+
return frontmatter === null ? '' : (0, frontmatter_2.readFrontmatterDescription)(frontmatter);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
fs_1.default.closeSync(fd);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
37
104
|
function collisionError(kind, name, first, second) {
|
|
38
105
|
return new Error(`Artifact name collision: ${kind} "${name}" exists in both ${first} and ${second}. ` +
|
|
39
106
|
`Remove or rename one of them, or declare "${name}" in "overrides" of the later registry's awm-registry.json.`);
|
|
@@ -54,7 +121,7 @@ function mergeEntry(kind, byName, entry, rootOverrides) {
|
|
|
54
121
|
}
|
|
55
122
|
/**
|
|
56
123
|
* Scans skills directories across all provided content roots and returns all valid skills.
|
|
57
|
-
* A valid skill is a directory that contains a SKILL.md file.
|
|
124
|
+
* A valid skill is a directory that contains a regular, non-symlink SKILL.md file.
|
|
58
125
|
* Throws on name collision across roots unless the later root declares the name in its awm-registry.json overrides.
|
|
59
126
|
*/
|
|
60
127
|
function discoverSkills(roots = (0, registries_1.contentRoots)()) {
|
|
@@ -68,12 +135,20 @@ function discoverSkills(roots = (0, registries_1.contentRoots)()) {
|
|
|
68
135
|
if (!entry.isDirectory())
|
|
69
136
|
continue;
|
|
70
137
|
const skillPath = path_1.default.join(dir, entry.name);
|
|
71
|
-
|
|
72
|
-
|
|
138
|
+
const skillFile = path_1.default.join(skillPath, 'SKILL.md');
|
|
139
|
+
let description;
|
|
140
|
+
try {
|
|
141
|
+
description = readSkillDescription(skillFile);
|
|
142
|
+
if (description === null)
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
throw new Error(`${skillFile}: cannot read (${e instanceof Error ? e.message : String(e)})`);
|
|
147
|
+
}
|
|
73
148
|
mergeEntry('skill', byName, {
|
|
74
149
|
name: entry.name,
|
|
75
150
|
path: skillPath,
|
|
76
|
-
description
|
|
151
|
+
description,
|
|
77
152
|
}, overrides);
|
|
78
153
|
}
|
|
79
154
|
}
|
|
@@ -17,6 +17,7 @@ exports.collectAndWarn = collectAndWarn;
|
|
|
17
17
|
const fs_1 = __importDefault(require("fs"));
|
|
18
18
|
const path_1 = __importDefault(require("path"));
|
|
19
19
|
const registries_1 = require("./registries");
|
|
20
|
+
const discovery_1 = require("./discovery");
|
|
20
21
|
const text_1 = require("./text");
|
|
21
22
|
const ALLOWED_FIELDS = ['name', 'appliesWhen', 'terminatesTo'];
|
|
22
23
|
// These fields are semantically short (a short identity, a short trigger condition, a
|
|
@@ -24,6 +25,24 @@ const ALLOWED_FIELDS = ['name', 'appliesWhen', 'terminatesTo'];
|
|
|
24
25
|
// untrusted input whose fields flow straight into the AI-provider context payload, so an
|
|
25
26
|
// unbounded string here would let a crafted registry bloat/DoS that context.
|
|
26
27
|
const MAX_FIELD_LENGTH = 500;
|
|
28
|
+
function discoverDeclaredSkillNames() {
|
|
29
|
+
let names = new Set();
|
|
30
|
+
const diagnostics = [];
|
|
31
|
+
const acceptedRoots = [];
|
|
32
|
+
for (const root of (0, registries_1.contentRoots)()) {
|
|
33
|
+
try {
|
|
34
|
+
// Admit the entire root only after global collision/override resolution succeeds.
|
|
35
|
+
const skills = (0, discovery_1.discoverSkills)([...acceptedRoots, root]);
|
|
36
|
+
names = new Set(skills.map((skill) => skill.name));
|
|
37
|
+
acceptedRoots.push(root);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
41
|
+
diagnostics.push(`${(0, text_1.sanitizeDiagnosticText)(root)}: orchestrator skill discovery unavailable (${(0, text_1.sanitizeDiagnosticText)(message)})`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { names, diagnostics };
|
|
45
|
+
}
|
|
27
46
|
function readDeclaredOrchestrators(root) {
|
|
28
47
|
const file = path_1.default.join(root, registries_1.REGISTRY_MANIFEST_NAME);
|
|
29
48
|
// Shares the same trust boundary as readRegistryManifest (registries.ts): a manifest
|
|
@@ -35,7 +54,7 @@ function readDeclaredOrchestrators(root) {
|
|
|
35
54
|
exists = (0, registries_1.assertRegularRegistryFile)(file);
|
|
36
55
|
}
|
|
37
56
|
catch (e) {
|
|
38
|
-
return { orchestrators: [], diagnostics: [`${file}: ${e instanceof Error ? e.message : String(e)}`] };
|
|
57
|
+
return { orchestrators: [], diagnostics: [(0, text_1.sanitizeDiagnosticText)(`${file}: ${e instanceof Error ? e.message : String(e)}`)] };
|
|
39
58
|
}
|
|
40
59
|
if (!exists)
|
|
41
60
|
return { orchestrators: [], diagnostics: [] };
|
|
@@ -44,28 +63,27 @@ function readDeclaredOrchestrators(root) {
|
|
|
44
63
|
contents = fs_1.default.readFileSync(file, 'utf-8');
|
|
45
64
|
}
|
|
46
65
|
catch (e) {
|
|
47
|
-
return { orchestrators: [], diagnostics: [`${file}: cannot read manifest (${e instanceof Error ? e.message : String(e)})`] };
|
|
66
|
+
return { orchestrators: [], diagnostics: [(0, text_1.sanitizeDiagnosticText)(`${file}: cannot read manifest (${e instanceof Error ? e.message : String(e)})`)] };
|
|
48
67
|
}
|
|
49
68
|
let raw;
|
|
50
69
|
try {
|
|
51
70
|
raw = JSON.parse(contents);
|
|
52
71
|
}
|
|
53
72
|
catch (e) {
|
|
54
|
-
return { orchestrators: [], diagnostics: [`${file}: manifest is not valid JSON (${e instanceof Error ? e.message : String(e)})`] };
|
|
73
|
+
return { orchestrators: [], diagnostics: [(0, text_1.sanitizeDiagnosticText)(`${file}: manifest is not valid JSON (${e instanceof Error ? e.message : String(e)})`)] };
|
|
55
74
|
}
|
|
56
75
|
const decl = raw?.orchestrator;
|
|
57
76
|
if (decl === undefined)
|
|
58
77
|
return { orchestrators: [], diagnostics: [] };
|
|
59
78
|
if (typeof decl !== 'object' || decl === null || Array.isArray(decl)) {
|
|
60
|
-
return { orchestrators: [], diagnostics: [`${file}: "orchestrator" must be an object`] };
|
|
79
|
+
return { orchestrators: [], diagnostics: [(0, text_1.sanitizeDiagnosticText)(`${file}: "orchestrator" must be an object`)] };
|
|
61
80
|
}
|
|
62
81
|
const problems = [];
|
|
63
82
|
const entries = decl;
|
|
64
83
|
for (const key of Object.keys(entries)) {
|
|
65
84
|
if (!ALLOWED_FIELDS.includes(key)) {
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// control characters, which would otherwise let a crafted key forge extra log lines.
|
|
85
|
+
// Quote keys unambiguously; sanitize the complete diagnostic below because
|
|
86
|
+
// JSON.stringify preserves Unicode line separators and C1 controls.
|
|
69
87
|
problems.push(`unknown field ${JSON.stringify(key)} — the contract admits only ${ALLOWED_FIELDS.join(', ')}`);
|
|
70
88
|
}
|
|
71
89
|
}
|
|
@@ -79,7 +97,7 @@ function readDeclaredOrchestrators(root) {
|
|
|
79
97
|
}
|
|
80
98
|
}
|
|
81
99
|
if (problems.length > 0) {
|
|
82
|
-
return { orchestrators: [], diagnostics: [`${file}: invalid "orchestrator" declaration — ${problems.join('; ')}`] };
|
|
100
|
+
return { orchestrators: [], diagnostics: [(0, text_1.sanitizeDiagnosticText)(`${file}: invalid "orchestrator" declaration — ${problems.join('; ')}`)] };
|
|
83
101
|
}
|
|
84
102
|
return {
|
|
85
103
|
orchestrators: [{
|
|
@@ -126,13 +144,19 @@ function readDeclaredOrchestrators(root) {
|
|
|
126
144
|
*/
|
|
127
145
|
function collectDeclaredOrchestrators() {
|
|
128
146
|
const declared = [];
|
|
129
|
-
const
|
|
147
|
+
const droppedNames = [];
|
|
148
|
+
const { names: availableSkillNames, diagnostics } = discoverDeclaredSkillNames();
|
|
130
149
|
const seenNames = new Set();
|
|
131
150
|
const seenSanitizedNames = new Set();
|
|
132
151
|
for (const reg of (0, registries_1.listRegistries)()) {
|
|
133
152
|
const r = readDeclaredOrchestrators(reg.contentRoot);
|
|
134
153
|
for (const orch of r.orchestrators) {
|
|
135
154
|
const file = path_1.default.join(reg.contentRoot, registries_1.REGISTRY_MANIFEST_NAME);
|
|
155
|
+
if (!availableSkillNames.has(orch.name)) {
|
|
156
|
+
droppedNames.push(orch.name);
|
|
157
|
+
diagnostics.push(`${(0, text_1.sanitizeDiagnosticText)(file)}: orchestrator declaration dropped because skill "${(0, text_1.sanitizeDiagnosticText)(orch.name)}" is not discoverable in configured safe registries`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
136
160
|
if (seenNames.has(orch.name)) {
|
|
137
161
|
diagnostics.push(`${file}: orchestrator "${(0, text_1.stripControlChars)(orch.name)}" duplicates one already declared by an earlier registry — shadowed duplicate dropped`);
|
|
138
162
|
continue;
|
|
@@ -149,7 +173,7 @@ function collectDeclaredOrchestrators() {
|
|
|
149
173
|
}
|
|
150
174
|
diagnostics.push(...r.diagnostics);
|
|
151
175
|
}
|
|
152
|
-
return { declared, diagnostics };
|
|
176
|
+
return { declared, diagnostics, droppedNames };
|
|
153
177
|
}
|
|
154
178
|
/** Recolecta declarados y emite sus diagnosticos como warnings. Punto unico usado por
|
|
155
179
|
* `InjectionOrchestrator.inputFor`/`statusInputFor` y por `commands/hooks/claude.ts`
|
package/dist/src/core/text.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.stripControlChars = stripControlChars;
|
|
4
|
+
exports.sanitizeDiagnosticText = sanitizeDiagnosticText;
|
|
4
5
|
exports.sanitizeDeclaredField = sanitizeDeclaredField;
|
|
5
6
|
// cli/src/core/text.ts
|
|
6
7
|
/** Neutraliza bytes de control C0 (incluyendo ESC `\x1b`) y DEL de texto que
|
|
@@ -16,6 +17,12 @@ function stripControlChars(text) {
|
|
|
16
17
|
// eslint-disable-next-line no-control-regex -- necesitamos matchear C0 deliberadamente
|
|
17
18
|
return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
|
18
19
|
}
|
|
20
|
+
/** Keeps an untrusted diagnostic field on one terminal line, without changing
|
|
21
|
+
* the whitespace policy for multiline public text or composed markdown. */
|
|
22
|
+
function sanitizeDiagnosticText(text) {
|
|
23
|
+
// eslint-disable-next-line no-control-regex -- remove all C0/C1 controls, including terminal escape bytes
|
|
24
|
+
return text.replace(/\r\n|[\r\n\t\u2028\u2029]/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, '');
|
|
25
|
+
}
|
|
19
26
|
/**
|
|
20
27
|
* Neutraliza marcado markdown (colapsa `\r?\n` a un espacio y quita
|
|
21
28
|
* `` ` * _ # < > ``) de contenido no confiable antes de interpolarlo en un
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const context_1 = require("../../src/commands/context");
|
|
4
|
-
const collected = (declared, diagnostics = []) => ({ declared, diagnostics });
|
|
4
|
+
const collected = (declared, diagnostics = []) => ({ declared, diagnostics, droppedNames: [] });
|
|
5
5
|
const uno = { name: 'mi-proceso', appliesWhen: 'cuando hay una tarea', terminatesTo: 'development-process' };
|
|
6
6
|
describe('awm context orchestrators', () => {
|
|
7
7
|
it('lista los orquestadores compuestos', () => {
|
|
@@ -55,6 +55,14 @@ describe('awm context orchestrators', () => {
|
|
|
55
55
|
const r = (0, context_1.runContextOrchestrators)(collected([uno]), { json: false, verify: 'de-verdad-no-existe' });
|
|
56
56
|
expect(r.code).toBe(2);
|
|
57
57
|
});
|
|
58
|
+
it('--verify rejects a dropped raw identity that sanitizes to a retained name', () => {
|
|
59
|
+
const input = {
|
|
60
|
+
...collected([{ name: 'foobar', appliesWhen: 'x', terminatesTo: 'y' }]),
|
|
61
|
+
droppedNames: ['foo_bar'],
|
|
62
|
+
};
|
|
63
|
+
expect((0, context_1.runContextOrchestrators)(input, { json: false, verify: 'foo_bar' }).code).toBe(2);
|
|
64
|
+
expect((0, context_1.runContextOrchestrators)(input, { json: false, verify: 'foobar' }).code).toBe(0);
|
|
65
|
+
});
|
|
58
66
|
it('sanea bytes de control antes de escribir a la terminal', () => {
|
|
59
67
|
const hostil = { name: 'x\x1b[31m', appliesWhen: 'w\x07', terminatesTo: 't\x00' };
|
|
60
68
|
const r = (0, context_1.runContextOrchestrators)(collected([hostil]), { json: false });
|
|
@@ -186,6 +186,8 @@ describe('installHook (happy path + merge)', () => {
|
|
|
186
186
|
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'session-start'), '#!/usr/bin/env bash\necho "{}"', { mode: 0o755 });
|
|
187
187
|
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'run-hook.cmd'), '#!/usr/bin/env bash\nexec bash "$1"', { mode: 0o755 });
|
|
188
188
|
fs_1.default.writeFileSync(path_1.default.join(regSkill, 'SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
|
|
189
|
+
fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills', 'mi-proceso'), { recursive: true });
|
|
190
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills', 'mi-proceso', 'SKILL.md'), '---\nname: mi-proceso\n---\n');
|
|
189
191
|
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'mi-proceso', appliesWhen: 'al arrancar', terminatesTo: 'development-process' } }));
|
|
190
192
|
installHook({ agent: 'claude-code', registryRoot, installMethod: 'symlink' });
|
|
191
193
|
const content = fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm', 'hooks', 'using-awm.md'), 'utf-8');
|
|
@@ -246,6 +248,8 @@ describe('installHook (happy path + merge)', () => {
|
|
|
246
248
|
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'session-start'), '#!/usr/bin/env bash\necho "{}"', { mode: 0o755 });
|
|
247
249
|
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'run-hook.cmd'), '#!/usr/bin/env bash\nexec bash "$1"', { mode: 0o755 });
|
|
248
250
|
fs_1.default.writeFileSync(path_1.default.join(regSkill, 'SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
|
|
251
|
+
fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills', 'proceso-valido'), { recursive: true });
|
|
252
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills', 'proceso-valido', 'SKILL.md'), '---\nname: proceso-valido\n---\n');
|
|
249
253
|
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'proceso-valido', appliesWhen: 'al arrancar', terminatesTo: 'development-process' } }));
|
|
250
254
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
|
|
251
255
|
let result;
|
|
@@ -101,6 +101,8 @@ describe('resyncInstalledHooks', () => {
|
|
|
101
101
|
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'session-start'), '#!/usr/bin/env bash\necho "{}"', { mode: 0o755 });
|
|
102
102
|
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'run-hook.cmd'), '#!/usr/bin/env bash\nexec bash "$1"', { mode: 0o755 });
|
|
103
103
|
fs_1.default.writeFileSync(path_1.default.join(regSkill, 'SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
|
|
104
|
+
fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills', 'mi-proceso'), { recursive: true });
|
|
105
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills', 'mi-proceso', 'SKILL.md'), '---\nname: mi-proceso\n---\n');
|
|
104
106
|
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'mi-proceso', appliesWhen: 'al arrancar', terminatesTo: 'development-process' } }));
|
|
105
107
|
const { installHook } = require('../../../src/commands/hooks/install');
|
|
106
108
|
installHook({ agent: 'claude-code', registryRoot, installMethod: 'symlink' });
|
|
@@ -255,6 +255,8 @@ describe('InjectionOrchestrator (declared orchestrators from an installed regist
|
|
|
255
255
|
const registryRoot = (0, registries_1.registryContentRoot)('declaring');
|
|
256
256
|
fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills/using-awm'), { recursive: true });
|
|
257
257
|
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills/using-awm/SKILL.md'), '---\nversion: "1.0.0"\n---\nBODY');
|
|
258
|
+
fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills/mi-proceso'), { recursive: true });
|
|
259
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills/mi-proceso/SKILL.md'), '---\nname: mi-proceso\n---\n');
|
|
258
260
|
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({
|
|
259
261
|
orchestrator: { name: 'mi-proceso', appliesWhen: 'al arrancar', terminatesTo: 'development-process' },
|
|
260
262
|
}));
|
|
@@ -60,4 +60,9 @@ describe('discovery multi-root', () => {
|
|
|
60
60
|
expect(discoverSkills([rootA, rootB]).length).toBe(1);
|
|
61
61
|
expect(discoverAgents([rootA, rootB])).toEqual([]);
|
|
62
62
|
});
|
|
63
|
+
it('rejects a real directory named SKILL.md as a nonregular artifact without blocking', () => {
|
|
64
|
+
fs_1.default.mkdirSync(path_1.default.join(rootA, 'skills', 'unsafe', 'SKILL.md'), { recursive: true });
|
|
65
|
+
const { discoverSkills } = require('../../src/core/discovery');
|
|
66
|
+
expect(() => discoverSkills([rootA])).toThrow(/SKILL\.md.*regular file/);
|
|
67
|
+
});
|
|
63
68
|
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const os_1 = __importDefault(require("os"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const discovery_1 = require("../../src/core/discovery");
|
|
10
|
+
describe('real skill parent races', () => {
|
|
11
|
+
let fixture;
|
|
12
|
+
let root;
|
|
13
|
+
let skillDir;
|
|
14
|
+
let skillFile;
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
fixture = fs_1.default.mkdtempSync(path_1.default.join(fs_1.default.realpathSync(os_1.default.tmpdir()), 'skill-parent-race-'));
|
|
17
|
+
root = path_1.default.join(fixture, 'registry');
|
|
18
|
+
skillDir = path_1.default.join(root, 'skills', 'safe');
|
|
19
|
+
skillFile = path_1.default.join(skillDir, 'SKILL.md');
|
|
20
|
+
fs_1.default.mkdirSync(skillDir, { recursive: true });
|
|
21
|
+
fs_1.default.writeFileSync(skillFile, '---\ndescription: Inside skill\n---\n');
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
jest.restoreAllMocks();
|
|
25
|
+
fs_1.default.rmSync(fixture, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
it('reads an unchanged skill through its descriptor', () => {
|
|
28
|
+
expect((0, discovery_1.discoverSkills)([root])).toEqual([
|
|
29
|
+
{ name: 'safe', path: skillDir, description: 'Inside skill' },
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
describe.each(['root', 'skills', 'skill directory'])('%s substitution', (component) => {
|
|
33
|
+
it.each(['symlink', 'directory'])('rejects a %s swapped after parent inspection before reading outside bytes', (replacement) => {
|
|
34
|
+
const parent = component === 'root' ? root : component === 'skills' ? path_1.default.dirname(skillDir) : skillDir;
|
|
35
|
+
const outside = path_1.default.join(fixture, 'outside');
|
|
36
|
+
const outsideFile = path_1.default.join(outside, path_1.default.relative(parent, skillFile));
|
|
37
|
+
fs_1.default.mkdirSync(path_1.default.dirname(outsideFile), { recursive: true });
|
|
38
|
+
fs_1.default.writeFileSync(outsideFile, '---\ndescription: Outside skill\n---\n');
|
|
39
|
+
const lstat = fs_1.default.lstatSync;
|
|
40
|
+
let swapped = false;
|
|
41
|
+
// Keep real lstat results; only schedule a real rename/symlink at the race boundary.
|
|
42
|
+
jest.spyOn(fs_1.default, 'lstatSync').mockImplementation(((...args) => {
|
|
43
|
+
const stat = lstat(...args);
|
|
44
|
+
if (args[0] === parent && !swapped) {
|
|
45
|
+
swapped = true;
|
|
46
|
+
fs_1.default.renameSync(parent, path_1.default.join(fixture, 'original-parent'));
|
|
47
|
+
if (replacement === 'symlink')
|
|
48
|
+
fs_1.default.symlinkSync(outside, parent, 'junction');
|
|
49
|
+
else
|
|
50
|
+
fs_1.default.renameSync(outside, parent);
|
|
51
|
+
}
|
|
52
|
+
return stat;
|
|
53
|
+
}));
|
|
54
|
+
const read = jest.spyOn(fs_1.default, 'readFileSync');
|
|
55
|
+
expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/symbolic link|directory identity/i);
|
|
56
|
+
expect(swapped).toBe(true);
|
|
57
|
+
expect(read.mock.calls.filter(([file]) => typeof file === 'number'
|
|
58
|
+
|| String(file).startsWith(fixture + path_1.default.sep))).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
it('rejects an observable parent symlink introduced after open and closes the descriptor without reading', () => {
|
|
62
|
+
const open = fs_1.default.openSync;
|
|
63
|
+
let descriptor;
|
|
64
|
+
jest.spyOn(fs_1.default, 'openSync').mockImplementation((...args) => {
|
|
65
|
+
descriptor = open(...args);
|
|
66
|
+
const moved = path_1.default.join(fixture, 'original-parent');
|
|
67
|
+
fs_1.default.renameSync(skillDir, moved);
|
|
68
|
+
fs_1.default.symlinkSync(moved, skillDir, 'junction');
|
|
69
|
+
return descriptor;
|
|
70
|
+
});
|
|
71
|
+
const read = jest.spyOn(fs_1.default, 'readFileSync');
|
|
72
|
+
const close = jest.spyOn(fs_1.default, 'closeSync');
|
|
73
|
+
let thrown;
|
|
74
|
+
try {
|
|
75
|
+
(0, discovery_1.discoverSkills)([root]);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
thrown = error;
|
|
79
|
+
}
|
|
80
|
+
if (process.platform === 'win32') {
|
|
81
|
+
// Windows holds the opened file's parent directory handle, so the
|
|
82
|
+
// replacement itself is denied before a symlink can be introduced.
|
|
83
|
+
// This is the same security guarantee: outside bytes cannot be read.
|
|
84
|
+
expect(thrown).toBeInstanceOf(Error);
|
|
85
|
+
expect(thrown.message).toMatch(/EPERM.*rename|rename.*EPERM/i);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
expect(thrown).toBeInstanceOf(Error);
|
|
89
|
+
expect(thrown.message).toMatch(/symbolic link/i);
|
|
90
|
+
expect(close).toHaveBeenCalledWith(descriptor);
|
|
91
|
+
}
|
|
92
|
+
expect(read.mock.calls.filter(([file]) => typeof file === 'number'
|
|
93
|
+
|| String(file).startsWith(fixture + path_1.default.sep))).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const discovery_1 = require("../../src/core/discovery");
|
|
9
|
+
jest.mock('fs');
|
|
10
|
+
const root = path_1.default.resolve('registry');
|
|
11
|
+
const skillDir = path_1.default.join(root, 'skills', 'safe');
|
|
12
|
+
const skillFile = path_1.default.join(skillDir, 'SKILL.md');
|
|
13
|
+
const content = '---\ndescription: >-\n Ordinary skill\n---\n';
|
|
14
|
+
const descriptor = 42;
|
|
15
|
+
const inspected = {
|
|
16
|
+
dev: 7n, ino: 9007199254740993n, size: BigInt(Buffer.byteLength(content)),
|
|
17
|
+
isFile: () => true, isSymbolicLink: () => false, isDirectory: () => false,
|
|
18
|
+
};
|
|
19
|
+
const directory = { dev: 7n, ino: 8n, isFile: () => false, isSymbolicLink: () => false, isDirectory: () => true };
|
|
20
|
+
describe.each([true, false])('skill descriptor safety (O_NOFOLLOW available: %s)', (noFollowAvailable) => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
jest.resetAllMocks();
|
|
23
|
+
const actual = jest.requireActual('fs');
|
|
24
|
+
jest.replaceProperty(fs_1.default, 'constants', {
|
|
25
|
+
...actual.constants,
|
|
26
|
+
O_NOFOLLOW: noFollowAvailable ? 0x20000 : undefined,
|
|
27
|
+
O_NONBLOCK: 0x800,
|
|
28
|
+
});
|
|
29
|
+
fs_1.default.existsSync.mockReturnValue(true);
|
|
30
|
+
fs_1.default.readdirSync.mockReturnValue([{ name: 'safe', isDirectory: () => true }]);
|
|
31
|
+
fs_1.default.lstatSync.mockImplementation((file) => {
|
|
32
|
+
if (file === skillFile)
|
|
33
|
+
return inspected;
|
|
34
|
+
if (file.endsWith('awm-registry.json'))
|
|
35
|
+
throw Object.assign(new Error('missing'), { code: 'ENOENT' });
|
|
36
|
+
return directory;
|
|
37
|
+
});
|
|
38
|
+
fs_1.default.openSync.mockReturnValue(descriptor);
|
|
39
|
+
fs_1.default.fstatSync.mockReturnValue(inspected);
|
|
40
|
+
fs_1.default.readFileSync.mockReturnValue(content);
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => jest.restoreAllMocks());
|
|
43
|
+
it('reads an ordinary skill from the validated descriptor and closes it', () => {
|
|
44
|
+
expect((0, discovery_1.discoverSkills)([root])).toEqual([{ name: 'safe', path: skillDir, description: 'Ordinary skill' }]);
|
|
45
|
+
expect(fs_1.default.readFileSync).toHaveBeenCalledWith(descriptor, 'utf-8');
|
|
46
|
+
expect(fs_1.default.closeSync).toHaveBeenCalledWith(descriptor);
|
|
47
|
+
const flags = fs_1.default.openSync.mock.calls[0][1];
|
|
48
|
+
expect(typeof flags).toBe('number');
|
|
49
|
+
if (noFollowAvailable)
|
|
50
|
+
expect(flags & fs_1.default.constants.O_NOFOLLOW).toBe(fs_1.default.constants.O_NOFOLLOW);
|
|
51
|
+
expect(flags & fs_1.default.constants.O_NONBLOCK).toBe(fs_1.default.constants.O_NONBLOCK);
|
|
52
|
+
});
|
|
53
|
+
it.each([
|
|
54
|
+
['another inode', { ino: inspected.ino + 1n }],
|
|
55
|
+
['another device', { dev: inspected.dev + 1n }],
|
|
56
|
+
['changed size', { size: inspected.size + 1n }],
|
|
57
|
+
['FIFO replacement', { isFile: () => false }],
|
|
58
|
+
['symlink replacement', { isFile: () => false, isSymbolicLink: () => true }],
|
|
59
|
+
['unobservable device', { dev: undefined }],
|
|
60
|
+
['unobservable inode', { ino: undefined }],
|
|
61
|
+
])('rejects %s after inspection, without reading replacement bytes', (_label, change) => {
|
|
62
|
+
fs_1.default.fstatSync.mockReturnValue({ ...inspected, ...change });
|
|
63
|
+
expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/SKILL\.md.*(identity|regular|size|symbolic)/i);
|
|
64
|
+
expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
|
|
65
|
+
expect(fs_1.default.closeSync).toHaveBeenCalledWith(descriptor);
|
|
66
|
+
});
|
|
67
|
+
it('opens nonblocking so a FIFO replacement cannot hang before fstat rejects it', () => {
|
|
68
|
+
// No FIFO is created: the open mock enforces the flag needed to avoid blocking.
|
|
69
|
+
fs_1.default.openSync.mockImplementation((_file, flags) => {
|
|
70
|
+
if (!(flags & fs_1.default.constants.O_NONBLOCK))
|
|
71
|
+
throw new Error('would block opening FIFO');
|
|
72
|
+
return descriptor;
|
|
73
|
+
});
|
|
74
|
+
fs_1.default.fstatSync.mockReturnValue({ ...inspected, isFile: () => false });
|
|
75
|
+
expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/regular file/i);
|
|
76
|
+
expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
|
|
77
|
+
expect(fs_1.default.closeSync).toHaveBeenCalledWith(descriptor);
|
|
78
|
+
});
|
|
79
|
+
it.each(['openSync', 'fstatSync', 'readFileSync'])('fails closed on %s errors', (operation) => {
|
|
80
|
+
fs_1.default[operation].mockImplementation(() => { throw new Error('replaced or unreadable'); });
|
|
81
|
+
expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/SKILL\.md.*replaced or unreadable/);
|
|
82
|
+
if (operation === 'openSync')
|
|
83
|
+
expect(fs_1.default.closeSync).not.toHaveBeenCalled();
|
|
84
|
+
else
|
|
85
|
+
expect(fs_1.default.closeSync).toHaveBeenCalledWith(descriptor);
|
|
86
|
+
if (operation !== 'readFileSync')
|
|
87
|
+
expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
it.each([root, path_1.default.join(root, 'skills'), skillDir])('rejects an observable directory symlink at %s', (linked) => {
|
|
90
|
+
const lstat = fs_1.default.lstatSync.getMockImplementation();
|
|
91
|
+
fs_1.default.lstatSync.mockImplementation((file, options) => file === linked
|
|
92
|
+
? { ...directory, isDirectory: () => false, isSymbolicLink: () => true }
|
|
93
|
+
: lstat(file, options));
|
|
94
|
+
expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/symbolic link/i);
|
|
95
|
+
expect(fs_1.default.openSync).not.toHaveBeenCalled();
|
|
96
|
+
expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
it.each(['dev', 'ino', 'size'])('fails closed when inspected %s is unobservable', (field) => {
|
|
99
|
+
const unknown = { ...inspected, [field]: undefined };
|
|
100
|
+
const lstat = fs_1.default.lstatSync.getMockImplementation();
|
|
101
|
+
fs_1.default.lstatSync.mockImplementation((file, options) => file === skillFile ? unknown : lstat(file, options));
|
|
102
|
+
fs_1.default.fstatSync.mockReturnValue(unknown);
|
|
103
|
+
expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/identity|size/i);
|
|
104
|
+
expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
it.each(['dev', 'ino'])('fails closed when parent %s is unobservable', (field) => {
|
|
107
|
+
const lstat = fs_1.default.lstatSync.getMockImplementation();
|
|
108
|
+
fs_1.default.lstatSync.mockImplementation((file, options) => file === skillDir
|
|
109
|
+
? { ...directory, [field]: undefined }
|
|
110
|
+
: lstat(file, options));
|
|
111
|
+
expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/directory identity/i);
|
|
112
|
+
expect(fs_1.default.openSync).not.toHaveBeenCalled();
|
|
113
|
+
expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -15,16 +15,30 @@ const WORKFLOWS_ROOT = CONTENT_ROOT;
|
|
|
15
15
|
// For backward-compat with mock expectations that check SKILLS_DIR-style paths:
|
|
16
16
|
const SKILLS_DIR = path_1.default.join(CONTENT_ROOT, 'skills');
|
|
17
17
|
const WORKFLOWS_DIR = path_1.default.join(CONTENT_ROOT, 'workflows');
|
|
18
|
+
const directoryStat = { dev: 1n, ino: 3n, isDirectory: () => true, isSymbolicLink: () => false };
|
|
19
|
+
const skillStat = { dev: 1n, ino: 2n, size: 0n, isFile: () => true, isSymbolicLink: () => false };
|
|
18
20
|
describe('Artifact Discovery', () => {
|
|
19
21
|
beforeEach(() => {
|
|
20
22
|
jest.clearAllMocks();
|
|
21
|
-
fs_1.default.lstatSync.mockImplementation(() => {
|
|
23
|
+
fs_1.default.lstatSync.mockImplementation((p) => {
|
|
24
|
+
if (!p.endsWith('.md') && !p.endsWith('.json'))
|
|
25
|
+
return directoryStat;
|
|
22
26
|
const error = Object.assign(new Error('missing'), { code: 'ENOENT' });
|
|
23
27
|
throw error;
|
|
24
28
|
});
|
|
29
|
+
fs_1.default.openSync.mockReturnValue(42);
|
|
30
|
+
fs_1.default.fstatSync.mockReturnValue(skillStat);
|
|
31
|
+
fs_1.default.readFileSync.mockReturnValue('');
|
|
25
32
|
});
|
|
26
33
|
describe('discoverSkills', () => {
|
|
27
34
|
it('should return a list of skill directories that contain a SKILL.md', () => {
|
|
35
|
+
fs_1.default.lstatSync.mockImplementation((p) => {
|
|
36
|
+
if (p.endsWith('SKILL.md'))
|
|
37
|
+
return skillStat;
|
|
38
|
+
if (!p.endsWith('.json'))
|
|
39
|
+
return directoryStat;
|
|
40
|
+
throw Object.assign(new Error('missing'), { code: 'ENOENT' });
|
|
41
|
+
});
|
|
28
42
|
fs_1.default.existsSync.mockImplementation((p) => {
|
|
29
43
|
if (p === SKILLS_DIR)
|
|
30
44
|
return true;
|
|
@@ -48,6 +62,19 @@ describe('Artifact Discovery', () => {
|
|
|
48
62
|
const skills = (0, discovery_1.discoverSkills)([SKILLS_ROOT]);
|
|
49
63
|
expect(skills).toEqual([]);
|
|
50
64
|
});
|
|
65
|
+
it.each(['fifo', 'symlink'])('rejects a %s SKILL.md before attempting any read', (kind) => {
|
|
66
|
+
fs_1.default.existsSync.mockReturnValue(true);
|
|
67
|
+
fs_1.default.readdirSync.mockReturnValue([{ name: 'unsafe', isDirectory: () => true }]);
|
|
68
|
+
fs_1.default.lstatSync.mockImplementation((p) => {
|
|
69
|
+
if (p.endsWith('SKILL.md'))
|
|
70
|
+
return { isFile: () => false, isSymbolicLink: () => kind === 'symlink' };
|
|
71
|
+
if (!p.endsWith('.json'))
|
|
72
|
+
return directoryStat;
|
|
73
|
+
throw Object.assign(new Error('missing'), { code: 'ENOENT' });
|
|
74
|
+
});
|
|
75
|
+
expect(() => (0, discovery_1.discoverSkills)([SKILLS_ROOT])).toThrow(/regular file|symbolic link/);
|
|
76
|
+
expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
51
78
|
it('should skip skill directories without a SKILL.md', () => {
|
|
52
79
|
fs_1.default.existsSync.mockImplementation((p) => {
|
|
53
80
|
if (p === SKILLS_DIR)
|
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
const fs_1 = __importDefault(require("fs"));
|
|
7
7
|
const path_1 = __importDefault(require("path"));
|
|
8
8
|
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const command_result_1 = require("../../src/core/command-result");
|
|
9
10
|
const orchestrators_1 = require("../../src/core/orchestrators");
|
|
10
11
|
const registries_1 = require("../../src/core/registries");
|
|
11
12
|
function registryWith(manifest) {
|
|
@@ -16,6 +17,7 @@ function registryWith(manifest) {
|
|
|
16
17
|
describe('readDeclaredOrchestrators', () => {
|
|
17
18
|
const created = [];
|
|
18
19
|
afterEach(() => {
|
|
20
|
+
jest.restoreAllMocks();
|
|
19
21
|
for (const r of created.splice(0))
|
|
20
22
|
fs_1.default.rmSync(r, { recursive: true, force: true });
|
|
21
23
|
});
|
|
@@ -176,6 +178,62 @@ describe('readDeclaredOrchestrators', () => {
|
|
|
176
178
|
expect(diagnostics[0].split('\n')).toHaveLength(1);
|
|
177
179
|
expect(diagnostics[0]).toContain(JSON.stringify('evil\nfake-warning: injected line'));
|
|
178
180
|
});
|
|
181
|
+
describe('single-line diagnostic boundary', () => {
|
|
182
|
+
const hostile = 'before\u2028\u2029\u0085\r\n\t'
|
|
183
|
+
+ String.fromCharCode(...Array.from({ length: 32 }, (_, i) => i))
|
|
184
|
+
+ String.fromCharCode(...Array.from({ length: 33 }, (_, i) => 127 + i)) + 'after';
|
|
185
|
+
it('sanitizes unknown metadata keys without accepting the declaration', () => {
|
|
186
|
+
const root = registryWith({
|
|
187
|
+
orchestrator: { name: 'x', appliesWhen: 'y', terminatesTo: 'none', [hostile]: true },
|
|
188
|
+
});
|
|
189
|
+
created.push(root);
|
|
190
|
+
const result = (0, orchestrators_1.readDeclaredOrchestrators)(root);
|
|
191
|
+
expect(result.orchestrators).toEqual([]);
|
|
192
|
+
expect(result.diagnostics).toHaveLength(1);
|
|
193
|
+
expect(result.diagnostics[0]).toContain('unknown field');
|
|
194
|
+
expect(result.diagnostics[0]).toContain('before');
|
|
195
|
+
expect(result.diagnostics[0]).toContain('after');
|
|
196
|
+
const stderrBoundary = jest.fn(command_result_1.diagnosticsToStderr);
|
|
197
|
+
stderrBoundary(result.diagnostics);
|
|
198
|
+
expect(stderrBoundary.mock.calls[0][0].join('')).not.toMatch(/[\u2028\u0085]/);
|
|
199
|
+
expect(stderrBoundary.mock.results[0].value).not.toMatch(/[\u2028\u0085]/);
|
|
200
|
+
// eslint-disable-next-line no-control-regex -- reject all terminal controls and line separators
|
|
201
|
+
expect(result.diagnostics[0]).not.toMatch(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/);
|
|
202
|
+
});
|
|
203
|
+
it.each(['inspection', 'read', 'parse', 'shape', 'fields'])('sanitizes %s diagnostics, including paths and error text', (branch) => {
|
|
204
|
+
// Mock hostile paths instead of creating filenames that are invalid on Windows.
|
|
205
|
+
const root = path_1.default.join(os_1.default.tmpdir(), hostile);
|
|
206
|
+
const error = new Error(hostile);
|
|
207
|
+
jest.spyOn(fs_1.default, 'lstatSync').mockImplementation(() => {
|
|
208
|
+
if (branch === 'inspection')
|
|
209
|
+
throw error;
|
|
210
|
+
return { isFile: () => true, isSymbolicLink: () => false };
|
|
211
|
+
});
|
|
212
|
+
jest.spyOn(fs_1.default, 'readFileSync').mockImplementation(() => {
|
|
213
|
+
if (branch === 'read')
|
|
214
|
+
throw error;
|
|
215
|
+
if (branch === 'parse')
|
|
216
|
+
return `{ ${hostile}`;
|
|
217
|
+
return JSON.stringify({ orchestrator: branch === 'shape' ? [] : {} });
|
|
218
|
+
});
|
|
219
|
+
if (branch === 'parse')
|
|
220
|
+
jest.spyOn(JSON, 'parse').mockImplementationOnce(() => { throw new SyntaxError(hostile); });
|
|
221
|
+
const result = (0, orchestrators_1.readDeclaredOrchestrators)(root);
|
|
222
|
+
expect(result.orchestrators).toEqual([]);
|
|
223
|
+
expect(result.diagnostics).toHaveLength(1);
|
|
224
|
+
expect(result.diagnostics[0]).toContain('awm-registry.json');
|
|
225
|
+
expect(result.diagnostics[0]).toContain('before');
|
|
226
|
+
expect(result.diagnostics[0]).toContain('after');
|
|
227
|
+
// eslint-disable-next-line no-control-regex -- reject all terminal controls and line separators
|
|
228
|
+
expect(result.diagnostics[0]).not.toMatch(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/);
|
|
229
|
+
});
|
|
230
|
+
it('preserves valid raw fields and structural parsing without skill discovery', () => {
|
|
231
|
+
const declaration = { name: hostile, appliesWhen: hostile, terminatesTo: hostile };
|
|
232
|
+
const root = registryWith({ orchestrator: declaration });
|
|
233
|
+
created.push(root);
|
|
234
|
+
expect((0, orchestrators_1.readDeclaredOrchestrators)(root)).toEqual({ orchestrators: [declaration], diagnostics: [] });
|
|
235
|
+
});
|
|
236
|
+
});
|
|
179
237
|
});
|
|
180
238
|
describe('collectDeclaredOrchestrators', () => {
|
|
181
239
|
// Isolates AWM_HOME per CLAUDE.md's testing rule ("ningun test puede tocar el ~/.awm
|
|
@@ -199,6 +257,11 @@ describe('collectDeclaredOrchestrators', () => {
|
|
|
199
257
|
fs_1.default.mkdirSync(root, { recursive: true });
|
|
200
258
|
fs_1.default.writeFileSync(path_1.default.join(root, 'awm-registry.json'), JSON.stringify(manifest));
|
|
201
259
|
}
|
|
260
|
+
function writeSkill(registryName, skillName) {
|
|
261
|
+
const skillDir = path_1.default.join((0, registries_1.registryContentRoot)(registryName), 'skills', skillName);
|
|
262
|
+
fs_1.default.mkdirSync(skillDir, { recursive: true });
|
|
263
|
+
fs_1.default.writeFileSync(path_1.default.join(skillDir, 'SKILL.md'), `---\nname: ${skillName}\n---\n\n# ${skillName}\n`);
|
|
264
|
+
}
|
|
202
265
|
it('dedupea por nombre entre dos registries, conservando el primero por orden de registries.json', () => {
|
|
203
266
|
(0, registries_1.writeRegistriesConfig)([
|
|
204
267
|
{ name: 'first', remote: 'unused' },
|
|
@@ -210,6 +273,7 @@ describe('collectDeclaredOrchestrators', () => {
|
|
|
210
273
|
writeManifest('second', {
|
|
211
274
|
orchestrator: { name: 'shared', appliesWhen: 'segundo', terminatesTo: 'b' },
|
|
212
275
|
});
|
|
276
|
+
writeSkill('first', 'shared');
|
|
213
277
|
const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
|
|
214
278
|
expect(declared).toHaveLength(1);
|
|
215
279
|
expect(declared[0]).toEqual({ name: 'shared', appliesWhen: 'primero', terminatesTo: 'a' });
|
|
@@ -217,23 +281,60 @@ describe('collectDeclaredOrchestrators', () => {
|
|
|
217
281
|
expect(diagnostics[0]).toMatch(/shared/);
|
|
218
282
|
expect(diagnostics[0]).toMatch(/duplicate|shadow/i);
|
|
219
283
|
});
|
|
220
|
-
it('
|
|
221
|
-
(0, registries_1.writeRegistriesConfig)([
|
|
222
|
-
|
|
223
|
-
{ name: 'second', remote: 'unused' },
|
|
224
|
-
]);
|
|
225
|
-
const nombreHostil = 'shared\x1b[31m';
|
|
284
|
+
it('keeps missing-skill diagnostics single-line with hostile metadata and portable paths', () => {
|
|
285
|
+
(0, registries_1.writeRegistriesConfig)([{ name: 'first', remote: 'unused' }]);
|
|
286
|
+
const nombreHostil = 'shared\r\nforged\t\x1b[31m\x00\x7f\x85\x9b\u2028\u2029';
|
|
226
287
|
writeManifest('first', {
|
|
227
288
|
orchestrator: { name: nombreHostil, appliesWhen: 'primero', terminatesTo: 'a' },
|
|
228
289
|
});
|
|
229
|
-
|
|
230
|
-
orchestrator: { name: nombreHostil, appliesWhen: 'segundo', terminatesTo: 'b' },
|
|
231
|
-
});
|
|
290
|
+
writeSkill('first', 'real-skill');
|
|
232
291
|
const { diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
|
|
233
292
|
expect(diagnostics).toHaveLength(1);
|
|
234
|
-
// eslint-disable-next-line no-control-regex --
|
|
235
|
-
expect(diagnostics[0]).not.toMatch(/[\x00-\
|
|
236
|
-
expect(diagnostics[0]).toContain('shared
|
|
293
|
+
// eslint-disable-next-line no-control-regex -- diagnostic must contain no terminal controls or line separators
|
|
294
|
+
expect(diagnostics[0]).not.toMatch(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/);
|
|
295
|
+
expect(diagnostics[0]).toContain('shared');
|
|
296
|
+
expect(diagnostics[0]).toContain('forged');
|
|
297
|
+
expect(diagnostics[0]).toContain('declaration dropped');
|
|
298
|
+
});
|
|
299
|
+
it('keeps discovery diagnostics single-line for hostile override metadata', () => {
|
|
300
|
+
(0, registries_1.writeRegistriesConfig)([{ name: 'hostile', remote: 'unused' }]);
|
|
301
|
+
writeManifest('hostile', { overrides: ['../forged\r\nwarning:\t\x1b[31m\x85\u2028'] });
|
|
302
|
+
writeSkill('hostile', 'real-skill');
|
|
303
|
+
const { diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
|
|
304
|
+
expect(diagnostics).toHaveLength(1);
|
|
305
|
+
expect(diagnostics[0]).toContain('discovery unavailable');
|
|
306
|
+
expect(diagnostics[0]).toContain('forged');
|
|
307
|
+
// eslint-disable-next-line no-control-regex -- diagnostic must contain no terminal controls or line separators
|
|
308
|
+
expect(diagnostics[0]).not.toMatch(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/);
|
|
309
|
+
});
|
|
310
|
+
it('excludes a later colliding root without losing earlier or subsequent healthy roots', () => {
|
|
311
|
+
(0, registries_1.writeRegistriesConfig)(['first', 'collision', 'last'].map((name) => ({ name, remote: 'unused' })));
|
|
312
|
+
for (const name of ['first', 'collision', 'last']) {
|
|
313
|
+
writeManifest(name, { orchestrator: { name: `${name}-process`, appliesWhen: 'x', terminatesTo: 'none' } });
|
|
314
|
+
writeSkill(name, `${name}-process`);
|
|
315
|
+
}
|
|
316
|
+
writeSkill('first', 'shared');
|
|
317
|
+
writeSkill('collision', 'shared');
|
|
318
|
+
// This name belongs only to the excluded root and must not poison later admission.
|
|
319
|
+
writeSkill('collision', 'last-process');
|
|
320
|
+
const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
|
|
321
|
+
expect(declared.map((d) => d.name)).toEqual(['first-process', 'last-process']);
|
|
322
|
+
expect(diagnostics).toHaveLength(2);
|
|
323
|
+
expect(diagnostics[0]).toMatch(/collision.*discovery unavailable.*Artifact name collision/);
|
|
324
|
+
expect(diagnostics[1]).toMatch(/collision-process.*not discoverable/);
|
|
325
|
+
});
|
|
326
|
+
it('admits a later root with a declared override', () => {
|
|
327
|
+
(0, registries_1.writeRegistriesConfig)(['first', 'override'].map((name) => ({ name, remote: 'unused' })));
|
|
328
|
+
writeSkill('first', 'shared');
|
|
329
|
+
writeSkill('override', 'shared');
|
|
330
|
+
writeSkill('override', 'override-process');
|
|
331
|
+
writeManifest('override', {
|
|
332
|
+
overrides: ['shared'],
|
|
333
|
+
orchestrator: { name: 'override-process', appliesWhen: 'x', terminatesTo: 'none' },
|
|
334
|
+
});
|
|
335
|
+
const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
|
|
336
|
+
expect(declared.map((d) => d.name)).toEqual(['override-process']);
|
|
337
|
+
expect(diagnostics).toEqual([]);
|
|
237
338
|
});
|
|
238
339
|
it('emite un diagnostico de colision post-saneo entre nombres crudos distintos, sin descartar ninguno', () => {
|
|
239
340
|
(0, registries_1.writeRegistriesConfig)([
|
|
@@ -244,12 +345,14 @@ describe('collectDeclaredOrchestrators', () => {
|
|
|
244
345
|
orchestrator: { name: 'foo_bar', appliesWhen: 'x', terminatesTo: 'a' },
|
|
245
346
|
});
|
|
246
347
|
writeManifest('second', {
|
|
247
|
-
orchestrator: { name: '
|
|
348
|
+
orchestrator: { name: 'foobar', appliesWhen: 'y', terminatesTo: 'b' },
|
|
248
349
|
});
|
|
350
|
+
writeSkill('first', 'foo_bar');
|
|
351
|
+
writeSkill('second', 'foobar');
|
|
249
352
|
const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
|
|
250
353
|
// Ambas declaraciones son genuinamente distintas (nombre crudo distinto) — ninguna se descarta.
|
|
251
354
|
expect(declared).toHaveLength(2);
|
|
252
|
-
expect(declared.map((d) => d.name).sort()).toEqual(['
|
|
355
|
+
expect(declared.map((d) => d.name).sort()).toEqual(['foo_bar', 'foobar']);
|
|
253
356
|
// Pero se advierte de la colision post-saneo (ambas renderizan como "foobar").
|
|
254
357
|
expect(diagnostics).toHaveLength(1);
|
|
255
358
|
expect(diagnostics[0]).toMatch(/foobar/);
|
|
@@ -266,9 +369,63 @@ describe('collectDeclaredOrchestrators', () => {
|
|
|
266
369
|
writeManifest('second', {
|
|
267
370
|
orchestrator: { name: 'dos', appliesWhen: 'y', terminatesTo: 'b' },
|
|
268
371
|
});
|
|
372
|
+
writeSkill('first', 'uno');
|
|
373
|
+
writeSkill('second', 'dos');
|
|
269
374
|
const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
|
|
270
375
|
expect(declared).toHaveLength(2);
|
|
271
376
|
expect(declared.map((d) => d.name).sort()).toEqual(['dos', 'uno']);
|
|
272
377
|
expect(diagnostics).toEqual([]);
|
|
273
378
|
});
|
|
379
|
+
it('omite una declaracion cuyo skill no existe y la diagnostica', () => {
|
|
380
|
+
(0, registries_1.writeRegistriesConfig)([{ name: 'phantom', remote: 'unused' }]);
|
|
381
|
+
writeManifest('phantom', {
|
|
382
|
+
orchestrator: { name: 'phantom-process', appliesWhen: 'x', terminatesTo: 'none' },
|
|
383
|
+
});
|
|
384
|
+
writeSkill('phantom', 'real-skill');
|
|
385
|
+
const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
|
|
386
|
+
expect(declared).toEqual([]);
|
|
387
|
+
expect(diagnostics).toHaveLength(1);
|
|
388
|
+
expect(diagnostics[0]).toMatch(/phantom-process/);
|
|
389
|
+
expect(diagnostics[0]).toMatch(/declaration dropped|skill/i);
|
|
390
|
+
});
|
|
391
|
+
it('resuelve una declaracion contra un skill de otro registry configurado', () => {
|
|
392
|
+
(0, registries_1.writeRegistriesConfig)([
|
|
393
|
+
{ name: 'declarations', remote: 'unused' },
|
|
394
|
+
{ name: 'skills', remote: 'unused' },
|
|
395
|
+
]);
|
|
396
|
+
writeManifest('declarations', {
|
|
397
|
+
orchestrator: { name: 'cross-registry', appliesWhen: 'x', terminatesTo: 'none' },
|
|
398
|
+
});
|
|
399
|
+
writeSkill('skills', 'cross-registry');
|
|
400
|
+
const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
|
|
401
|
+
expect(declared).toEqual([{ name: 'cross-registry', appliesWhen: 'x', terminatesTo: 'none' }]);
|
|
402
|
+
expect(diagnostics).toEqual([]);
|
|
403
|
+
});
|
|
404
|
+
it('diagnostica un root cuyo discovery falla y conserva una declaracion sana', () => {
|
|
405
|
+
(0, registries_1.writeRegistriesConfig)([
|
|
406
|
+
{ name: 'broken', remote: 'unused' },
|
|
407
|
+
{ name: 'healthy', remote: 'unused' },
|
|
408
|
+
]);
|
|
409
|
+
writeManifest('broken', {
|
|
410
|
+
orchestrator: { name: 'broken-process', appliesWhen: 'x', terminatesTo: 'none' },
|
|
411
|
+
});
|
|
412
|
+
fs_1.default.writeFileSync(path_1.default.join((0, registries_1.registryContentRoot)('broken'), 'skills'), 'not a directory');
|
|
413
|
+
writeManifest('healthy', {
|
|
414
|
+
orchestrator: { name: 'healthy-process', appliesWhen: 'x', terminatesTo: 'none' },
|
|
415
|
+
});
|
|
416
|
+
writeSkill('healthy', 'healthy-process');
|
|
417
|
+
const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
|
|
418
|
+
expect(declared).toEqual([{ name: 'healthy-process', appliesWhen: 'x', terminatesTo: 'none' }]);
|
|
419
|
+
expect(diagnostics.join('\n')).toMatch(/broken.*discovery unavailable/i);
|
|
420
|
+
});
|
|
421
|
+
it('conserva terminatesTo aunque no corresponda a un skill descubierto', () => {
|
|
422
|
+
(0, registries_1.writeRegistriesConfig)([{ name: 'registry', remote: 'unused' }]);
|
|
423
|
+
writeManifest('registry', {
|
|
424
|
+
orchestrator: { name: 'entry-process', appliesWhen: 'x', terminatesTo: 'missing-successor' },
|
|
425
|
+
});
|
|
426
|
+
writeSkill('registry', 'entry-process');
|
|
427
|
+
const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
|
|
428
|
+
expect(declared).toEqual([{ name: 'entry-process', appliesWhen: 'x', terminatesTo: 'missing-successor' }]);
|
|
429
|
+
expect(diagnostics).toEqual([]);
|
|
430
|
+
});
|
|
274
431
|
});
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const text_1 = require("../../src/core/text");
|
|
4
|
+
describe('sanitizeDiagnosticText', () => {
|
|
5
|
+
it.each([
|
|
6
|
+
['a\r\nb\rc\nd\te', 'a b c d e'],
|
|
7
|
+
['a\x00\x07\x1b[31m\x7f\x85\x9bz', 'a[31mz'],
|
|
8
|
+
['a\u2028b\u2029c', 'a b c'],
|
|
9
|
+
['plain *markdown* — café', 'plain *markdown* — café'],
|
|
10
|
+
])('sanitizes a single diagnostic field: %j', (input, expected) => {
|
|
11
|
+
expect((0, text_1.sanitizeDiagnosticText)(input)).toBe(expected);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
4
14
|
describe('stripControlChars', () => {
|
|
5
15
|
it('elimina ESC y demas bytes de control C0', () => {
|
|
6
16
|
expect((0, text_1.stripControlChars)('a\x1b[31mb\x00c\x07d')).toBe('a[31mbcd');
|
|
@@ -14,13 +14,13 @@ function seedRegistry() {
|
|
|
14
14
|
const home = fs_1.default.realpathSync(fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-ctx-e2e-')));
|
|
15
15
|
const awmHome = path_1.default.join(home, '.awm');
|
|
16
16
|
const root = path_1.default.join(awmHome, 'registries', 'test-registry');
|
|
17
|
-
fs_1.default.mkdirSync(path_1.default.join(root, 'skills', '
|
|
17
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'skills', 'ejemplo-proceso'), { recursive: true });
|
|
18
18
|
fs_1.default.writeFileSync(path_1.default.join(awmHome, 'registries.json'), JSON.stringify([{ name: 'test-registry', remote: 'https://example.invalid/test.git' }]));
|
|
19
19
|
fs_1.default.writeFileSync(path_1.default.join(root, 'awm-registry.json'), JSON.stringify({
|
|
20
20
|
minCliVersion: '8.5.0',
|
|
21
21
|
orchestrator: { name: 'ejemplo-proceso', appliesWhen: 'cuando hay una tarea sin plan', terminatesTo: 'development-process' },
|
|
22
22
|
}));
|
|
23
|
-
fs_1.default.writeFileSync(path_1.default.join(root, 'skills', '
|
|
23
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'skills', 'ejemplo-proceso', 'SKILL.md'), '---\nname: ejemplo-proceso\nversion: "1.0.0"\n---\n\n# ejemplo-proceso\n');
|
|
24
24
|
return home;
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
@@ -36,7 +36,7 @@ function seedRealisticRegistry() {
|
|
|
36
36
|
const home = fs_1.default.realpathSync(fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-ctx-e2e-real-')));
|
|
37
37
|
const awmHome = path_1.default.join(home, '.awm');
|
|
38
38
|
const root = path_1.default.join(awmHome, 'registries', 'test-registry');
|
|
39
|
-
fs_1.default.mkdirSync(path_1.default.join(root, 'skills', '
|
|
39
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'skills', 'task_capture_process'), { recursive: true });
|
|
40
40
|
fs_1.default.writeFileSync(path_1.default.join(awmHome, 'registries.json'), JSON.stringify([{ name: 'test-registry', remote: 'https://example.invalid/test.git' }]));
|
|
41
41
|
fs_1.default.writeFileSync(path_1.default.join(root, 'awm-registry.json'), JSON.stringify({
|
|
42
42
|
minCliVersion: '8.5.0',
|
|
@@ -46,7 +46,7 @@ function seedRealisticRegistry() {
|
|
|
46
46
|
terminatesTo: 'development-process',
|
|
47
47
|
},
|
|
48
48
|
}));
|
|
49
|
-
fs_1.default.writeFileSync(path_1.default.join(root, 'skills', '
|
|
49
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'skills', 'task_capture_process', 'SKILL.md'), '---\nname: task_capture_process\nversion: "1.0.0"\n---\n\n# task_capture_process\n');
|
|
50
50
|
return home;
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
@@ -63,7 +63,7 @@ function seedMixedRegistries() {
|
|
|
63
63
|
const awmHome = path_1.default.join(home, '.awm');
|
|
64
64
|
const sanoRoot = path_1.default.join(awmHome, 'registries', 'sano-registry');
|
|
65
65
|
const rotoRoot = path_1.default.join(awmHome, 'registries', 'roto-registry');
|
|
66
|
-
fs_1.default.mkdirSync(path_1.default.join(sanoRoot, 'skills', '
|
|
66
|
+
fs_1.default.mkdirSync(path_1.default.join(sanoRoot, 'skills', 'proceso-sano'), { recursive: true });
|
|
67
67
|
fs_1.default.mkdirSync(path_1.default.join(rotoRoot, 'skills', 'using-awm'), { recursive: true });
|
|
68
68
|
fs_1.default.writeFileSync(path_1.default.join(awmHome, 'registries.json'), JSON.stringify([
|
|
69
69
|
{ name: 'sano-registry', remote: 'https://example.invalid/sano.git' },
|
|
@@ -79,8 +79,20 @@ function seedMixedRegistries() {
|
|
|
79
79
|
minCliVersion: '8.5.0',
|
|
80
80
|
orchestrator: { name: 'proceso-roto', appliesWhen: 'cuando algo pasa' },
|
|
81
81
|
}));
|
|
82
|
-
fs_1.default.writeFileSync(path_1.default.join(sanoRoot, 'skills', '
|
|
83
|
-
|
|
82
|
+
fs_1.default.writeFileSync(path_1.default.join(sanoRoot, 'skills', 'proceso-sano', 'SKILL.md'), '---\nname: proceso-sano\nversion: "1.0.0"\n---\n\n# proceso-sano\n');
|
|
83
|
+
return home;
|
|
84
|
+
}
|
|
85
|
+
function seedPhantomRegistry() {
|
|
86
|
+
const home = fs_1.default.realpathSync(fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-ctx-e2e-phantom-')));
|
|
87
|
+
const awmHome = path_1.default.join(home, '.awm');
|
|
88
|
+
const root = path_1.default.join(awmHome, 'registries', 'test-registry');
|
|
89
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'skills', 'real-skill'), { recursive: true });
|
|
90
|
+
fs_1.default.writeFileSync(path_1.default.join(awmHome, 'registries.json'), JSON.stringify([{ name: 'test-registry', remote: 'https://example.invalid/test.git' }]));
|
|
91
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'awm-registry.json'), JSON.stringify({
|
|
92
|
+
minCliVersion: '8.5.0',
|
|
93
|
+
orchestrator: { name: 'phantom-process', appliesWhen: 'cuando hay una tarea sin plan', terminatesTo: 'development-process' },
|
|
94
|
+
}));
|
|
95
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'skills', 'real-skill', 'SKILL.md'), '---\nname: real-skill\nversion: "1.0.0"\n---\n\n# real-skill\n');
|
|
84
96
|
return home;
|
|
85
97
|
}
|
|
86
98
|
function run(home, ...args) {
|
|
@@ -142,3 +154,41 @@ describe('awm context orchestrators -- diagnosticos + sanos mezclados (binario r
|
|
|
142
154
|
expect(r.stderr).toContain('terminatesTo');
|
|
143
155
|
});
|
|
144
156
|
});
|
|
157
|
+
describe('awm context orchestrators -- declaracion phantom (binario real)', () => {
|
|
158
|
+
let home;
|
|
159
|
+
beforeAll(() => { home = seedPhantomRegistry(); });
|
|
160
|
+
afterAll(() => fs_1.default.rmSync(home, { recursive: true, force: true }));
|
|
161
|
+
it('--verify devuelve 2 y diagnostica una declaracion sin skill resoluble', () => {
|
|
162
|
+
const r = run(home, 'context', 'orchestrators', '--verify', 'phantom-process');
|
|
163
|
+
expect(r.status).toBe(2);
|
|
164
|
+
expect(r.stderr).toMatch(/phantom-process/);
|
|
165
|
+
expect(r.stderr).toMatch(/declaration dropped|skill/i);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
describe('awm context orchestrators -- dropped sanitized collision (real binary)', () => {
|
|
169
|
+
let home;
|
|
170
|
+
beforeAll(() => {
|
|
171
|
+
home = seedPhantomRegistry();
|
|
172
|
+
const awmHome = path_1.default.join(home, '.awm');
|
|
173
|
+
const phantomRoot = path_1.default.join(awmHome, 'registries', 'test-registry');
|
|
174
|
+
const retainedRoot = path_1.default.join(awmHome, 'registries', 'retained');
|
|
175
|
+
fs_1.default.writeFileSync(path_1.default.join(awmHome, 'registries.json'), JSON.stringify([
|
|
176
|
+
{ name: 'test-registry', remote: 'unused' }, { name: 'retained', remote: 'unused' },
|
|
177
|
+
]));
|
|
178
|
+
fs_1.default.writeFileSync(path_1.default.join(phantomRoot, 'awm-registry.json'), JSON.stringify({
|
|
179
|
+
orchestrator: { name: 'foo_bar', appliesWhen: 'x', terminatesTo: 'none' },
|
|
180
|
+
}));
|
|
181
|
+
fs_1.default.mkdirSync(path_1.default.join(retainedRoot, 'skills', 'foobar'), { recursive: true });
|
|
182
|
+
fs_1.default.writeFileSync(path_1.default.join(retainedRoot, 'skills', 'foobar', 'SKILL.md'), '# foobar\n');
|
|
183
|
+
fs_1.default.writeFileSync(path_1.default.join(retainedRoot, 'awm-registry.json'), JSON.stringify({
|
|
184
|
+
orchestrator: { name: 'foobar', appliesWhen: 'x', terminatesTo: 'none' },
|
|
185
|
+
}));
|
|
186
|
+
});
|
|
187
|
+
afterAll(() => fs_1.default.rmSync(home, { recursive: true, force: true }));
|
|
188
|
+
it('verifies the retained raw name and rejects the dropped raw name', () => {
|
|
189
|
+
expect(run(home, 'context', 'orchestrators', '--verify', 'foobar').status).toBe(0);
|
|
190
|
+
const dropped = run(home, 'context', 'orchestrators', '--verify', 'foo_bar');
|
|
191
|
+
expect(dropped.status).toBe(2);
|
|
192
|
+
expect(dropped.stderr).toContain('declaration dropped');
|
|
193
|
+
});
|
|
194
|
+
});
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|