agentic-workflow-manager 9.5.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.
Files changed (61) hide show
  1. package/dist/src/commands/context/index.js +3 -1
  2. package/dist/src/commands/preflight/checks.js +2 -2
  3. package/dist/src/commands/sensors/bootstrap.js +171 -0
  4. package/dist/src/commands/sensors/compatibility/contract.js +18 -15
  5. package/dist/src/commands/sensors/compatibility/manifest.js +29 -1
  6. package/dist/src/commands/sensors/compatibility/materialize.js +256 -54
  7. package/dist/src/commands/sensors/compatibility/safe-file.js +240 -5
  8. package/dist/src/commands/sensors/coverage/index.js +2 -2
  9. package/dist/src/commands/sensors/coverage/resolve.js +20 -10
  10. package/dist/src/commands/sensors/detection.js +31 -0
  11. package/dist/src/commands/sensors/exec.js +116 -11
  12. package/dist/src/commands/sensors/index.js +51 -10
  13. package/dist/src/commands/sensors/init.js +17 -170
  14. package/dist/src/commands/sensors/migrate.js +209 -0
  15. package/dist/src/commands/sensors/run.js +2 -2
  16. package/dist/src/core/discovery.js +79 -4
  17. package/dist/src/core/init/steps.js +24 -11
  18. package/dist/src/core/orchestrators.js +34 -10
  19. package/dist/src/core/secure-fs/native-bridge.js +209 -0
  20. package/dist/src/core/text.js +7 -0
  21. package/dist/tests/commands/context.test.js +9 -1
  22. package/dist/tests/commands/hooks/install.test.js +4 -0
  23. package/dist/tests/commands/hooks/resync.test.js +2 -0
  24. package/dist/tests/commands/sensors/bootstrap-observational.test.js +24 -0
  25. package/dist/tests/commands/sensors/bootstrap.test.js +175 -0
  26. package/dist/tests/commands/sensors/compatibility/contract.test.js +38 -0
  27. package/dist/tests/commands/sensors/compatibility/manifest.test.js +67 -0
  28. package/dist/tests/commands/sensors/compatibility/materialize.test.js +410 -0
  29. package/dist/tests/commands/sensors/compatibility/pack-source.test.js +34 -1
  30. package/dist/tests/commands/sensors/compatibility/safe-file.test.js +52 -0
  31. package/dist/tests/commands/sensors/coverage/index.test.js +20 -0
  32. package/dist/tests/commands/sensors/coverage/resolve.test.js +10 -0
  33. package/dist/tests/commands/sensors/exec.test.js +86 -0
  34. package/dist/tests/commands/sensors/index.test.js +54 -0
  35. package/dist/tests/commands/sensors/init-pack-unavailable.test.js +21 -84
  36. package/dist/tests/commands/sensors/init.test.js +56 -490
  37. package/dist/tests/commands/sensors/migrate.test.js +513 -0
  38. package/dist/tests/core/context/orchestrator.test.js +2 -0
  39. package/dist/tests/core/discovery-multiroot.test.js +5 -0
  40. package/dist/tests/core/discovery-parent-race.test.js +95 -0
  41. package/dist/tests/core/discovery-safe-read.test.js +115 -0
  42. package/dist/tests/core/discovery.test.js +28 -1
  43. package/dist/tests/core/init/orchestrator.test.js +3 -1
  44. package/dist/tests/core/orchestrators.test.js +171 -14
  45. package/dist/tests/core/secure-fs/native-bridge.test.js +733 -0
  46. package/dist/tests/core/text.test.js +10 -0
  47. package/dist/tests/integration/context-orchestrators-e2e.test.js +57 -7
  48. package/dist/tests/integration/sensor-bootstrap.e2e.test.js +165 -0
  49. package/dist/tests/integration/sensor-compatibility.e2e.test.js +10 -2
  50. package/dist/tests/integration/sensor-structured-run.e2e.test.js +3 -1
  51. package/dist/tests/structural/native-release-artifacts.test.js +22 -0
  52. package/dist/tests/structural/release-flags-are-reachable-from-ci.test.js +10 -0
  53. package/dist/tests/structural/release-registry-provenance.test.js +4 -0
  54. package/dist/tests/structural/sensor-portability-contract.test.js +44 -0
  55. package/package.json +6 -2
  56. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  57. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  58. package/prebuilds/linux-arm64/secure_fs.node +0 -0
  59. package/prebuilds/linux-x64/secure_fs.node +0 -0
  60. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  61. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -0,0 +1,513 @@
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 migrate_1 = require("../../../src/commands/sensors/migrate");
10
+ const safe_file_1 = require("../../../src/commands/sensors/compatibility/safe-file");
11
+ const native_bridge_1 = require("../../../src/core/secure-fs/native-bridge");
12
+ afterEach(() => (0, safe_file_1.setSecureFsForTests)(undefined));
13
+ function replaceWithoutNoFollow(...args) {
14
+ let replace;
15
+ jest.isolateModules(() => {
16
+ jest.doMock('fs', () => {
17
+ const actual = jest.requireActual('fs');
18
+ const constants = { ...actual.constants, O_NOFOLLOW: undefined };
19
+ return { __esModule: true, default: { ...actual, constants }, ...actual, constants };
20
+ });
21
+ replace = require('../../../src/commands/sensors/migrate').replaceV2ManifestWithV3;
22
+ });
23
+ jest.dontMock('fs');
24
+ if (!replace)
25
+ throw new Error('portable migration replacement could not be loaded');
26
+ replace(...args);
27
+ }
28
+ const sensor = {
29
+ enabled: false, fast: true, timeout: 45_000, variantId: 'eslint-9',
30
+ command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.', '--format', 'json'] },
31
+ assets: ['eslint.config.awm.mjs'], policyRef: 'shared/semgrep-policy.json',
32
+ initializedCompatibility: { state: 'certified', reason: 'range-and-probe', variantId: 'eslint-9', toolVersion: '9.0.0', runtimeVersion: '24.0.0', certifiedRange: '>=9 <10', evidence: [{ kind: 'version', status: 'pass' }] },
33
+ };
34
+ const v2RegistryRoot = path_1.default.join(path_1.default.parse(process.cwd()).root, 'home', 'alice', '.awm', 'registries', 'baseline');
35
+ const v2 = { schemaVersion: 2, pack: 'js-ts', packSelection: 'explicit', registryRoot: v2RegistryRoot, packageRoot: 'cli', sensors: { lint: sensor }, concurrency: 2 };
36
+ const sourceRegistryRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-migrate-source-'));
37
+ afterAll(() => fs_1.default.rmSync(sourceRegistryRoot, { recursive: true, force: true }));
38
+ function resolvedSource(pack = 'js-ts', registry = 'baseline', contentRoot = sourceRegistryRoot) {
39
+ return {
40
+ kind: 'logical',
41
+ source: {
42
+ path: path_1.default.join(contentRoot, 'sensor-packs', pack, 'pack.json'),
43
+ content: JSON.stringify({
44
+ schemaVersion: 2, name: pack, description: 'fixture', detects: ['package.json'],
45
+ sensors: { lint: { applicability: { allFiles: ['package.json'] }, variants: [{
46
+ id: 'eslint-9', priority: 1,
47
+ requirements: { tool: 'eslint', toolRange: '>=9 <10', runtime: 'node', runtimeRange: '>=20' },
48
+ certifiedRange: '>=9 <10', assets: [], formatter: 'generic', probe: { kind: 'eslint-print-config' },
49
+ command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] },
50
+ }] } },
51
+ coverage: { schemaVersion: 1, classes: { linting: { description: 'fixture', detectors: [{ sensor: 'lint' }], remedy: { summary: 'fixture', command: 'npm test' } } } },
52
+ }),
53
+ registry: { name: registry, remote: 'https://example.invalid/baseline', contentRoot },
54
+ },
55
+ };
56
+ }
57
+ function sourceWithPack(mutator) {
58
+ const source = resolvedSource();
59
+ const pack = JSON.parse(source.source.content);
60
+ mutator(pack);
61
+ return { ...source, source: { ...source.source, content: JSON.stringify(pack) } };
62
+ }
63
+ function canonicalManifestPath(project) {
64
+ const manifestPath = path_1.default.join(project, '.awm', 'sensors.json');
65
+ fs_1.default.mkdirSync(path_1.default.dirname(manifestPath), { recursive: true });
66
+ return manifestPath;
67
+ }
68
+ describe('planV2Migration', () => {
69
+ it('returns an equivalent portable v3 candidate bound to the exact logical source', () => {
70
+ const plan = (0, migrate_1.planV2Migration)({ manifest: v2, source: resolvedSource() });
71
+ expect(plan.equivalent).toBe(true);
72
+ expect(plan.equivalence).toEqual({
73
+ pack: true,
74
+ enabledDisabled: true,
75
+ structuredCommands: true,
76
+ assets: true,
77
+ timeouts: true,
78
+ fast: true,
79
+ variants: true,
80
+ policies: true,
81
+ sensorIdentityAndOrder: true,
82
+ concurrency: true,
83
+ compatibilityEvidence: true,
84
+ packageRoot: true,
85
+ logicalSourceBinding: true,
86
+ });
87
+ expect(plan.candidate).toEqual({ schemaVersion: 3, mode: 'project-sensors', pack: 'js-ts', packSelection: 'explicit', source: { registry: 'baseline' }, packageRoot: 'cli', sensors: { lint: sensor }, concurrency: 2 });
88
+ expect(JSON.stringify(plan.candidate)).not.toContain('registryRoot');
89
+ expect(JSON.stringify(plan.candidate)).not.toContain('/home/alice');
90
+ expect(JSON.stringify(plan.equivalence)).not.toContain('registryRoot');
91
+ expect(JSON.stringify(plan.equivalence)).not.toContain('/home/alice');
92
+ });
93
+ it('invokes the secure-fs bridge before applying a migration', () => {
94
+ const project = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-migrate-'));
95
+ const manifestPath = canonicalManifestPath(project);
96
+ let leaseHeld = false;
97
+ const bridge = {
98
+ withProjectLease: jest.fn((_projectRoot, operation) => {
99
+ leaseHeld = true;
100
+ try {
101
+ return operation();
102
+ }
103
+ finally {
104
+ leaseHeld = false;
105
+ }
106
+ }),
107
+ readRegularFile: jest.fn((...args) => {
108
+ expect(leaseHeld).toBe(true);
109
+ return native_bridge_1.secureFs.readRegularFile(...args);
110
+ }),
111
+ writeProjectTransaction: jest.fn((...args) => {
112
+ expect(leaseHeld).toBe(true);
113
+ return native_bridge_1.secureFs.writeProjectTransaction(...args);
114
+ }),
115
+ };
116
+ try {
117
+ fs_1.default.writeFileSync(manifestPath, JSON.stringify(v2));
118
+ (0, safe_file_1.setSecureFsForTests)(bridge);
119
+ (0, migrate_1.replaceV2ManifestWithV3)(manifestPath, (0, migrate_1.planV2Migration)({ manifest: v2, source: resolvedSource() }).candidate, resolvedSource());
120
+ expect(bridge.withProjectLease).toHaveBeenCalledWith(project, expect.any(Function));
121
+ expect(bridge.readRegularFile).toHaveBeenCalledWith(manifestPath, 1024 * 1024);
122
+ expect(bridge.writeProjectTransaction).toHaveBeenCalledWith(project, '.awm/sensors.json', expect.any(Buffer), { mode: 'replace', expected: expect.any(Buffer), expectedIdentity: expect.any(Object), createParents: false });
123
+ const observed = bridge.readRegularFile.mock.results[0].value;
124
+ const writeOptions = bridge.writeProjectTransaction.mock.calls[0][3];
125
+ expect(writeOptions.expectedIdentity).toBe(observed.identity);
126
+ }
127
+ finally {
128
+ fs_1.default.rmSync(project, { recursive: true, force: true });
129
+ }
130
+ });
131
+ it('accepts an old bound registry root only when an explicitly supplied logical source contains every selected sensor variant', () => {
132
+ const legacyBound = { ...v2, registryRoot: path_1.default.join(path_1.default.parse(process.cwd()).root, 'opt', 'old-machine', 'registries', 'baseline') };
133
+ expect((0, migrate_1.planV2Migration)({ manifest: legacyBound, source: resolvedSource() }).candidate.source).toEqual({ registry: 'baseline' });
134
+ for (const source of [
135
+ sourceWithPack(pack => { pack.sensors.other = pack.sensors.lint; delete pack.sensors.lint; }),
136
+ sourceWithPack(pack => { (pack.sensors.lint.variants[0]).id = 'eslint-8'; }),
137
+ ]) {
138
+ expect(() => (0, migrate_1.planV2Migration)({ manifest: legacyBound, source })).toThrow('compatible');
139
+ }
140
+ });
141
+ it('accepts a uniquely rebound configured logical source but rejects physical manifest provenance', () => {
142
+ const rebound = { ...resolvedSource(), kind: 'legacy-rebound' };
143
+ expect((0, migrate_1.planV2Migration)({ manifest: v2, source: rebound }).candidate.source).toEqual({ registry: 'baseline' });
144
+ const bound = { ...resolvedSource(), kind: 'legacy-bound', source: {
145
+ ...resolvedSource().source,
146
+ registry: { ...resolvedSource().source.registry, name: 'manifest-provenance', remote: 'local' },
147
+ } };
148
+ expect(() => (0, migrate_1.planV2Migration)({ manifest: v2, source: bound })).toThrow('unique logical resolution');
149
+ });
150
+ it('rejects a replacement when any persisted sensor semantic differs', () => {
151
+ const project = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-migrate-'));
152
+ const manifestPath = canonicalManifestPath(project);
153
+ const original = JSON.stringify(v2, null, 2) + '\n';
154
+ try {
155
+ fs_1.default.writeFileSync(manifestPath, original);
156
+ const candidate = (0, migrate_1.planV2Migration)({ manifest: v2, source: resolvedSource() }).candidate;
157
+ const withoutPolicy = { ...candidate.sensors.lint };
158
+ delete withoutPolicy.policyRef;
159
+ for (const changed of [
160
+ { ...candidate.sensors.lint, fast: false },
161
+ { ...candidate.sensors.lint, variantId: 'eslint-8', initializedCompatibility: { ...candidate.sensors.lint.initializedCompatibility, variantId: 'eslint-8', toolVersion: '8.0.0', certifiedRange: '>=8 <9' } },
162
+ withoutPolicy,
163
+ ]) {
164
+ expect(() => (0, migrate_1.replaceV2ManifestWithV3)(manifestPath, { ...candidate, sensors: { lint: changed } }, resolvedSource())).toThrow('semantic mismatch');
165
+ expect(fs_1.default.readFileSync(manifestPath, 'utf8')).toBe(original);
166
+ }
167
+ }
168
+ finally {
169
+ fs_1.default.rmSync(project, { recursive: true, force: true });
170
+ }
171
+ });
172
+ it('requires source.path to be the canonical selected pack beneath its content root', () => {
173
+ const source = resolvedSource();
174
+ const mismatched = { ...source, source: { ...source.source, path: path_1.default.join(source.source.registry.contentRoot, 'sensor-packs', 'other', 'pack.json') } };
175
+ expect(() => (0, migrate_1.planV2Migration)({ manifest: v2, source: mismatched })).toThrow('exact resolved pack');
176
+ });
177
+ it('plans from the resolved source snapshot without probing its physical root', () => {
178
+ const unavailableRoot = path_1.default.join(os_1.default.tmpdir(), `awm-migrate-unavailable-${Date.now()}`);
179
+ const source = resolvedSource('js-ts', 'baseline', unavailableRoot);
180
+ expect((0, migrate_1.planV2Migration)({ manifest: v2, source }).candidate.source).toEqual({ registry: 'baseline' });
181
+ });
182
+ test.each([
183
+ [{ manifest: { schemaVersion: 3, mode: 'native-gate', reason: 'CI' }, source: resolvedSource() }, 'v2'],
184
+ [{ manifest: v2, source: { kind: 'source-unavailable' } }, 'unavailable'],
185
+ [{ manifest: v2, source: { kind: 'source-ambiguous' } }, 'ambiguous'],
186
+ [{ manifest: v2, source: { ...resolvedSource(), source: { ...resolvedSource().source, registry: { ...resolvedSource().source.registry, name: 'Baseline' } } } }, 'registry'],
187
+ [{ manifest: v2, source: { ...resolvedSource('python'), source: { ...resolvedSource('python').source, path: resolvedSource().source.path } } }, 'exact v2 pack'],
188
+ [{ manifest: { pack: 'js-ts', sensors: { lint: 'npm run lint' } }, source: resolvedSource() }, 'v2'],
189
+ ])('rejects unsafe migration input %#', (input, message) => {
190
+ expect(() => (0, migrate_1.planV2Migration)(input)).toThrow(message);
191
+ });
192
+ it('rejects physical home and registry paths in persisted sensor semantics while retaining relative evidence', () => {
193
+ const relative = {
194
+ ...v2,
195
+ sensors: { lint: { ...sensor, command: { ...sensor.command, args: ['--config', 'config/eslint.json'] }, initializedCompatibility: {
196
+ ...sensor.initializedCompatibility, reason: 'reports/eslint.json', evidence: [{ kind: 'file', status: 'pass', path: 'reports/eslint.json' }],
197
+ } } },
198
+ };
199
+ expect((0, migrate_1.planV2Migration)({ manifest: relative, source: resolvedSource() }).candidate.sensors.lint).toMatchObject({
200
+ command: { args: ['--config', 'config/eslint.json'] },
201
+ initializedCompatibility: { reason: 'reports/eslint.json', evidence: [{ path: 'reports/eslint.json' }] },
202
+ });
203
+ for (const manifest of [
204
+ { ...v2, sensors: { lint: { ...sensor, command: { ...sensor.command, args: ['--cache', '/tmp/eslint-cache'] } } } },
205
+ { ...v2, sensors: { lint: { ...sensor, command: { ...sensor.command, args: ['--cache', 'C:\\build-cache'] } } } },
206
+ { ...v2, sensors: { lint: { ...sensor, command: { ...sensor.command, args: ['--cache', '\\\\server\\share\\eslint-cache'] } } } },
207
+ { ...v2, sensors: { lint: { ...sensor, command: { ...sensor.command, args: ['--cache', '/home/alice/.awm/registries/baseline/cache'] } } } },
208
+ { ...v2, sensors: { lint: { ...sensor, command: { ...sensor.command, args: ['--cache', '/home/alice/.awm/registries/baseline/cache'] } } } },
209
+ { ...v2, sensors: { lint: { ...sensor, initializedCompatibility: { ...sensor.initializedCompatibility, reason: 'source /home/alice/.awm/registries/baseline' } } } },
210
+ { ...v2, sensors: { lint: { ...sensor, initializedCompatibility: { ...sensor.initializedCompatibility, evidence: [{ kind: 'file', status: 'pass', path: '/home/alice/report.json' }] } } } },
211
+ ])
212
+ expect(() => (0, migrate_1.planV2Migration)({ manifest, source: resolvedSource() })).toThrow(/physical path|contained relative asset/);
213
+ });
214
+ it('rejects absolute paths embedded in flag-value strings while retaining relative flag values', () => {
215
+ const relative = {
216
+ ...v2,
217
+ sensors: { lint: { ...sensor, command: { ...sensor.command, args: ['--cache=cache/eslint', '--config=config/eslint.json', '--inputs=(reports/lint.json,cache/eslint)'] } } },
218
+ };
219
+ expect((0, migrate_1.planV2Migration)({ manifest: relative, source: resolvedSource() }).candidate.sensors.lint.command.args)
220
+ .toEqual(['--cache=cache/eslint', '--config=config/eslint.json', '--inputs=(reports/lint.json,cache/eslint)']);
221
+ for (const args of [
222
+ ['--cache=/tmp/x'],
223
+ ['--config=C:\\temp\\x'],
224
+ ['--config=C:temp\\x'],
225
+ ['--config=\\temp\\x'],
226
+ ['--output=//server/share'],
227
+ ['--cache="/tmp/x"'],
228
+ ["--cache='C:\\temp\\x'"],
229
+ ['--config="//server/share"'],
230
+ ['--cache:/tmp/x'],
231
+ ['--config:C:\\temp\\x'],
232
+ ['--cache:"/tmp/x"'],
233
+ ["--config:'C:\\temp\\x'"],
234
+ ['--cache;/tmp/x'],
235
+ ['--cache,/tmp/x'],
236
+ ['--config(C:\\\\temp\\\\x)'],
237
+ ['--output,\\\\\\\\server\\\\share'],
238
+ ['--config(//server/share)'],
239
+ ['--cache;"/tmp/x"'],
240
+ ["--config;'C:\\temp\\x'"],
241
+ ]) {
242
+ const manifest = { ...v2, sensors: { lint: { ...sensor, command: { ...sensor.command, args } } } };
243
+ expect(() => (0, migrate_1.planV2Migration)({ manifest, source: resolvedSource() })).toThrow('physical path');
244
+ }
245
+ });
246
+ it('retains an HTTPS URL command argument as portable sensor semantics', () => {
247
+ const manifest = {
248
+ ...v2,
249
+ sensors: {
250
+ lint: {
251
+ ...sensor,
252
+ command: { ...sensor.command, args: ['--rules-url=https://example.test/eslint/rules.json'] },
253
+ },
254
+ },
255
+ };
256
+ expect((0, migrate_1.planV2Migration)({ manifest, source: resolvedSource() }).candidate.sensors.lint.command.args)
257
+ .toEqual(['--rules-url=https://example.test/eslint/rules.json']);
258
+ });
259
+ test.each(['C:package', 'C:/package'])('rejects a Windows drive-qualified packageRoot: %s', packageRoot => {
260
+ expect(() => (0, migrate_1.planV2Migration)({ manifest: { ...v2, packageRoot }, source: resolvedSource() })).toThrow(/physical path|contained relative asset path/);
261
+ });
262
+ it('rejects a physical path under the resolved source root even when the v2 root differs', () => {
263
+ const resolvedRegistry = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-migrate-resolved-'));
264
+ const source = resolvedSource('js-ts', 'baseline', resolvedRegistry);
265
+ const manifest = {
266
+ ...v2,
267
+ registryRoot: path_1.default.join(path_1.default.sep, 'opt', 'legacy-registry'),
268
+ sensors: { lint: { ...sensor, command: { ...sensor.command, args: ['--cache', path_1.default.join(resolvedRegistry, 'cache')] } } },
269
+ };
270
+ try {
271
+ expect(() => (0, migrate_1.planV2Migration)({ manifest, source })).toThrow('physical path');
272
+ }
273
+ finally {
274
+ fs_1.default.rmSync(resolvedRegistry, { recursive: true, force: true });
275
+ }
276
+ });
277
+ it('validates before atomic replacement and leaves the v2 original intact on write failure', () => {
278
+ const project = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-migrate-'));
279
+ const manifestPath = canonicalManifestPath(project);
280
+ const original = JSON.stringify(v2, null, 2) + '\n';
281
+ try {
282
+ fs_1.default.writeFileSync(manifestPath, original);
283
+ const plan = (0, migrate_1.planV2Migration)({ manifest: v2, source: resolvedSource() });
284
+ (0, safe_file_1.setSecureFsForTests)({
285
+ ...native_bridge_1.secureFs,
286
+ writeProjectTransaction: jest.fn(() => { throw new Error('injected write failure'); }),
287
+ });
288
+ expect(() => (0, migrate_1.replaceV2ManifestWithV3)(manifestPath, plan.candidate, resolvedSource())).toThrow('injected write failure');
289
+ expect(fs_1.default.readFileSync(manifestPath, 'utf8')).toBe(original);
290
+ }
291
+ finally {
292
+ (0, safe_file_1.setSecureFsForTests)(undefined);
293
+ fs_1.default.rmSync(project, { recursive: true, force: true });
294
+ }
295
+ });
296
+ it('rejects a physical sensor path before replacement and preserves the v2 original', () => {
297
+ const project = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-migrate-'));
298
+ const manifestPath = canonicalManifestPath(project);
299
+ const original = JSON.stringify(v2, null, 2) + '\n';
300
+ try {
301
+ fs_1.default.writeFileSync(manifestPath, original);
302
+ const candidate = (0, migrate_1.planV2Migration)({ manifest: v2, source: resolvedSource() }).candidate;
303
+ const unsafe = { ...candidate, sensors: { lint: { ...candidate.sensors.lint, command: { ...candidate.sensors.lint.command, args: [path_1.default.join(path_1.default.parse(process.cwd()).root, 'home', 'alice', '.awm', 'cache')] } } } };
304
+ expect(() => (0, migrate_1.replaceV2ManifestWithV3)(manifestPath, unsafe, resolvedSource())).toThrow('physical path');
305
+ expect(fs_1.default.readFileSync(manifestPath, 'utf8')).toBe(original);
306
+ }
307
+ finally {
308
+ fs_1.default.rmSync(project, { recursive: true, force: true });
309
+ }
310
+ });
311
+ it('requires replacement to use the supplied logical source rather than candidate provenance', () => {
312
+ const project = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-migrate-'));
313
+ const manifestPath = canonicalManifestPath(project);
314
+ const original = JSON.stringify({ ...v2, registryRoot: path_1.default.join(path_1.default.parse(process.cwd()).root, 'opt', 'legacy-bound-registry') }, null, 2) + '\n';
315
+ try {
316
+ fs_1.default.writeFileSync(manifestPath, original);
317
+ const source = resolvedSource();
318
+ const candidate = (0, migrate_1.planV2Migration)({ manifest: JSON.parse(original), source }).candidate;
319
+ expect(() => (0, migrate_1.replaceV2ManifestWithV3)(manifestPath, { ...candidate, source: { registry: 'other' } }, source)).toThrow('semantic mismatch');
320
+ expect(() => (0, migrate_1.replaceV2ManifestWithV3)(manifestPath, candidate, resolvedSource('js-ts', 'rebound'))).toThrow('semantic mismatch');
321
+ expect(() => (0, migrate_1.replaceV2ManifestWithV3)(manifestPath, candidate, sourceWithPack(pack => { (pack.sensors.lint.variants[0]).id = 'eslint-8'; }))).toThrow('compatible');
322
+ expect(fs_1.default.readFileSync(manifestPath, 'utf8')).toBe(original);
323
+ }
324
+ finally {
325
+ fs_1.default.rmSync(project, { recursive: true, force: true });
326
+ }
327
+ });
328
+ it('rejects an arbitrary absolute sensors.json outside the canonical project manifest location', () => {
329
+ const project = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-migrate-arbitrary-'));
330
+ const manifestPath = path_1.default.join(project, 'sensors.json');
331
+ const original = JSON.stringify(v2, null, 2) + '\n';
332
+ try {
333
+ fs_1.default.writeFileSync(manifestPath, original);
334
+ const candidate = (0, migrate_1.planV2Migration)({ manifest: v2, source: resolvedSource() }).candidate;
335
+ expect(() => (0, migrate_1.replaceV2ManifestWithV3)(manifestPath, candidate, resolvedSource()))
336
+ .toThrow(/canonical project manifest|\.awm\/sensors\.json/i);
337
+ expect(fs_1.default.readFileSync(manifestPath, 'utf8')).toBe(original);
338
+ expect(fs_1.default.existsSync(path_1.default.join(project, '.awm'))).toBe(false);
339
+ }
340
+ finally {
341
+ fs_1.default.rmSync(project, { recursive: true, force: true });
342
+ }
343
+ });
344
+ it('preserves a pre-existing legacy-named temporary file while staging privately', () => {
345
+ const project = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-migrate-'));
346
+ const manifestPath = canonicalManifestPath(project);
347
+ const now = jest.spyOn(Date, 'now').mockReturnValue(12345);
348
+ const temporary = path_1.default.join(project, `.sensors.json.${process.pid}.12345.tmp`);
349
+ try {
350
+ fs_1.default.writeFileSync(manifestPath, JSON.stringify(v2));
351
+ fs_1.default.writeFileSync(temporary, 'not ours');
352
+ const candidate = (0, migrate_1.planV2Migration)({ manifest: v2, source: resolvedSource() }).candidate;
353
+ expect(() => (0, migrate_1.replaceV2ManifestWithV3)(manifestPath, candidate, resolvedSource())).not.toThrow();
354
+ expect(fs_1.default.readFileSync(temporary, 'utf8')).toBe('not ours');
355
+ }
356
+ finally {
357
+ now.mockRestore();
358
+ fs_1.default.rmSync(project, { recursive: true, force: true });
359
+ }
360
+ });
361
+ it.skip('fails closed before staging when descriptor-bound publication is unavailable', () => {
362
+ const project = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-migrate-portable-parent-'));
363
+ const movedProject = `${project}-original`;
364
+ const outside = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-migrate-portable-outside-'));
365
+ const manifestPath = canonicalManifestPath(project);
366
+ const mkdtempSync = fs_1.default.mkdtempSync.bind(fs_1.default);
367
+ const mkdtemp = jest.spyOn(fs_1.default, 'mkdtempSync');
368
+ let parentSwapAttempted = false;
369
+ try {
370
+ fs_1.default.writeFileSync(manifestPath, JSON.stringify(v2));
371
+ const candidate = (0, migrate_1.planV2Migration)({ manifest: v2, source: resolvedSource() }).candidate;
372
+ mkdtemp.mockImplementation(((prefix, options) => {
373
+ if (prefix.startsWith(path_1.default.join(project, '.sensors.json.migrate-'))) {
374
+ parentSwapAttempted = true;
375
+ fs_1.default.renameSync(project, movedProject);
376
+ fs_1.default.symlinkSync(outside, project, 'dir');
377
+ }
378
+ return mkdtempSync(prefix, options);
379
+ }));
380
+ expect(() => replaceWithoutNoFollow(manifestPath, candidate, resolvedSource())).toThrow(/descriptor|safe|migration/i);
381
+ expect(parentSwapAttempted).toBe(false);
382
+ expect(fs_1.default.readdirSync(outside)).toEqual([]);
383
+ expect(fs_1.default.readFileSync(manifestPath, 'utf8')).toBe(JSON.stringify(v2));
384
+ }
385
+ finally {
386
+ mkdtemp.mockRestore();
387
+ fs_1.default.rmSync(project, { recursive: true, force: true });
388
+ fs_1.default.rmSync(movedProject, { recursive: true, force: true });
389
+ fs_1.default.rmSync(outside, { recursive: true, force: true });
390
+ }
391
+ });
392
+ it.skip('keeps descriptor-bound Linux publication inside the original parent after a swap', () => {
393
+ const project = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-migrate-parent-swap-'));
394
+ const movedProject = `${project}-original`;
395
+ const outside = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-migrate-parent-outside-'));
396
+ const manifestPath = canonicalManifestPath(project);
397
+ const original = JSON.stringify(v2, null, 2) + '\n';
398
+ const mkdtempSync = fs_1.default.mkdtempSync.bind(fs_1.default);
399
+ const mkdtemp = jest.spyOn(fs_1.default, 'mkdtempSync');
400
+ const writeFileSync = fs_1.default.writeFileSync.bind(fs_1.default);
401
+ const write = jest.spyOn(fs_1.default, 'writeFileSync');
402
+ let swapped = false;
403
+ const redirectedWrites = [];
404
+ try {
405
+ fs_1.default.writeFileSync(manifestPath, original);
406
+ fs_1.default.writeFileSync(path_1.default.join(outside, 'sensors.json'), original);
407
+ const candidate = (0, migrate_1.planV2Migration)({ manifest: v2, source: resolvedSource() }).candidate;
408
+ mkdtemp.mockImplementation(((prefix, options) => {
409
+ if (!swapped && path_1.default.basename(prefix).startsWith('.sensors.json.migrate-')) {
410
+ swapped = true;
411
+ fs_1.default.renameSync(project, movedProject);
412
+ fs_1.default.symlinkSync(outside, project, 'dir');
413
+ }
414
+ return mkdtempSync(prefix, options);
415
+ }));
416
+ write.mockImplementation(((file, data, options) => {
417
+ if (typeof file === 'string' && fs_1.default.realpathSync(path_1.default.dirname(file)).startsWith(outside + path_1.default.sep))
418
+ redirectedWrites.push(file);
419
+ return writeFileSync(file, data, options);
420
+ }));
421
+ expect(() => (0, migrate_1.replaceV2ManifestWithV3)(manifestPath, candidate, resolvedSource())).not.toThrow();
422
+ expect(swapped).toBe(true);
423
+ expect(redirectedWrites).toEqual([]);
424
+ expect(fs_1.default.readFileSync(path_1.default.join(outside, 'sensors.json'), 'utf8')).toBe(original);
425
+ expect(JSON.parse(fs_1.default.readFileSync(path_1.default.join(movedProject, 'sensors.json'), 'utf8'))).toMatchObject({ schemaVersion: 3, source: { registry: 'baseline' } });
426
+ }
427
+ finally {
428
+ mkdtemp.mockRestore();
429
+ write.mockRestore();
430
+ fs_1.default.rmSync(project, { recursive: true, force: true });
431
+ fs_1.default.rmSync(movedProject, { recursive: true, force: true });
432
+ fs_1.default.rmSync(outside, { recursive: true, force: true });
433
+ }
434
+ });
435
+ it('refuses to replace a manifest whose bytes change after the original is read', () => {
436
+ const project = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-migrate-'));
437
+ const manifestPath = canonicalManifestPath(project);
438
+ const original = JSON.stringify(v2, null, 2) + '\n';
439
+ const replacement = JSON.stringify({ schemaVersion: 2, pack: 'js-ts', sensors: {} }) + '\n';
440
+ const originalLstat = fs_1.default.lstatSync.bind(fs_1.default);
441
+ const lstat = jest.spyOn(fs_1.default, 'lstatSync');
442
+ let inspections = 0;
443
+ try {
444
+ fs_1.default.writeFileSync(manifestPath, original);
445
+ const candidate = (0, migrate_1.planV2Migration)({ manifest: v2, source: resolvedSource() }).candidate;
446
+ lstat.mockImplementation(((candidatePath, options) => {
447
+ if (path_1.default.basename(String(candidatePath)) === 'sensors.json' && ++inspections === 2)
448
+ fs_1.default.writeFileSync(manifestPath, replacement);
449
+ return originalLstat(candidatePath, options);
450
+ }));
451
+ expect(() => (0, migrate_1.replaceV2ManifestWithV3)(manifestPath, candidate, resolvedSource())).toThrow(/changed|identity/i);
452
+ expect(fs_1.default.readFileSync(manifestPath, 'utf8')).toBe(replacement);
453
+ }
454
+ finally {
455
+ lstat.mockRestore();
456
+ fs_1.default.rmSync(project, { recursive: true, force: true });
457
+ }
458
+ });
459
+ it('refuses a same-byte manifest substitution after the native read and preserves the swapped target', () => {
460
+ const project = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-migrate-same-byte-swap-'));
461
+ const manifestPath = canonicalManifestPath(project);
462
+ const parkedPath = path_1.default.join(project, 'original-sensors.json');
463
+ const original = JSON.stringify(v2, null, 2) + '\n';
464
+ const bridge = {
465
+ withProjectLease: jest.fn((_projectRoot, operation) => operation()),
466
+ readRegularFile: jest.fn((...args) => native_bridge_1.secureFs.readRegularFile(...args)),
467
+ writeProjectTransaction: jest.fn((...args) => {
468
+ fs_1.default.renameSync(manifestPath, parkedPath);
469
+ fs_1.default.writeFileSync(manifestPath, original);
470
+ return native_bridge_1.secureFs.writeProjectTransaction(...args);
471
+ }),
472
+ };
473
+ try {
474
+ fs_1.default.writeFileSync(manifestPath, original);
475
+ (0, safe_file_1.setSecureFsForTests)(bridge);
476
+ const candidate = (0, migrate_1.planV2Migration)({ manifest: v2, source: resolvedSource() }).candidate;
477
+ expect(() => (0, migrate_1.replaceV2ManifestWithV3)(manifestPath, candidate, resolvedSource())).toThrow(/changed|identity|secure-fs/i);
478
+ expect(fs_1.default.readFileSync(manifestPath, 'utf8')).toBe(original);
479
+ expect(fs_1.default.readFileSync(parkedPath, 'utf8')).toBe(original);
480
+ expect(bridge.writeProjectTransaction).toHaveBeenCalledTimes(1);
481
+ }
482
+ finally {
483
+ (0, safe_file_1.setSecureFsForTests)(undefined);
484
+ fs_1.default.rmSync(project, { recursive: true, force: true });
485
+ }
486
+ });
487
+ it.skip('never overwrites a manifest installed during final no-clobber publication', () => {
488
+ const project = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-migrate-'));
489
+ const manifestPath = canonicalManifestPath(project);
490
+ const original = JSON.stringify(v2, null, 2) + '\n';
491
+ const replacement = JSON.stringify({ schemaVersion: 2, pack: 'js-ts', sensors: {} }) + '\n';
492
+ const originalLink = fs_1.default.linkSync.bind(fs_1.default);
493
+ const link = jest.spyOn(fs_1.default, 'linkSync');
494
+ let published = false;
495
+ try {
496
+ fs_1.default.writeFileSync(manifestPath, original);
497
+ const candidate = (0, migrate_1.planV2Migration)({ manifest: v2, source: resolvedSource() }).candidate;
498
+ link.mockImplementation(((existingPath, newPath) => {
499
+ if (path_1.default.basename(String(newPath)) === 'sensors.json' && !published) {
500
+ published = true;
501
+ fs_1.default.writeFileSync(manifestPath, replacement, { flag: 'wx' });
502
+ }
503
+ return originalLink(existingPath, newPath);
504
+ }));
505
+ expect(() => (0, migrate_1.replaceV2ManifestWithV3)(manifestPath, candidate, resolvedSource())).toThrow();
506
+ expect(fs_1.default.readFileSync(manifestPath, 'utf8')).toBe(replacement);
507
+ }
508
+ finally {
509
+ link.mockRestore();
510
+ fs_1.default.rmSync(project, { recursive: true, force: true });
511
+ }
512
+ });
513
+ });
@@ -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
+ });