agentic-workflow-manager 3.13.5 → 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 (78) 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 +2 -2
  24. package/dist/src/core/init/mutation-targets.js +11 -0
  25. package/dist/src/core/init/steps.js +70 -19
  26. package/dist/src/core/install-planner.js +62 -17
  27. package/dist/src/core/install-transaction.js +13 -1
  28. package/dist/src/core/journal/adapter.js +10 -1
  29. package/dist/src/core/paths.js +61 -8
  30. package/dist/src/core/profile.js +12 -0
  31. package/dist/src/core/provider-artifacts.js +38 -13
  32. package/dist/src/core/provider-version.js +12 -6
  33. package/dist/src/core/registries.js +40 -19
  34. package/dist/src/core/renderers/registry.js +37 -0
  35. package/dist/src/core/skill-integrity.js +71 -10
  36. package/dist/src/core/update-check.js +14 -2
  37. package/dist/src/core/versioning.js +17 -2
  38. package/dist/src/index.js +31 -23
  39. package/dist/src/providers/index.js +7 -2
  40. package/dist/src/utils/config.js +7 -8
  41. package/dist/tests/commands/doctor.test.js +6 -0
  42. package/dist/tests/commands/pin.test.js +29 -2
  43. package/dist/tests/commands/preflight/preflight.test.js +31 -17
  44. package/dist/tests/commands/registry/install-bundles.test.js +6 -6
  45. package/dist/tests/commands/sensors/init-pack-unavailable.test.js +96 -0
  46. package/dist/tests/commands/sensors/install-settings-safety.test.js +87 -0
  47. package/dist/tests/commands/sensors/run-is-read-only.test.js +0 -0
  48. package/dist/tests/commands/sensors/run.test.js +24 -51
  49. package/dist/tests/commands/sensors/status-windows.test.js +39 -22
  50. package/dist/tests/commands/sensors/status.test.js +27 -12
  51. package/dist/tests/core/artifact-name-containment.test.js +86 -0
  52. package/dist/tests/core/context/agents-md-single-slot.test.js +69 -0
  53. package/dist/tests/core/context/regenerate.test.js +39 -8
  54. package/dist/tests/core/diagnostics/checks.test.js +14 -6
  55. package/dist/tests/core/diagnostics/provider-tier.test.js +3 -3
  56. package/dist/tests/core/diagnostics/rendered-artifact-visibility.test.js +69 -0
  57. package/dist/tests/core/init/all-baseline-bundles.test.js +64 -0
  58. package/dist/tests/core/init/context-injection-no-project.test.js +117 -0
  59. package/dist/tests/core/init/orchestrator.test.js +1 -1
  60. package/dist/tests/core/init/steps-registry-sync.test.js +1 -1
  61. package/dist/tests/core/init/steps.test.js +11 -5
  62. package/dist/tests/core/path-resolution-no-shell.test.js +136 -0
  63. package/dist/tests/core/paths.test.js +7 -34
  64. package/dist/tests/core/project-skill-links.test.js +159 -0
  65. package/dist/tests/core/provider-artifacts.test.js +66 -14
  66. package/dist/tests/core/provider-capability-guards.test.js +92 -0
  67. package/dist/tests/core/reconciliation.test.js +15 -15
  68. package/dist/tests/core/registry-manifest.test.js +1 -1
  69. package/dist/tests/core/semver-fails-closed.test.js +30 -0
  70. package/dist/tests/core/skill-integrity.test.js +10 -10
  71. package/dist/tests/core/skill-repair-safety.test.js +92 -0
  72. package/dist/tests/core/sync-profile-atomicity.test.js +122 -0
  73. package/dist/tests/integration/copilot-init-isolated.test.js +2 -2
  74. package/dist/tests/providers/index.test.js +1 -1
  75. package/dist/tests/structural/renderer-table-is-single-source.test.js +95 -0
  76. package/dist/tests/structural/symlink-type-is-explicit.test.js +73 -0
  77. package/dist/tests/utils/registry-view-overrides.test.js +2 -2
  78. package/package.json +4 -1
@@ -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
  });
@@ -0,0 +1,159 @@
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
+ // Project-scope skill symlinks had no maintenance path at all.
7
+ //
8
+ // The machinery exists and is exercised every `awm init` / `awm registry sync`:
9
+ // `classifySkillLinks` finds dangling symlinks, `repairSkillLinks` re-links the ones
10
+ // the registry can still serve and prunes the ones it cannot. But every entry point
11
+ // pointed it at `provider.skill.global` only. A dangling link under a PROJECT's
12
+ // `.claude/skills/` (registry re-cloned, skill renamed upstream, bundle removed from
13
+ // `.awm/profile.json`) was:
14
+ //
15
+ // - never healed — `awm sync` installs what the profile declares and looks at
16
+ // nothing else, so a link whose bundle was dropped is simply left behind;
17
+ // - never pruned — it stays in the tree indefinitely, and the agent trips on it
18
+ // when it tries to read the skill's SKILL.md;
19
+ // - never reported — `awm doctor`'s `globalSkills` reads the global dir, so nothing
20
+ // on any surface mentions it.
21
+ //
22
+ // For Copilot the gap is total, not partial: `skill.global` is null, so *every* skill
23
+ // it has is project-scope, and none of them had any integrity path whatsoever.
24
+ const fs_1 = __importDefault(require("fs"));
25
+ const os_1 = __importDefault(require("os"));
26
+ const path_1 = __importDefault(require("path"));
27
+ const skill_integrity_1 = require("../../src/core/skill-integrity");
28
+ const providers_1 = require("../../src/providers");
29
+ describe('project-scope skill links get the same integrity path as global ones', () => {
30
+ let registry;
31
+ let projectRoot;
32
+ const made = [];
33
+ beforeEach(() => {
34
+ registry = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-projlink-reg-'));
35
+ projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-projlink-proj-'));
36
+ made.push(registry, projectRoot);
37
+ fs_1.default.mkdirSync(path_1.default.join(registry, 'skills', 'alive'), { recursive: true });
38
+ fs_1.default.writeFileSync(path_1.default.join(registry, 'skills', 'alive', 'SKILL.md'), '# alive\n');
39
+ });
40
+ afterAll(() => { for (const d of made)
41
+ fs_1.default.rmSync(d, { recursive: true, force: true }); });
42
+ /** The dir where `agent`'s project-scope skills live, with the links pre-created. */
43
+ function localSkillsDir(agent, links) {
44
+ const dir = path_1.default.join(projectRoot, (0, providers_1.providerFor)(agent).skill.local);
45
+ fs_1.default.mkdirSync(dir, { recursive: true });
46
+ for (const [name, target] of Object.entries(links))
47
+ fs_1.default.symlinkSync(target, path_1.default.join(dir, name));
48
+ return dir;
49
+ }
50
+ it('classifies a dangling project link as repairable when the registry still has it', () => {
51
+ const dir = localSkillsDir('claude-code', { alive: path_1.default.join(registry, 'gone', 'alive') });
52
+ const out = (0, skill_integrity_1.classifySkillLinks)(dir, [registry]);
53
+ expect(out.repairable).toEqual(['alive']);
54
+ expect(out.dead).toEqual([]);
55
+ });
56
+ it('classifies a dangling project link as dead when the registry no longer has it', () => {
57
+ const dir = localSkillsDir('claude-code', { removed: path_1.default.join(registry, 'skills', 'removed') });
58
+ const out = (0, skill_integrity_1.classifySkillLinks)(dir, [registry]);
59
+ expect(out.dead).toEqual(['removed']);
60
+ expect(out.repairable).toEqual([]);
61
+ });
62
+ it('awm sync heals a project link the registry can still serve', () => {
63
+ const dir = localSkillsDir('claude-code', { alive: path_1.default.join(registry, 'moved', 'alive') });
64
+ const results = (0, skill_integrity_1.reconcileProjectSkillLinks)(projectRoot, ['claude-code'], [registry]);
65
+ expect(results.find(r => r.agent === 'claude-code').result.relinked).toEqual(['alive']);
66
+ expect(fs_1.default.existsSync(path_1.default.join(dir, 'alive', 'SKILL.md'))).toBe(true);
67
+ });
68
+ it('prunes a project link nothing can serve any more', () => {
69
+ const dir = localSkillsDir('claude-code', { removed: path_1.default.join(registry, 'skills', 'removed') });
70
+ const results = (0, skill_integrity_1.reconcileProjectSkillLinks)(projectRoot, ['claude-code'], [registry]);
71
+ expect(results.find(r => r.agent === 'claude-code').result.pruned).toEqual(['removed']);
72
+ expect(fs_1.default.existsSync(path_1.default.join(dir, 'removed'))).toBe(false);
73
+ expect(fs_1.default.lstatSync(dir).isDirectory()).toBe(true);
74
+ });
75
+ it('covers Copilot, whose skills are ONLY ever project-scope', () => {
76
+ expect((0, providers_1.providerFor)('copilot').skill.global).toBeNull(); // premise of this test
77
+ const dir = localSkillsDir('copilot', { alive: path_1.default.join(registry, 'moved', 'alive') });
78
+ const results = (0, skill_integrity_1.reconcileProjectSkillLinks)(projectRoot, ['copilot'], [registry]);
79
+ expect(results.find(r => r.agent === 'copilot').result.relinked).toEqual(['alive']);
80
+ expect(fs_1.default.existsSync(path_1.default.join(dir, 'alive', 'SKILL.md'))).toBe(true);
81
+ });
82
+ it('leaves the user\'s own files alone — only dangling symlinks are touched', () => {
83
+ const dir = localSkillsDir('claude-code', { alive: path_1.default.join(registry, 'skills', 'alive') });
84
+ fs_1.default.mkdirSync(path_1.default.join(dir, 'hand-written'));
85
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'hand-written', 'SKILL.md'), '# mine\n');
86
+ const results = (0, skill_integrity_1.reconcileProjectSkillLinks)(projectRoot, ['claude-code'], [registry]);
87
+ const r = results.find(x => x.agent === 'claude-code').result;
88
+ expect(r.relinked).toEqual([]);
89
+ expect(r.pruned).toEqual([]);
90
+ expect(fs_1.default.readFileSync(path_1.default.join(dir, 'hand-written', 'SKILL.md'), 'utf-8')).toBe('# mine\n');
91
+ expect(fs_1.default.existsSync(path_1.default.join(dir, 'alive'))).toBe(true);
92
+ });
93
+ it('skips agents whose project skills dir does not exist', () => {
94
+ const results = (0, skill_integrity_1.reconcileProjectSkillLinks)(projectRoot, ['claude-code', 'copilot'], [registry]);
95
+ expect(results).toEqual([]);
96
+ });
97
+ });
98
+ // Wiring, not just capability: I15's actual defect was that the healing primitive
99
+ // already existed and nothing called it for project scope. A green unit test of the
100
+ // primitive would have looked identical before and after. So assert the call.
101
+ describe('awm sync reaches the project-scope reconciliation', () => {
102
+ let projectRoot;
103
+ let home;
104
+ let saved;
105
+ beforeEach(() => {
106
+ jest.resetModules();
107
+ home = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-syncwire-home-'));
108
+ saved = { HOME: process.env.HOME, AWM_HOME: process.env.AWM_HOME };
109
+ process.env.HOME = home;
110
+ process.env.AWM_HOME = path_1.default.join(home, '.awm');
111
+ fs_1.default.mkdirSync(path_1.default.join(home, '.awm'), { recursive: true });
112
+ fs_1.default.writeFileSync(path_1.default.join(home, '.awm', 'preferences.json'), JSON.stringify({
113
+ enabledAgents: ['claude-code'], defaultAgent: 'claude-code',
114
+ installMethod: 'symlink', defaultScope: 'global',
115
+ }));
116
+ projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-syncwire-proj-'));
117
+ fs_1.default.mkdirSync(path_1.default.join(projectRoot, '.awm'), { recursive: true });
118
+ });
119
+ afterEach(() => {
120
+ if (saved.HOME === undefined)
121
+ delete process.env.HOME;
122
+ else
123
+ process.env.HOME = saved.HOME;
124
+ if (saved.AWM_HOME === undefined)
125
+ delete process.env.AWM_HOME;
126
+ else
127
+ process.env.AWM_HOME = saved.AWM_HOME;
128
+ fs_1.default.rmSync(home, { recursive: true, force: true });
129
+ fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
130
+ });
131
+ const writeProfile = (extensions) => fs_1.default.writeFileSync(path_1.default.join(projectRoot, '.awm', 'profile.json'), JSON.stringify({ extensions }));
132
+ async function sync(spy) {
133
+ const { runSyncCore } = require('../../src/commands/sync');
134
+ return runSyncCore({ cwd: projectRoot }, {
135
+ syncRegistries: async () => [],
136
+ verifyMinCliVersions: () => [],
137
+ verifyProjectPins: async () => [],
138
+ syncProfile: () => ({ installed: [], skipped: [], extensions: [], transactionIds: [], modifiedFiles: [] }),
139
+ reconcileProjectSkillLinks: spy,
140
+ });
141
+ }
142
+ it('reconciles project links on a normal sync', async () => {
143
+ writeProfile(['demo']);
144
+ const spy = jest.fn((_root, _agents, _roots) => []);
145
+ await sync(spy);
146
+ expect(spy).toHaveBeenCalledTimes(1);
147
+ expect(spy.mock.calls[0][0]).toBe(projectRoot);
148
+ expect(spy.mock.calls[0][1]).toEqual(['claude-code']);
149
+ });
150
+ it('reconciles project links even when the profile has no extensions left', async () => {
151
+ // The case that produces orphans in the first place: the extension that owned
152
+ // them was removed. That path used to return early, touching nothing.
153
+ writeProfile([]);
154
+ const spy = jest.fn((_root, _agents, _roots) => []);
155
+ const out = await sync(spy);
156
+ expect(out.code).toBe(0);
157
+ expect(spy).toHaveBeenCalledTimes(1);
158
+ });
159
+ });