agentic-workflow-manager 3.13.4 → 3.13.6

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.
Files changed (90) hide show
  1. package/dist/src/commands/add.js +5 -0
  2. package/dist/src/commands/doctor.js +1 -0
  3. package/dist/src/commands/hooks/claude.js +15 -2
  4. package/dist/src/commands/hooks/shared.js +6 -1
  5. package/dist/src/commands/init.js +1 -0
  6. package/dist/src/commands/pin.js +24 -4
  7. package/dist/src/commands/preflight/checks.js +3 -2
  8. package/dist/src/commands/registry/add.js +1 -1
  9. package/dist/src/commands/sensors/index.js +7 -0
  10. package/dist/src/commands/sensors/init.js +48 -7
  11. package/dist/src/commands/sensors/install.js +20 -11
  12. package/dist/src/commands/sensors/run.js +31 -29
  13. package/dist/src/commands/sync.js +29 -0
  14. package/dist/src/commands/watch/index.js +11 -1
  15. package/dist/src/core/artifact-name.js +80 -0
  16. package/dist/src/core/bundle-install.js +55 -12
  17. package/dist/src/core/bundles.js +72 -13
  18. package/dist/src/core/context/regenerate.js +21 -4
  19. package/dist/src/core/context/strategies/codex-agents.js +18 -2
  20. package/dist/src/core/diagnostics/checks.js +26 -2
  21. package/dist/src/core/diagnostics/context.js +91 -25
  22. package/dist/src/core/diagnostics/provider-checks.js +62 -14
  23. package/dist/src/core/discovery.js +15 -23
  24. package/dist/src/core/export/transform.js +30 -25
  25. package/dist/src/core/frontmatter.js +223 -0
  26. package/dist/src/core/init/mutation-targets.js +11 -0
  27. package/dist/src/core/init/steps.js +70 -19
  28. package/dist/src/core/install-planner.js +62 -17
  29. package/dist/src/core/install-transaction.js +13 -1
  30. package/dist/src/core/journal/adapter.js +10 -1
  31. package/dist/src/core/paths.js +61 -8
  32. package/dist/src/core/profile.js +12 -0
  33. package/dist/src/core/provider-artifacts.js +38 -13
  34. package/dist/src/core/provider-version.js +12 -6
  35. package/dist/src/core/registries.js +40 -19
  36. package/dist/src/core/renderers/canonical-agent.js +33 -2
  37. package/dist/src/core/renderers/registry.js +37 -0
  38. package/dist/src/core/renderers/skill-source.js +11 -19
  39. package/dist/src/core/skill-integrity.js +71 -10
  40. package/dist/src/core/update-check.js +14 -2
  41. package/dist/src/core/versioning.js +17 -2
  42. package/dist/src/index.js +31 -23
  43. package/dist/src/providers/index.js +7 -2
  44. package/dist/src/ui/text.js +10 -2
  45. package/dist/src/utils/config.js +7 -8
  46. package/dist/tests/commands/doctor.test.js +6 -0
  47. package/dist/tests/commands/pin.test.js +29 -2
  48. package/dist/tests/commands/preflight/preflight.test.js +31 -17
  49. package/dist/tests/commands/registry/install-bundles.test.js +6 -6
  50. package/dist/tests/commands/sensors/init-pack-unavailable.test.js +96 -0
  51. package/dist/tests/commands/sensors/install-settings-safety.test.js +87 -0
  52. package/dist/tests/commands/sensors/run-is-read-only.test.js +0 -0
  53. package/dist/tests/commands/sensors/run.test.js +24 -51
  54. package/dist/tests/commands/sensors/status-windows.test.js +39 -22
  55. package/dist/tests/commands/sensors/status.test.js +27 -12
  56. package/dist/tests/core/artifact-name-containment.test.js +86 -0
  57. package/dist/tests/core/context/agents-md-single-slot.test.js +69 -0
  58. package/dist/tests/core/context/regenerate.test.js +39 -8
  59. package/dist/tests/core/diagnostics/checks.test.js +14 -6
  60. package/dist/tests/core/diagnostics/provider-tier.test.js +3 -3
  61. package/dist/tests/core/diagnostics/rendered-artifact-visibility.test.js +69 -0
  62. package/dist/tests/core/discovery.test.js +10 -1
  63. package/dist/tests/core/export/transform.test.js +110 -21
  64. package/dist/tests/core/frontmatter-description-vs-yaml.test.js +118 -0
  65. package/dist/tests/core/init/all-baseline-bundles.test.js +64 -0
  66. package/dist/tests/core/init/context-injection-no-project.test.js +117 -0
  67. package/dist/tests/core/init/orchestrator.test.js +1 -1
  68. package/dist/tests/core/init/steps-registry-sync.test.js +1 -1
  69. package/dist/tests/core/init/steps.test.js +11 -5
  70. package/dist/tests/core/path-resolution-no-shell.test.js +136 -0
  71. package/dist/tests/core/paths.test.js +7 -34
  72. package/dist/tests/core/project-skill-links.test.js +159 -0
  73. package/dist/tests/core/provider-artifacts.test.js +66 -14
  74. package/dist/tests/core/provider-capability-guards.test.js +92 -0
  75. package/dist/tests/core/reconciliation.test.js +15 -15
  76. package/dist/tests/core/registry-manifest.test.js +1 -1
  77. package/dist/tests/core/renderers/canonical-agent.test.js +36 -0
  78. package/dist/tests/core/renderers/cursor-mdc.test.js +37 -7
  79. package/dist/tests/core/renderers/skill-source-block-scalar.test.js +117 -0
  80. package/dist/tests/core/semver-fails-closed.test.js +30 -0
  81. package/dist/tests/core/skill-integrity.test.js +10 -10
  82. package/dist/tests/core/skill-repair-safety.test.js +92 -0
  83. package/dist/tests/core/sync-profile-atomicity.test.js +122 -0
  84. package/dist/tests/integration/copilot-init-isolated.test.js +2 -2
  85. package/dist/tests/providers/index.test.js +1 -1
  86. package/dist/tests/structural/renderer-table-is-single-source.test.js +95 -0
  87. package/dist/tests/structural/symlink-type-is-explicit.test.js +73 -0
  88. package/dist/tests/ui/text.test.js +9 -0
  89. package/dist/tests/utils/registry-view-overrides.test.js +2 -2
  90. package/package.json +6 -1
@@ -0,0 +1,118 @@
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
+ // Test DIFERENCIAL: readFrontmatterDescription (parser a mano, cero deps de
7
+ // runtime) contra js-yaml (parser YAML real, devDependency solo de test).
8
+ //
9
+ // Por que existe: el repo parsea el frontmatter a mano a proposito (ver
10
+ // core/export/transform.ts — "sin parser YAML a proposito, YAGNI, cero deps").
11
+ // Esa decision es sostenible SOLO si algo verifica que la implementacion a
12
+ // mano coincide con la semantica YAML real; si no, cada forma valida que el
13
+ // parser casero no contempla es un bug esperando a un skill que la use. Ya
14
+ // paso dos veces: (1) un block scalar (`description: >-`) se colapsaba a
15
+ // vacio, rompiendo `awm add -a cursor|copilot` y abortando `awm export`
16
+ // entero contra un skill real del registry; (2) este mismo test diferencial
17
+ // descubrio que un escalar single-quoted no deshacia el escape YAML de `'`
18
+ // (duplicado: `''`), devolviendo "it''s" en vez de "it's".
19
+ //
20
+ // js-yaml es devDependency: NO entra en el bundle que instalan los usuarios.
21
+ // La regla de cero dependencias de runtime queda intacta — de hecho este test
22
+ // es lo que la hace defendible.
23
+ const js_yaml_1 = __importDefault(require("js-yaml"));
24
+ const discovery_1 = require("../../src/core/discovery");
25
+ /** Cada caso es un bloque de frontmatter cuyo `description` debe resolverse
26
+ * IGUAL que como lo hace un parser YAML real. Sin valores esperados
27
+ * hardcodeados a proposito: la referencia es js-yaml, asi que el test no
28
+ * puede "envejecer" hacia una expectativa equivocada. */
29
+ const CASES = [
30
+ ['folded (>)', 'description: >\n a\n b'],
31
+ ['folded strip (>-)', 'description: >-\n a\n b'],
32
+ ['folded keep (>+)', 'description: >+\n a\n b'],
33
+ ['literal (|)', 'description: |\n a\n b'],
34
+ ['literal strip (|-)', 'description: |-\n a\n b'],
35
+ ['folded con parrafos', 'description: >-\n a\n\n b'],
36
+ ['literal con parrafos', 'description: |-\n a\n\n b'],
37
+ ['bloque seguido de otra clave', 'description: >-\n solo\nname: x'],
38
+ ['bloque con indent 4', 'description: >-\n a\n b'],
39
+ ['bloque con indent explicito', 'description: >2\n a\n b'],
40
+ ['bloque de una sola linea', 'description: >-\n sola'],
41
+ ['bloque con colon adentro', 'description: >-\n clave: valor'],
42
+ ['bloque con hash adentro', 'description: >-\n a #no-es-comentario'],
43
+ ['plain', 'description: hola mundo'],
44
+ ['plain con guion', 'description: usa - guiones'],
45
+ ['double-quoted con colon', 'description: "hola: mundo"'],
46
+ ['double-quoted con comilla escapada', 'description: "dice \\"hola\\""'],
47
+ ['double-quoted con \\n', 'description: "a\\nb"'],
48
+ ['double-quoted con backslash', 'description: "c:\\\\\\\\ruta"'],
49
+ ['single-quoted con apostrofe escapado', "description: 'it''s aqui'"],
50
+ ['single-quoted con doble apostrofe', "description: 'a''''b'"],
51
+ ['single-quoted con colon', "description: 'clave: valor'"],
52
+ ['double-quoted con em-dash', 'description: "em — dash"'],
53
+ // Casos que una revision adversarial encontro y los tests a mano no cubrian.
54
+ ['folded con lineas MAS indentadas (no se pliegan)', 'description: >-\n intro\n - item uno\n - item dos\n outro'],
55
+ ['folded con indentacion irregular', 'description: >\n foo\n bar\n baz'],
56
+ ['indicador con comentario final', 'description: >- # nota al margen\n el texto real'],
57
+ ['literal con comentario final', 'description: |- # nota\n linea'],
58
+ ['clave description indentada NO gana sobre la real', 'metadata:\n description: nota interna\ndescription: la verdadera'],
59
+ ['description dentro del contenido de otro bloque', 'example: |\n description: falsa\ndescription: la verdadera'],
60
+ ['plain con comentario final', 'description: hola mundo # un comentario'],
61
+ ['plain con hash pegado (NO es comentario)', 'description: usar C# y F#'],
62
+ ['plain multilinea (se pliega como folded)', 'description: primera\n segunda\n tercera'],
63
+ ['quoted con hash adentro (literal)', 'description: "tiene # adentro"'],
64
+ // Segunda tanda de revision adversarial: espacios, blancos consecutivos y
65
+ // escalares entrecomillados que cierran en una linea posterior.
66
+ ['blancos consecutivos en folded', 'description: >-\n a\n\n\n b'],
67
+ ['espacios finales dentro de un folded', 'description: >-\n a \n b'],
68
+ ['linea solo-espacios dentro de un folded', 'description: >-\n a\n \n b'],
69
+ ['double-quoted multilinea', 'description: "hola\n mundo"'],
70
+ ['single-quoted multilinea', "description: 'hola\n mundo'"],
71
+ ['double-quoted con comentario final', 'description: "x" # comentario'],
72
+ ['single-quoted con comentario final', "description: 'y' # comentario"],
73
+ ['single-quoted con apostrofe y comentario', "description: 'it''s aqui' # nota"],
74
+ ];
75
+ /** Formas que YAML RECHAZA: no hay valor correcto que devolver, y devolver el
76
+ * indicador crudo como si fuera la descripcion seria peor que no devolver
77
+ * nada (se publicaria en el artefacto). El contrato aca es '' — que hace
78
+ * fallar fuerte a parseSkillSource y claudeAiTransform. */
79
+ const INVALID_CASES = [
80
+ ['indicador con indent 0', 'description: >0\n a'],
81
+ ['indicador con indent de 2 digitos', 'description: >12\n a'],
82
+ ['indicador con basura pegada', 'description: >-basura\n a'],
83
+ ['indicador con comentario sin espacio', 'description: >-#c\n a'],
84
+ ];
85
+ describe('readFrontmatterDescription coincide con un parser YAML real (js-yaml)', () => {
86
+ it.each(CASES)('%s', (_name, frontmatter) => {
87
+ const parsed = js_yaml_1.default.load(frontmatter);
88
+ // Guard del propio fixture: si js-yaml no lo lee como string, el caso
89
+ // esta mal escrito y el test seria vacuo — que falle fuerte.
90
+ expect(typeof parsed.description).toBe('string');
91
+ const expected = parsed.description.trim();
92
+ expect((0, discovery_1.readFrontmatterDescription)(frontmatter)).toBe(expected);
93
+ });
94
+ it.each(INVALID_CASES)('%s: YAML lo rechaza, nosotros devolvemos vacio (nunca el indicador crudo)', (_name, frontmatter) => {
95
+ // Guard del fixture: si js-yaml LO ACEPTARA, el caso esta mal clasificado
96
+ // y este test seria vacuo — que falle fuerte.
97
+ expect(() => js_yaml_1.default.load(frontmatter)).toThrow();
98
+ expect((0, discovery_1.readFrontmatterDescription)(frontmatter)).toBe('');
99
+ });
100
+ it('resuelve el SKILL.md real del registry igual que js-yaml (extract-design-md, el caso que origino el bug)', () => {
101
+ // Forma exacta del skill real de awm-baseline-registry que rompia
102
+ // `awm add -a cursor|copilot` y `awm export frontend`.
103
+ const frontmatter = [
104
+ 'name: extract-design-md',
105
+ 'version: "1.0.1"',
106
+ 'description: >-',
107
+ ' Extract a comprehensive design system (DESIGN.md) directly from frontend source',
108
+ ' code — React, Vue, Svelte, Angular, plain HTML/CSS, or any web framework.',
109
+ 'allowed-tools:',
110
+ ' - "Bash"',
111
+ ].join('\n');
112
+ const expected = (js_yaml_1.default.load(frontmatter).description).trim();
113
+ expect((0, discovery_1.readFrontmatterDescription)(frontmatter)).toBe(expected);
114
+ // Y no absorbio la clave siguiente ni sus items indentados.
115
+ expect((0, discovery_1.readFrontmatterDescription)(frontmatter)).not.toContain('allowed-tools');
116
+ expect((0, discovery_1.readFrontmatterDescription)(frontmatter)).not.toContain('Bash');
117
+ });
118
+ });
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ // `awm init` and `awm update` disagreed about what "the baseline" is.
4
+ //
5
+ // init (steps.ts) bundles.find(b => b.scope === 'baseline') ← the FIRST one
6
+ // update (reconciliation.ts) bundles.filter(b => b.scope === 'baseline' …) ← all of them
7
+ //
8
+ // A registry shipping two baseline bundles therefore installed one on `awm init` and
9
+ // two on the next `awm update` — and the diagnostic that decides whether the baseline
10
+ // is satisfied used `find` too, so it agreed with init and disagreed with update. The
11
+ // second bundle appeared out of nowhere on an unrelated command, and nothing before
12
+ // that had reported it missing.
13
+ //
14
+ // `filter` is the correct reading: `scope: 'baseline'` is a property a bundle HAS, not
15
+ // a slot only one bundle can occupy.
16
+ const steps_1 = require("../../../src/core/init/steps");
17
+ function bundle(name, scope) {
18
+ return {
19
+ name, description: '', version: '1.0.0', scope, visibility: 'public',
20
+ dependsOn: [], skills: [], workflows: [], agents: [], source: `bundles/${name}`,
21
+ };
22
+ }
23
+ function deps(bundles) {
24
+ const installs = [];
25
+ const d = {
26
+ cwd: '/repo',
27
+ agent: 'claude-code',
28
+ enabledAgents: ['claude-code'],
29
+ bundles,
30
+ installMethod: 'symlink',
31
+ registryRoot: '',
32
+ contentDir: '',
33
+ sensorPacksRoot: '',
34
+ ctx: {
35
+ machine: { devCore: { present: false, brokenLinks: [] } },
36
+ project: null,
37
+ },
38
+ actions: { installBundle: (o) => { installs.push(o.bundleName); } },
39
+ };
40
+ return { d, installs };
41
+ }
42
+ describe('awm init installs every baseline bundle, like awm update reconciles every one', () => {
43
+ it('installs all of them, not just the first', () => {
44
+ const { d, installs } = deps([
45
+ bundle('dev-core', 'baseline'),
46
+ bundle('team-baseline', 'baseline'),
47
+ bundle('frontend', 'project'),
48
+ ]);
49
+ const result = (0, steps_1.stepDevCore)(d);
50
+ expect(installs).toEqual(['dev-core', 'team-baseline']);
51
+ expect(result.action).toBe('applied');
52
+ });
53
+ it('installs nothing from a non-baseline scope', () => {
54
+ const { d, installs } = deps([bundle('dev-core', 'baseline'), bundle('extras', 'ambient')]);
55
+ (0, steps_1.stepDevCore)(d);
56
+ expect(installs).toEqual(['dev-core']);
57
+ });
58
+ it('still skips entirely when the baseline is already satisfied', () => {
59
+ const { d, installs } = deps([bundle('dev-core', 'baseline'), bundle('team-baseline', 'baseline')]);
60
+ d.ctx.machine.devCore.present = true;
61
+ expect((0, steps_1.stepDevCore)(d).action).toBe('skipped');
62
+ expect(installs).toEqual([]);
63
+ });
64
+ });
@@ -0,0 +1,117 @@
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
+ // `stepContextInjection` is a MACHINE-level step — it runs even when `ctx.project`
7
+ // is null. For providers whose context delivery is project-scope (managed-agents-md
8
+ // with `globalPath: null` — Cursor, Copilot) it then fell back to writing at `d.cwd`:
9
+ //
10
+ // $ cd ~/scratch && awm init -a copilot # not a project: no .git, no package.json
11
+ // ~/scratch/AGENTS.md ← written
12
+ // ~/scratch/.awm/context/awm-context.md ← written
13
+ //
14
+ // `planInitMutationTargets` enumerates both of those paths only inside
15
+ // `if (projectRoot)`, so with no project root neither entered the backup session. A
16
+ // later failed step rolled back everything that WAS enumerated and left these two
17
+ // behind — while the outcome reported `rolledBack: true` and listed `restoredFiles`,
18
+ // i.e. claimed a clean restore it had not performed.
19
+ //
20
+ // `--machine-only` reached the same place from the other side: it nulls `ctx.project`
21
+ // deliberately, so the step took the `?? d.cwd` branch even inside a real project.
22
+ //
23
+ // Two different situations were being read as one. Running in a directory the user
24
+ // means as their project but which has no marker yet is legitimate — Copilot has no
25
+ // other channel, so removing that write would leave it with no context at all. What
26
+ // was wrong is that the path was never enumerated, so it survived rollback; that is
27
+ // fixed in mutation-targets, which now covers `cwd` for these providers regardless of
28
+ // whether a project root was found.
29
+ //
30
+ // `--machine-only` is the case that must not write at all: it nulls `ctx.project` on
31
+ // purpose, and this step read that as "no project found" and wrote into cwd anyway —
32
+ // under the one flag whose entire promise is that it won't touch the project.
33
+ const fs_1 = __importDefault(require("fs"));
34
+ const os_1 = __importDefault(require("os"));
35
+ const path_1 = __importDefault(require("path"));
36
+ const steps_1 = require("../../../src/core/init/steps");
37
+ const mutation_targets_1 = require("../../../src/core/init/mutation-targets");
38
+ const materializer_1 = require("../../../src/core/context/materializer");
39
+ function deps(agent, cwd, projectRoot, machineOnly = false) {
40
+ const installed = [];
41
+ const d = {
42
+ cwd,
43
+ agent,
44
+ enabledAgents: [agent],
45
+ bundles: [],
46
+ installMethod: 'symlink',
47
+ registryRoot: '',
48
+ contentDir: '',
49
+ sensorPacksRoot: '',
50
+ machineOnly,
51
+ ctx: {
52
+ machine: {},
53
+ project: projectRoot ? { root: projectRoot } : null,
54
+ },
55
+ actions: {
56
+ contextStatus: () => 'absent',
57
+ installContext: (op) => { installed.push(op); },
58
+ },
59
+ };
60
+ return { d, installed };
61
+ }
62
+ describe('project-scope context injection without a project', () => {
63
+ let cwd;
64
+ beforeEach(() => { cwd = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-noproj-')); });
65
+ afterEach(() => { fs_1.default.rmSync(cwd, { recursive: true, force: true }); });
66
+ it.each(['copilot', 'cursor'])('%s: writes nothing at all under --machine-only', (agent) => {
67
+ const { d, installed } = deps(agent, cwd, null, true);
68
+ const result = (0, steps_1.stepContextInjection)(d);
69
+ expect(installed).toEqual([]);
70
+ expect(result.action).toBe('skipped');
71
+ expect(result.detail).toMatch(/machine-only/i);
72
+ });
73
+ it.each(['copilot', 'cursor'])('%s: still delivers into an unmarked cwd — its only channel — now that the path is backed up', (agent) => {
74
+ const { d, installed } = deps(agent, cwd, null);
75
+ const result = (0, steps_1.stepContextInjection)(d);
76
+ expect(result.action).toBe('applied');
77
+ expect(installed[0].projectRoot).toBe(cwd);
78
+ // the write is only safe because mutation-targets enumerates it — asserted below
79
+ expect((0, mutation_targets_1.planInitMutationTargets)({ cwd, agent, bundles: [] }))
80
+ .toContain(path_1.default.join(cwd, 'AGENTS.md'));
81
+ });
82
+ it('leaves a GLOBAL-scope provider untouched by --machine-only', () => {
83
+ // --machine-only excludes the project, not the machine. Codex's context is
84
+ // machine-global, so it must still be installed.
85
+ const { d, installed } = deps('codex', cwd, null, true);
86
+ expect((0, steps_1.stepContextInjection)(d).action).toBe('applied');
87
+ expect(installed).toHaveLength(1);
88
+ });
89
+ it('still injects for a local-scope provider when a project root IS known', () => {
90
+ const { d, installed } = deps('copilot', cwd, cwd);
91
+ const result = (0, steps_1.stepContextInjection)(d);
92
+ expect(result.action).toBe('applied');
93
+ expect(installed).toHaveLength(1);
94
+ expect(installed[0].projectRoot).toBe(cwd);
95
+ });
96
+ it('leaves global-scope injection (codex) alone — it needs no project', () => {
97
+ const { d, installed } = deps('codex', cwd, null);
98
+ const result = (0, steps_1.stepContextInjection)(d);
99
+ expect(result.action).toBe('applied');
100
+ expect(installed).toHaveLength(1);
101
+ expect(installed[0].scope).toBe('global');
102
+ });
103
+ });
104
+ describe('mutation-targets covers the local-scope context paths independently', () => {
105
+ let cwd;
106
+ beforeEach(() => { cwd = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-noproj-mt-')); });
107
+ afterEach(() => { fs_1.default.rmSync(cwd, { recursive: true, force: true }); });
108
+ it.each(['copilot', 'cursor'])('%s: enumerates AGENTS.md and .awm/context even with no project root', (agent) => {
109
+ const targets = (0, mutation_targets_1.planInitMutationTargets)({ cwd, agent, bundles: [] });
110
+ expect(targets).toContain(path_1.default.join(cwd, 'AGENTS.md'));
111
+ expect(targets).toContain((0, materializer_1.projectContextPath)(cwd));
112
+ });
113
+ it('does not invent those targets for a provider that never writes them', () => {
114
+ const targets = (0, mutation_targets_1.planInitMutationTargets)({ cwd, agent: 'claude-code', bundles: [] });
115
+ expect(targets).not.toContain((0, materializer_1.projectContextPath)(cwd));
116
+ });
117
+ });
@@ -58,7 +58,7 @@ describe('runInitSteps — orchestrator', () => {
58
58
  function buildDeps(cwd) {
59
59
  const { gatherContext } = require('../../../src/core/diagnostics/context');
60
60
  const { discoverAllBundles } = require('../../../src/core/bundles');
61
- const { contentRoots, REGISTRIES_DIR } = require('../../../src/core/registries');
61
+ const { contentRoots, registriesDir } = require('../../../src/core/registries');
62
62
  const { defaultActions } = require('../../../src/core/init/steps');
63
63
  // Seed content at ~/.awm/registries/baseline (content root IS the registry root).
64
64
  const contentRoot = path_1.default.join(process.env.AWM_HOME, 'registries', 'baseline');
@@ -57,7 +57,7 @@ describe('stepCache — registry sync error results', () => {
57
57
  const ctx = {
58
58
  machine: {
59
59
  registryCache: { present: false },
60
- hook: { present: true, degraded: false },
60
+ hook: { present: true, degraded: false, applicable: true },
61
61
  devCore: { present: true, brokenLinks: [] },
62
62
  ambient: { wanted: [], installed: [] },
63
63
  contextInjection: [],
@@ -19,7 +19,7 @@ function bundle(name, scope, skills) {
19
19
  function machine() {
20
20
  return {
21
21
  registryCache: { present: true, gitState: 'clean' },
22
- hook: { present: true, degraded: false },
22
+ hook: { present: true, degraded: false, applicable: true },
23
23
  devCore: { present: true, brokenLinks: [] },
24
24
  ambient: { wanted: [], installed: [] },
25
25
  contextInjection: [],
@@ -31,6 +31,7 @@ function project(over = {}) {
31
31
  root: '/repo',
32
32
  profile: { present: true, extensions: [] },
33
33
  activeBundles: { expected: [], linked: [], broken: [] },
34
+ orphanLinks: { repairable: [], dead: [] },
34
35
  sensors: { present: true },
35
36
  constitution: { present: true },
36
37
  context: { present: true, file: 'CLAUDE.md' },
@@ -101,14 +102,14 @@ describe('stepHook / stepDevCore / stepAmbient', () => {
101
102
  it('hook installs when absent', () => {
102
103
  const a = spies();
103
104
  const m = machine();
104
- m.hook = { present: false };
105
+ m.hook = { present: false, applicable: true };
105
106
  expect((0, steps_1.stepHook)(deps({ machine: m, project: null }, a)).action).toBe('applied');
106
107
  expect(a.installHook).toHaveBeenCalled();
107
108
  });
108
109
  it('hook reinstalls when present but degraded', () => {
109
110
  const a = spies();
110
111
  const m = machine();
111
- m.hook = { present: true, degraded: true };
112
+ m.hook = { present: true, degraded: true, applicable: true };
112
113
  expect((0, steps_1.stepHook)(deps({ machine: m, project: null }, a)).action).toBe('applied');
113
114
  expect(a.installHook).toHaveBeenCalled();
114
115
  });
@@ -123,7 +124,7 @@ describe('stepHook / stepDevCore / stepAmbient', () => {
123
124
  it('hook skips (not calls installHook) for an agent with no hook mechanism', () => {
124
125
  const a = spies();
125
126
  const m = machine();
126
- m.hook = { present: false };
127
+ m.hook = { present: false, applicable: true };
127
128
  const r = (0, steps_1.stepHook)(deps({ machine: m, project: null }, a, { agent: 'opencode', enabledAgents: ['opencode'] }));
128
129
  expect(r.action).toBe('skipped');
129
130
  expect(a.installHook).not.toHaveBeenCalled();
@@ -533,7 +534,7 @@ describe('stepContextInjection', () => {
533
534
  it('installs Copilot context at local scope with projectRoot (no global AGENTS.md-equivalent)', () => {
534
535
  const a = spies();
535
536
  a.contextStatus.mockReturnValue('absent');
536
- const r = (0, steps_1.stepContextInjection)(deps({ machine: machine(), project: null }, a, { agent: 'copilot', cwd: '/repo' }));
537
+ const r = (0, steps_1.stepContextInjection)(deps({ machine: machine(), project: project({ root: '/repo' }) }, a, { agent: 'copilot', cwd: '/repo' }));
537
538
  expect(r.action).toBe('applied');
538
539
  expect(a.installContext).toHaveBeenCalledWith(expect.objectContaining({ agent: 'copilot', scope: 'local', projectRoot: '/repo' }));
539
540
  });
@@ -550,6 +551,11 @@ describe('stepContextInjection', () => {
550
551
  expect(a.installContext).toHaveBeenCalledWith(expect.objectContaining({ agent: 'copilot', scope: 'local', projectRoot: '/repo' }));
551
552
  });
552
553
  it('falls back to raw cwd when no project was discovered', () => {
554
+ // Legitimate: Copilot has no channel other than a project AGENTS.md, and a
555
+ // directory without .git/package.json is still where the user is working. What
556
+ // made this a rollback hole was mutation-targets.ts enumerating that path only
557
+ // inside `if (projectRoot)` — fixed there; see
558
+ // tests/core/init/context-injection-no-project.test.ts.
553
559
  const a = spies();
554
560
  a.contextStatus.mockReturnValue('absent');
555
561
  const r = (0, steps_1.stepContextInjection)(deps({ machine: machine(), project: null }, a, { agent: 'copilot', cwd: '/nowhere' }));
@@ -0,0 +1,136 @@
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
+ // SECURITY regression: `resolveOnPath` used to interpolate its argument into a
7
+ // SHELL string (`command -v ${bin}` / `where ${bin}`) and run it through
8
+ // execSync. The argument is the first token of a sensor's `cmd`, which comes
9
+ // from `.awm/sensors.json` — a file that is COMMITTED IN THE REPO and also
10
+ // regenerated from a registry sensor-pack's `defaultCmd`. So both a cloned
11
+ // untrusted repo and a third-party registry could reach it.
12
+ //
13
+ // The token cannot contain whitespace (it is a split token), but that stops
14
+ // nothing: `${IFS}` and `$(...)` sidestep it entirely. Confirmed exploit on the
15
+ // real binary — `awm sensors status` (and `awm preflight`) executed an attacker
16
+ // command and then reported the sensor as HEALTHY, because the injected command
17
+ // exited 0:
18
+ //
19
+ // {"sensors":{"lint":{"cmd":"foo;touch${IFS}/tmp/PWNED","fast":true}}}
20
+ // $ awm sensors status # -> /tmp/PWNED created, status "✔ HEALTHY"
21
+ //
22
+ // `awm sensors status` and `awm preflight` are commands a user reasonably
23
+ // expects to be INERT — read-only inspection. The fix removes the shell
24
+ // entirely: PATH is resolved in-process, so there is no interpreter to inject
25
+ // into, on any platform.
26
+ const fs_1 = __importDefault(require("fs"));
27
+ const os_1 = __importDefault(require("os"));
28
+ const path_1 = __importDefault(require("path"));
29
+ const paths_1 = require("../../src/core/paths");
30
+ describe('resolveOnPath: sin shell, inmune a inyeccion', () => {
31
+ const realPlatform = process.platform;
32
+ let binDir;
33
+ let originalPath;
34
+ beforeEach(() => {
35
+ binDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-pathres-'));
36
+ originalPath = process.env.PATH;
37
+ process.env.PATH = binDir;
38
+ });
39
+ afterEach(() => {
40
+ Object.defineProperty(process, 'platform', { value: realPlatform });
41
+ if (originalPath === undefined)
42
+ delete process.env.PATH;
43
+ else
44
+ process.env.PATH = originalPath;
45
+ fs_1.default.rmSync(binDir, { recursive: true, force: true });
46
+ });
47
+ function setPlatform(p) {
48
+ Object.defineProperty(process, 'platform', { value: p });
49
+ }
50
+ /** Un binario "real" en el PATH falso. */
51
+ function putBinary(name, mode = 0o755) {
52
+ const p = path_1.default.join(binDir, name);
53
+ fs_1.default.writeFileSync(p, '#!/bin/sh\nexit 0\n');
54
+ fs_1.default.chmodSync(p, mode);
55
+ return p;
56
+ }
57
+ it('encuentra un binario real presente en PATH', () => {
58
+ setPlatform('linux');
59
+ putBinary('mytool');
60
+ expect((0, paths_1.resolveOnPath)('mytool')).toBe(true);
61
+ });
62
+ it('devuelve false para un binario ausente', () => {
63
+ setPlatform('linux');
64
+ expect((0, paths_1.resolveOnPath)('definitivamente-no-existe')).toBe(false);
65
+ });
66
+ it('NO ejecuta un payload de inyeccion — el efecto lateral jamas ocurre (regresion de seguridad)', () => {
67
+ setPlatform('linux');
68
+ const marker = path_1.default.join(binDir, 'PWNED');
69
+ // El mismo payload confirmado contra el binario real.
70
+ const payload = `foo;touch\${IFS}${marker}`;
71
+ expect((0, paths_1.resolveOnPath)(payload)).toBe(false);
72
+ expect(fs_1.default.existsSync(marker)).toBe(false);
73
+ });
74
+ it.each([
75
+ ['sustitucion de comando', 'foo$(touch MARKER)'],
76
+ ['backticks', 'foo`touch MARKER`'],
77
+ ['encadenado con &&', 'foo&&touch'],
78
+ ['pipe', 'foo|touch'],
79
+ ['redireccion', 'foo>MARKER'],
80
+ ['newline embebido', 'foo\ntouch MARKER'],
81
+ ['expansion de variable', '$HOME'],
82
+ ])('trata %s como un nombre literal, nunca como sintaxis', (_n, payload) => {
83
+ setPlatform('linux');
84
+ // No existe un archivo con ese nombre literal => false, y nada se ejecuta.
85
+ expect((0, paths_1.resolveOnPath)(payload)).toBe(false);
86
+ });
87
+ // Solo donde el filesystem PUEDE expresar la premisa. `setPlatform('linux')` cambia
88
+ // lo que hace el codigo, no lo que hace el disco: en un runner Windows `chmod 0o644`
89
+ // no quita ningun bit de ejecucion (no existe), asi que `accessSync(X_OK)` aprueba el
90
+ // archivo y el test fallaba sobre un producto que se comporta bien. La rama win32 —
91
+ // "no exige bit de ejecucion" — la cubre su propio caso mas abajo, que SI es
92
+ // observable ahi.
93
+ const itPosix = realPlatform === 'win32' ? it.skip : it;
94
+ itPosix('en POSIX exige el bit de ejecucion — un archivo no ejecutable no es un binario resoluble', () => {
95
+ setPlatform('linux');
96
+ putBinary('noexec', 0o644);
97
+ expect((0, paths_1.resolveOnPath)('noexec')).toBe(false);
98
+ });
99
+ it('en win32 resuelve por PATHEXT, incluyendo los shims .cmd de npm (regresion: R1, bug publicado en v3.9.0)', () => {
100
+ setPlatform('win32');
101
+ // En win32 el usuario escribe `eslint`, y lo que existe en disco es
102
+ // `eslint.cmd` — no resolverlo fue un bug real que rompia los sensores.
103
+ fs_1.default.writeFileSync(path_1.default.join(binDir, 'eslint.cmd'), '@echo off\r\n');
104
+ process.env.PATHEXT = '.COM;.EXE;.BAT;.CMD';
105
+ expect((0, paths_1.resolveOnPath)('eslint')).toBe(true);
106
+ expect((0, paths_1.resolveOnPath)('noexiste')).toBe(false);
107
+ });
108
+ it('en win32 no exige bit de ejecucion (no existe ese concepto ahi)', () => {
109
+ setPlatform('win32');
110
+ fs_1.default.writeFileSync(path_1.default.join(binDir, 'tool.exe'), '');
111
+ process.env.PATHEXT = '.COM;.EXE;.BAT;.CMD';
112
+ expect((0, paths_1.resolveOnPath)('tool')).toBe(true);
113
+ });
114
+ it('un nombre con separador de ruta se chequea como ruta directa, sin recorrer PATH', () => {
115
+ // La plataforma REAL, no una simulada: el invariante es "un nombre que trae el
116
+ // separador de SU plataforma se resuelve como ruta". Fijar 'linux' mientras el
117
+ // disco entrega `D:\...\directo` mezclaba las dos mitades — el codigo buscaba
118
+ // '/' y la ruta traia '\\' — y fallaba por la contradiccion del arreglo, no por
119
+ // el producto. Asi el caso corre de verdad en los dos sistemas operativos.
120
+ setPlatform(realPlatform);
121
+ const abs = putBinary('directo');
122
+ expect((0, paths_1.resolveOnPath)(abs)).toBe(true);
123
+ expect((0, paths_1.resolveOnPath)(path_1.default.join(binDir, 'no-esta'))).toBe(false);
124
+ });
125
+ it('entradas vacias o no-string no resuelven ni crashean', () => {
126
+ setPlatform('linux');
127
+ expect((0, paths_1.resolveOnPath)('')).toBe(false);
128
+ expect((0, paths_1.resolveOnPath)(' ')).toBe(false);
129
+ expect((0, paths_1.resolveOnPath)(undefined)).toBe(false);
130
+ });
131
+ it('un PATH ausente no crashea', () => {
132
+ setPlatform('linux');
133
+ delete process.env.PATH;
134
+ expect((0, paths_1.resolveOnPath)('cualquiera')).toBe(false);
135
+ });
136
+ });
@@ -5,10 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const os_1 = __importDefault(require("os"));
7
7
  const path_1 = __importDefault(require("path"));
8
- const child_process_1 = require("child_process");
9
8
  const paths_1 = require("../../src/core/paths");
10
- jest.mock('child_process', () => ({ execSync: jest.fn() }));
11
- const mockExecSync = child_process_1.execSync;
12
9
  describe('core/paths', () => {
13
10
  let origHome;
14
11
  let origAwmHome;
@@ -16,7 +13,6 @@ describe('core/paths', () => {
16
13
  beforeEach(() => {
17
14
  origHome = process.env.HOME;
18
15
  origAwmHome = process.env.AWM_HOME;
19
- mockExecSync.mockReset();
20
16
  });
21
17
  afterEach(() => {
22
18
  if (origHome === undefined)
@@ -106,34 +102,11 @@ describe('core/paths', () => {
106
102
  setPlatform('linux');
107
103
  expect(() => (0, paths_1.noteWindowsCaveat)(throwingLog)).not.toThrow();
108
104
  });
109
- describe('resolveOnPath', () => {
110
- it('uses `command -v` on POSIX and returns true when the binary resolves', () => {
111
- setPlatform('linux');
112
- mockExecSync.mockImplementation(((cmd) => {
113
- if (cmd === 'command -v semgrep')
114
- return Buffer.from('/usr/bin/semgrep');
115
- throw new Error(`not found: ${cmd}`);
116
- }));
117
- expect((0, paths_1.resolveOnPath)('semgrep')).toBe(true);
118
- });
119
- it('returns false on POSIX when `command -v` fails to resolve the binary', () => {
120
- setPlatform('linux');
121
- mockExecSync.mockImplementation(() => { throw new Error('not found'); });
122
- expect((0, paths_1.resolveOnPath)('semgrep')).toBe(false);
123
- });
124
- it('uses `where` on win32, not `command -v`', () => {
125
- setPlatform('win32');
126
- mockExecSync.mockImplementation(((cmd) => {
127
- if (cmd === 'where semgrep')
128
- return Buffer.from('C:\\tools\\semgrep.exe');
129
- throw new Error(`not found: ${cmd}`);
130
- }));
131
- expect((0, paths_1.resolveOnPath)('semgrep')).toBe(true);
132
- });
133
- it('returns false on win32 when `where` cannot find the binary', () => {
134
- setPlatform('win32');
135
- mockExecSync.mockImplementation(() => { throw new Error('not found'); });
136
- expect((0, paths_1.resolveOnPath)('semgrep')).toBe(false);
137
- });
138
- });
105
+ // `resolveOnPath` ya no invoca un shell (ni ningun subproceso): resuelve PATH
106
+ // en proceso. Los tests que vivian aca mockeaban `execSync` y verificaban las
107
+ // strings `command -v X` / `where X`, o sea la implementacion removida — y dos
108
+ // de ellos ademas pasaban por accidente en cualquier maquina que tuviera el
109
+ // binario del fixture instalado de verdad. Su reemplazo, con PATH controlado y
110
+ // el exploit de inyeccion como ancla, esta en
111
+ // tests/core/path-resolution-no-shell.test.ts.
139
112
  });