agentic-workflow-manager 9.8.0 → 9.9.0

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 (45) hide show
  1. package/dist/src/commands/job/gate.js +18 -0
  2. package/dist/src/commands/job/index.js +61 -0
  3. package/dist/src/commands/model-policy/index.js +73 -0
  4. package/dist/src/commands/plan/index.js +59 -2
  5. package/dist/src/commands/watch/apply.js +32 -0
  6. package/dist/src/core/admission/index.js +52 -2
  7. package/dist/src/core/admission/registry-contracts.js +1 -1
  8. package/dist/src/core/journal/requests.js +1 -1
  9. package/dist/src/core/journal/types.js +22 -1
  10. package/dist/src/core/model-policy/canonical.js +18 -0
  11. package/dist/src/core/model-policy/capabilities.js +197 -0
  12. package/dist/src/core/model-policy/capability-types.js +2 -0
  13. package/dist/src/core/model-policy/journal.js +124 -0
  14. package/dist/src/core/model-policy/paths.js +12 -0
  15. package/dist/src/core/model-policy/resolve.js +116 -0
  16. package/dist/src/core/model-policy/store.js +182 -0
  17. package/dist/src/core/model-policy/types.js +2 -0
  18. package/dist/src/core/model-policy/validate.js +112 -0
  19. package/dist/src/core/plan/identity.js +4 -2
  20. package/dist/src/core/plan/validate.js +15 -16
  21. package/dist/src/index.js +2 -0
  22. package/dist/tests/commands/job/gate-reconcile.test.js +16 -0
  23. package/dist/tests/commands/job/gate.test.js +9 -0
  24. package/dist/tests/commands/job/routing-report-cli.test.js +46 -0
  25. package/dist/tests/commands/job/routing.test.js +11 -0
  26. package/dist/tests/commands/model-policy/index.test.js +57 -0
  27. package/dist/tests/commands/plan/index.test.js +52 -0
  28. package/dist/tests/commands/watch/apply.test.js +12 -0
  29. package/dist/tests/core/admission/index.test.js +53 -0
  30. package/dist/tests/core/model-policy/capabilities.test.js +365 -0
  31. package/dist/tests/core/model-policy/journal.test.js +46 -0
  32. package/dist/tests/core/model-policy/policy.test.js +47 -0
  33. package/dist/tests/core/model-policy/resolve.test.js +111 -0
  34. package/dist/tests/core/model-policy/store.test.js +177 -0
  35. package/dist/tests/core/plan/validate.test.js +40 -2
  36. package/dist/tests/core/secure-fs/native-bridge.test.js +81 -4
  37. package/dist/tests/structural/directory-fsync-is-single-source.test.js +97 -0
  38. package/dist/tests/structural/native-release-artifacts.test.js +14 -0
  39. package/package.json +3 -2
  40. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  41. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  42. package/prebuilds/linux-arm64/secure_fs.node +0 -0
  43. package/prebuilds/linux-x64/secure_fs.node +0 -0
  44. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  45. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -0,0 +1,177 @@
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 atomic_file_1 = require("../../../src/core/atomic-file");
10
+ const canonical_1 = require("../../../src/core/model-policy/canonical");
11
+ const store_1 = require("../../../src/core/model-policy/store");
12
+ const paths_1 = require("../../../src/core/model-policy/paths");
13
+ const policy = () => ({ schema: 'model-policy/v1', mappings: [{ target: 'codex', runtimeKind: 'native', profiles: {
14
+ mechanical: { selector: { kind: 'model', id: 'gpt-5.6-luna' }, effort: { kind: 'explicit', value: 'medium' } },
15
+ integration: { selector: { kind: 'model', id: 'gpt-5.6-terra' }, effort: { kind: 'explicit', value: 'medium' } },
16
+ judgment: { selector: { kind: 'model', id: 'gpt-5.6-sol' }, effort: { kind: 'explicit', value: 'medium' } },
17
+ }, fullCapability: { selector: { kind: 'model', id: 'gpt-5.6-sol' }, effort: { kind: 'explicit', value: 'high' } }, degradation: { allowMissingModelOverride: false, allowMissingEffortOverride: false, allowMissingObservedIdentity: false } }], implementationBudget: { maxAttempts: 3, escalation: ['mechanical', 'integration', 'judgment'], judgmentEfforts: ['medium', 'high'] } });
18
+ function createIdentityFencedBoundary() {
19
+ const observations = new WeakMap();
20
+ return {
21
+ withProjectLease: (_root, operation) => operation(),
22
+ readRegularFile: (file) => {
23
+ const identity = Object.freeze({});
24
+ const bytes = fs_1.default.readFileSync(file);
25
+ observations.set(identity, { file, bytes });
26
+ return { bytes, identity };
27
+ },
28
+ writeProjectTransaction: (root, destination, content, options) => {
29
+ const file = path_1.default.join(root, destination);
30
+ fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
31
+ if (options.mode === 'create' && fs_1.default.existsSync(file))
32
+ throw new Error('project destination already exists');
33
+ if (options.mode === 'replace') {
34
+ const observed = observations.get(options.expectedIdentity);
35
+ if (!observed || observed.file !== file || !observed.bytes.equals(options.expected) || !fs_1.default.readFileSync(file).equals(options.expected))
36
+ throw new Error('original changed before fenced replacement');
37
+ }
38
+ const temporary = `${file}.test-tmp`;
39
+ const fd = fs_1.default.openSync(temporary, 'wx', 0o600);
40
+ try {
41
+ fs_1.default.writeFileSync(fd, content);
42
+ fs_1.default.fsyncSync(fd);
43
+ fs_1.default.closeSync(fd);
44
+ fs_1.default.renameSync(temporary, file);
45
+ // La durabilidad de la ENTRADA publicada se pide por el unico helper
46
+ // que define esa politica (atomic-file.fsyncDirSync): fail-closed en
47
+ // POSIX y degradado SOLO ante el gap conocido de win32 (EPERM al
48
+ // fsync-ear un fd de directorio, capacidad que el SO no expone).
49
+ // Reimplementarla aca la hacia divergir y rompia la matriz Windows.
50
+ (0, atomic_file_1.fsyncDirSync)(path_1.default.dirname(file));
51
+ }
52
+ finally {
53
+ try {
54
+ fs_1.default.rmSync(temporary, { force: true });
55
+ }
56
+ catch { /* preserve transaction result */ }
57
+ }
58
+ },
59
+ };
60
+ }
61
+ const identityFencedBoundary = createIdentityFencedBoundary();
62
+ const approve = (input) => (0, store_1.approvePolicyWithBoundary)(input, identityFencedBoundary);
63
+ describe('approved policy store', () => {
64
+ let root;
65
+ let oldHome;
66
+ let oldAwmHome;
67
+ beforeEach(() => { root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-policy-')); oldHome = process.env.HOME; oldAwmHome = process.env.AWM_HOME; process.env.AWM_HOME = path_1.default.join(root, 'operator'); });
68
+ afterEach(() => { jest.restoreAllMocks(); delete process.env.POLICY_TEST_FAIL_WRITE; delete process.env.POLICY_TEST_MUTATE_DURING_REPLACE; if (oldHome === undefined)
69
+ delete process.env.HOME;
70
+ else
71
+ process.env.HOME = oldHome; if (oldAwmHome === undefined)
72
+ delete process.env.AWM_HOME;
73
+ else
74
+ process.env.AWM_HOME = oldAwmHome; fs_1.default.rmSync(root, { recursive: true, force: true }); });
75
+ const candidate = () => { const file = path_1.default.join(root, 'candidate.json'); fs_1.default.writeFileSync(file, JSON.stringify(policy())); return file; };
76
+ it('publishes only an expected digest and reads it without creating state', () => {
77
+ const file = candidate();
78
+ const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
79
+ expect((0, store_1.readEffectivePolicy)(root)).toEqual({ state: 'absent' });
80
+ const approved = approve({ file, scope: 'user', cwd: root, expectedDigest: digest });
81
+ expect(approved.contentDigest).toBe(digest);
82
+ expect((0, store_1.readEffectivePolicy)(root)).toMatchObject({ state: 'approved', provenance: 'user', policy: { contentDigest: digest } });
83
+ });
84
+ it('rejects a mismatched digest before writing', () => {
85
+ const file = candidate();
86
+ expect(() => approve({ file, scope: 'user', cwd: root, expectedDigest: '0'.repeat(64) })).toThrow(/digest/i);
87
+ expect(fs_1.default.existsSync((0, paths_1.userPolicyPath)())).toBe(false);
88
+ });
89
+ it('allows exactly one initial writer and exact CAS replacement', () => {
90
+ const file = candidate();
91
+ const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
92
+ approve({ file, scope: 'user', cwd: root, expectedDigest: digest });
93
+ expect(() => approve({ file, scope: 'user', cwd: root, expectedDigest: digest })).toThrow(/replace|existing/i);
94
+ expect(() => approve({ file, scope: 'user', cwd: root, expectedDigest: digest, replaceDigest: 'f'.repeat(64) })).toThrow(/predecessor|replace/i);
95
+ expect(approve({ file, scope: 'user', cwd: root, expectedDigest: digest, replaceDigest: digest }).lineage.previousDigest).toBe(digest);
96
+ });
97
+ it('rejects an unexpected opaque identity token before replacing bytes', () => {
98
+ const target = path_1.default.join(root, 'identity.json');
99
+ fs_1.default.writeFileSync(target, 'observed');
100
+ const observed = identityFencedBoundary.readRegularFile(target, 1024);
101
+ expect(() => identityFencedBoundary.writeProjectTransaction(root, 'identity.json', Buffer.from('replacement'), {
102
+ mode: 'replace', createParents: false, expected: observed.bytes, expectedIdentity: Object.freeze({}),
103
+ })).toThrow(/original changed/i);
104
+ expect(fs_1.default.readFileSync(target, 'utf8')).toBe('observed');
105
+ });
106
+ it('resolves a relative candidate through cwd instead of process cwd', () => {
107
+ const cwd = path_1.default.join(root, 'project');
108
+ fs_1.default.mkdirSync(cwd);
109
+ const file = path_1.default.join(cwd, 'candidate.json');
110
+ fs_1.default.writeFileSync(file, JSON.stringify(policy()));
111
+ const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
112
+ expect(approve({ file: 'candidate.json', scope: 'project', cwd, expectedDigest: digest }).contentDigest).toBe(digest);
113
+ });
114
+ it('accepts exactly one of two competing fenced replacements', () => {
115
+ const file = candidate();
116
+ const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
117
+ approve({ file, scope: 'user', cwd: root, expectedDigest: digest });
118
+ const input = { file, scope: 'user', cwd: root, expectedDigest: digest, replaceDigest: digest };
119
+ let entered = false;
120
+ let accepted = 0;
121
+ const boundary = {
122
+ withProjectLease: (_root, operation) => operation(),
123
+ readRegularFile: (target) => ({ bytes: fs_1.default.readFileSync(target), identity: Object.freeze({}) }),
124
+ writeProjectTransaction: (base, destination, bytes, options) => {
125
+ const target = path_1.default.join(base, destination);
126
+ if (options.mode === 'replace' && !entered) {
127
+ entered = true;
128
+ // Model a non-cooperating external process: the native
129
+ // identity fence, not this advisory lock, must reject us.
130
+ fs_1.default.rmSync(`${target}.lock`);
131
+ (0, store_1.approvePolicyWithBoundary)(input, boundary);
132
+ fs_1.default.writeFileSync(`${target}.lock`, 'outer lock placeholder');
133
+ }
134
+ if (options.mode === 'replace' && (!options.expected || !fs_1.default.readFileSync(target).equals(options.expected)))
135
+ throw new Error('original changed before fenced replacement');
136
+ fs_1.default.mkdirSync(path_1.default.dirname(target), { recursive: true });
137
+ fs_1.default.writeFileSync(target, bytes);
138
+ accepted++;
139
+ },
140
+ };
141
+ expect(() => (0, store_1.approvePolicyWithBoundary)(input, boundary)).toThrow(/changed|replacement/i);
142
+ expect(accepted).toBe(1);
143
+ });
144
+ it('returns a failed publication and preserves predecessor bytes on injected transaction failure', () => {
145
+ const file = candidate();
146
+ const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
147
+ approve({ file, scope: 'user', cwd: root, expectedDigest: digest });
148
+ const before = fs_1.default.readFileSync((0, paths_1.userPolicyPath)());
149
+ const boundary = {
150
+ withProjectLease: (_root, operation) => operation(),
151
+ readRegularFile: (target) => ({ bytes: fs_1.default.readFileSync(target), identity: Object.freeze({}) }),
152
+ writeProjectTransaction: () => { throw new Error('injected durable publication failure'); },
153
+ };
154
+ expect(() => (0, store_1.approvePolicyWithBoundary)({ file, scope: 'user', cwd: root, expectedDigest: digest, replaceDigest: digest }, boundary)).toThrow(/publication failure/);
155
+ expect(fs_1.default.readFileSync((0, paths_1.userPolicyPath)()).equals(before)).toBe(true);
156
+ });
157
+ it('blocks an invalid project policy instead of falling back to a valid user policy', () => {
158
+ const file = candidate();
159
+ const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
160
+ approve({ file, scope: 'user', cwd: root, expectedDigest: digest });
161
+ fs_1.default.mkdirSync(path_1.default.join(root, '.awm'));
162
+ fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'model-policy.json'), '{"content":');
163
+ expect((0, store_1.readEffectivePolicy)(root)).toMatchObject({ state: 'invalid', provenance: 'project' });
164
+ });
165
+ it('rejects duplicate JSON keys and symlinked parent or destination without touching the target', () => {
166
+ const duplicate = path_1.default.join(root, 'duplicate.json');
167
+ fs_1.default.writeFileSync(duplicate, '{"schema":"model-policy/v1","schema":"model-policy/v1"}');
168
+ expect(() => approve({ file: duplicate, scope: 'user', cwd: root, expectedDigest: 'a'.repeat(64) })).toThrow(/duplicate/i);
169
+ const file = candidate();
170
+ const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
171
+ fs_1.default.mkdirSync(process.env.AWM_HOME);
172
+ fs_1.default.symlinkSync(root, path_1.default.join(process.env.AWM_HOME, 'link'));
173
+ const target = (0, paths_1.userPolicyPath)();
174
+ fs_1.default.symlinkSync(path_1.default.join(root, 'outside'), target);
175
+ expect(() => approve({ file, scope: 'user', cwd: root, expectedDigest: digest })).toThrow(/symlink/i);
176
+ });
177
+ });
@@ -50,6 +50,15 @@ function fixture(root, mutate) {
50
50
  fs_1.default.writeFileSync(plan, markdown);
51
51
  return plan;
52
52
  }
53
+ function v2Fixture(root, profile = 'mechanical') {
54
+ const plan = fixture(root, manifest => {
55
+ manifest.schema = 'compact-slices/v2';
56
+ const slices = manifest.slices;
57
+ slices[0].implementerProfile = profile;
58
+ });
59
+ fs_1.default.writeFileSync(plan, fs_1.default.readFileSync(plan, 'utf8').replace(START, '<!-- AWM:COMPACT-SLICES:START v2 -->').replace(END, '<!-- AWM:COMPACT-SLICES:END v2 -->'));
60
+ return plan;
61
+ }
53
62
  describe('validatePlanFile', () => {
54
63
  it('validates an authenticated snapshot without reopening a swapped plan parent', () => {
55
64
  const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-plan-snapshot-'));
@@ -86,6 +95,35 @@ describe('validatePlanFile', () => {
86
95
  expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'valid', schema: 'compact-slices/v1' });
87
96
  expect(fs_1.default.readFileSync(plan, 'utf8')).toBe(before);
88
97
  });
98
+ test.each(['mechanical', 'integration', 'judgment'])('accepts v2 with the exact %s implementer profile', profile => {
99
+ const report = (0, validate_1.validatePlanFile)(v2Fixture(root, profile), root);
100
+ expect(report).toMatchObject({ state: 'valid', schema: 'compact-slices/v2', manifest: { slices: [expect.objectContaining({ implementerProfile: profile })] } });
101
+ });
102
+ test('rejects v1 profile without weakening exact keys', () => {
103
+ expect((0, validate_1.validatePlanFile)(fixture(root, manifest => { (manifest.slices[0]).implementerProfile = 'mechanical'; }), root)).toMatchObject({ state: 'invalid' });
104
+ });
105
+ test.each([
106
+ ['missing profile', (text) => text.replace(/,\n\s*"implementerProfile": "mechanical"/, '')],
107
+ ['concrete model', (text) => text.replace('"implementerProfile": "mechanical"', '"implementerProfile": "mechanical",\n "model": "vendor-model"')],
108
+ ['unknown profile', (text) => text.replace('"implementerProfile": "mechanical"', '"implementerProfile": "cheap"')],
109
+ ])('rejects v2 %s without weakening exact keys', (_name, mutate) => {
110
+ const plan = v2Fixture(root);
111
+ fs_1.default.writeFileSync(plan, mutate(fs_1.default.readFileSync(plan, 'utf8')));
112
+ expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid' });
113
+ });
114
+ test('preserves v2 profile bytes in the execution identity', () => {
115
+ const mechanical = fs_1.default.readFileSync(v2Fixture(root, 'mechanical'), 'utf8');
116
+ const integration = mechanical.replace('"implementerProfile": "mechanical"', '"implementerProfile": "integration"');
117
+ const integrationPath = path_1.default.join(root, 'integration.md');
118
+ fs_1.default.writeFileSync(integrationPath, integration);
119
+ const left = (0, validate_1.validatePlanFile)(path_1.default.join(root, 'plan.md'), root);
120
+ const right = (0, validate_1.validatePlanFile)(integrationPath, root);
121
+ expect(left).toMatchObject({ state: 'valid', schema: 'compact-slices/v2' });
122
+ expect(right).toMatchObject({ state: 'valid', schema: 'compact-slices/v2' });
123
+ if (left.state !== 'valid' || right.state !== 'valid')
124
+ throw new Error('v2 fixture must validate');
125
+ expect(left.executionDigest).not.toBe(right.executionDigest);
126
+ });
89
127
  test('makes an approved valid report and its nested manifest immutable after verification', () => {
90
128
  const report = (0, validate_1.validatePlanFile)(fixture(root), root);
91
129
  expectApprovedPlanValid(report);
@@ -436,7 +474,7 @@ describe('validatePlanFile', () => {
436
474
  test('keeps a future schema behind partial markers invalid rather than unsupported', () => {
437
475
  const plan = path_1.default.join(root, 'partial-future.md');
438
476
  fs_1.default.writeFileSync(plan, `${START}\n{"schema":"compact-slices/v2"}`);
439
- expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'unsupported', schema: 'compact-slices/v2' });
477
+ expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code: 'PLAN_MARKERS' })] });
440
478
  });
441
479
  test('applies scalar limits before classifying a future schema as unsupported', () => {
442
480
  const plan = path_1.default.join(root, 'future-too-large.md');
@@ -733,7 +771,7 @@ describe('validatePlanFile', () => {
733
771
  test('recognizes a parseable future schema when only the END marker is broken', () => {
734
772
  const plan = path_1.default.join(root, 'future-missing-end.md');
735
773
  fs_1.default.writeFileSync(plan, `${START}\n{"schema":"compact-slices/v2"}\n`);
736
- expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'unsupported', schema: 'compact-slices/v2' });
774
+ expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code: 'PLAN_MARKERS' })] });
737
775
  });
738
776
  test('accepts CRLF Markdown with nested headings and fenced code in a required section', () => {
739
777
  const plan = fixture(root);
@@ -160,6 +160,28 @@ describe('native secure-fs Windows source contract', () => {
160
160
  expect(removal).not.toMatch(/\bDeleteFileW\b/);
161
161
  });
162
162
  });
163
+ describe('native secure-fs durable publication source contract', () => {
164
+ const source = () => fs_1.default.readFileSync(path_1.default.resolve(__dirname, '../../../native/secure_fs.cc'), 'utf8').replace(/\r\n/g, '\n');
165
+ it('turns parent-directory fsync failure after publication into a bounded error', () => {
166
+ const native = source();
167
+ const write = native.slice(native.indexOf('napi_value WriteProjectTransaction'), native.indexOf('\n} // namespace'));
168
+ expect(native).toMatch(/if \(fsync\(parent\) != 0\)[\s\S]{0,240}Throw\(env, "secure-fs durable directory sync failed"\)/);
169
+ expect(write).toMatch(/fsync\(parent\) == 0;[\s\S]{0,180}secure-fs durable directory sync failed/);
170
+ const withoutWriteSync = write.replace('fsync(parent) == 0', 'true');
171
+ expect(withoutWriteSync).not.toMatch(/fsync\(parent\) == 0;/);
172
+ expect(withoutWriteSync).not.toMatch(/fsync\(parent\) == 0;[\s\S]{0,180}secure-fs durable directory sync failed/);
173
+ });
174
+ it('degrades only the unsupported Windows directory flush and retains all other durable failures', () => {
175
+ expect(source()).toMatch(/FlushFileBuffers\(parent\.handle\) == 0 && GetLastError\(\) != ERROR_ACCESS_DENIED/);
176
+ expect(source()).toMatch(/if \(directory_sync_failed\).*secure-fs durable directory sync failed/);
177
+ });
178
+ it('does not clean a staging name already consumed by a fenced POSIX rename', () => {
179
+ const write = source().slice(source().indexOf('napi_value WriteProjectTransaction'), source().indexOf('\n} // namespace'));
180
+ expect(write).toContain('const bool cleaned = options.replace || unlinkat(parent, temporary.c_str(), 0) == 0;');
181
+ const reverted = write.replace('options.replace || ', '');
182
+ expect(reverted).not.toContain('const bool cleaned = options.replace || unlinkat(parent, temporary.c_str(), 0) == 0;');
183
+ });
184
+ });
163
185
  describe('native secure-fs POSIX source contract', () => {
164
186
  const source = () => fs_1.default.readFileSync(path_1.default.resolve(__dirname, '../../../native/secure_fs.cc'), 'utf8');
165
187
  it('uses an unpredictable nonce and a bounded retry loop for descriptor-relative staging', () => {
@@ -399,6 +421,38 @@ const nativeFixtureAvailable = ['linux', 'darwin', 'win32'].includes(process.pla
399
421
  && ['x64', 'arm64'].includes(process.arch)
400
422
  && fs_1.default.existsSync(path_1.default.join(__dirname, '../../../prebuilds', `${process.platform}-${process.arch}`, 'secure_fs.node'));
401
423
  const nativeOnly = nativeFixtureAvailable ? describe : describe.skip;
424
+ const testAddonPath = path_1.default.join(__dirname, '../../../native/build/Release/secure_fs_test.node');
425
+ const nativeTestOnly = describe;
426
+ nativeTestOnly('native secure-fs directory-sync fault injection', () => {
427
+ let root;
428
+ let testBinding;
429
+ beforeEach(() => {
430
+ expect(fs_1.default.existsSync(testAddonPath)).toBe(true);
431
+ root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-secure-fs-fsync-'));
432
+ testBinding = require(testAddonPath);
433
+ });
434
+ afterEach(() => { testBinding.setDirectoryFsyncFailureForTests(false); fs_1.default.rmSync(root, { recursive: true, force: true }); });
435
+ it('writes the target then reports the forced post-publication directory-sync failure', () => {
436
+ testBinding.setDirectoryFsyncFailureForTests(true);
437
+ expect(() => testBinding.writeProjectTransaction(root, 'durable.txt', Buffer.from('published'), { mode: 'create', createParents: false })).toThrow('secure-fs durable directory sync failed');
438
+ expect(fs_1.default.readFileSync(path_1.default.join(root, 'durable.txt'), 'utf8')).toBe('published');
439
+ });
440
+ it('publishes an exact fenced replacement with fault injection disabled', () => {
441
+ const target = path_1.default.join(root, 'replace.json');
442
+ fs_1.default.writeFileSync(target, 'before');
443
+ const observed = testBinding.readRegularFile(target, 1024);
444
+ expect(() => testBinding.writeProjectTransaction(root, 'replace.json', Buffer.from('after'), {
445
+ mode: 'replace', expected: observed.bytes, expectedIdentity: observed.identity, createParents: false,
446
+ })).not.toThrow();
447
+ expect(fs_1.default.readFileSync(target, 'utf8')).toBe('after');
448
+ });
449
+ it('keeps the production prebuild free of the test-only control', () => {
450
+ if (!nativeFixtureAvailable)
451
+ return;
452
+ const production = require(path_1.default.join(__dirname, '../../../prebuilds', `${process.platform}-${process.arch}`, 'secure_fs.node'));
453
+ expect(production.setDirectoryFsyncFailureForTests).toBeUndefined();
454
+ });
455
+ });
402
456
  nativeOnly('native secure-fs identity fence fixtures', () => {
403
457
  let root;
404
458
  let binding;
@@ -406,6 +460,11 @@ nativeOnly('native secure-fs identity fence fixtures', () => {
406
460
  root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-secure-fs-identity-'));
407
461
  binding = require(path_1.default.join(__dirname, '../../../prebuilds', `${process.platform}-${process.arch}`, 'secure_fs.node'));
408
462
  });
463
+ it('performs a real artifact-backed publish when the CI prebuild is available', () => {
464
+ const target = path_1.default.join(root, 'durable.txt');
465
+ binding.writeProjectTransaction(root, 'durable.txt', Buffer.from('published'), { mode: 'create', createParents: false });
466
+ expect(fs_1.default.readFileSync(target, 'utf8')).toBe('published');
467
+ });
409
468
  afterEach(() => { fs_1.default.rmSync(root, { recursive: true, force: true }); });
410
469
  it('returns bytes with a canonical native identity from the same regular-file read', () => {
411
470
  const target = path_1.default.join(root, 'sensors.json');
@@ -690,6 +749,7 @@ windowsOnly('native secure-fs Windows handle fixtures', () => {
690
749
  fs.symlinkSync(workerData.outside, workerData.trusted, 'junction');
691
750
  Atomics.add(state, 1, 1);
692
751
  Atomics.notify(state, 1);
752
+ parentPort.postMessage('ready');
693
753
  Atomics.wait(state, 0, 0, 1);
694
754
  fs.rmSync(workerData.trusted);
695
755
  fs.renameSync(workerData.parked, workerData.trusted);
@@ -701,13 +761,30 @@ windowsOnly('native secure-fs Windows handle fixtures', () => {
701
761
  restore();
702
762
  parentPort.postMessage('done');
703
763
  `, { eval: true, workerData: { shared, trusted, parked, outside } });
704
- const done = new Promise((resolve, reject) => {
705
- worker.once('message', () => resolve());
706
- worker.once('error', reject);
764
+ let resolveReady;
765
+ let rejectReady;
766
+ let resolveDone;
767
+ let rejectDone;
768
+ const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
769
+ const done = new Promise((resolve, reject) => { resolveDone = resolve; rejectDone = reject; });
770
+ const readyTimeout = setTimeout(() => rejectReady(new Error('junction swap worker did not become ready')), 5_000);
771
+ worker.on('message', message => {
772
+ if (message === 'ready') {
773
+ clearTimeout(readyTimeout);
774
+ resolveReady();
775
+ }
776
+ if (message === 'done')
777
+ resolveDone();
778
+ });
779
+ worker.once('error', error => {
780
+ clearTimeout(readyTimeout);
781
+ const failure = error instanceof Error ? error : new Error('junction swap worker failed');
782
+ rejectReady(failure);
783
+ rejectDone(failure);
707
784
  });
708
785
  let published = 0;
709
786
  try {
710
- Atomics.wait(state, 1, 0, 5_000);
787
+ await ready;
711
788
  expect(Atomics.load(state, 1)).toBeGreaterThan(0);
712
789
  for (let attempt = 0; attempt < 500; attempt += 1) {
713
790
  try {
@@ -0,0 +1,97 @@
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
+ // Structural guard, same family as symlink-type-is-explicit.test.ts.
7
+ //
8
+ // fsync-ing a DIRECTORY file descriptor is a POSIX-only capability. On Windows
9
+ // `fs.openSync(dir, 'r')` succeeds and the following `fs.fsyncSync(fd)` always
10
+ // fails with EPERM — libuv's mapping of `FlushFileBuffers` on a directory
11
+ // handle, which Win32 rejects categorically. That is not a durability failure
12
+ // (NTFS journals the rename itself); the mechanism simply does not exist there.
13
+ //
14
+ // `atomic-file.fsyncDirSync` is where that decision lives, and it is deliberately
15
+ // narrow: degrade ONLY on win32 + EPERM at the fsync, keep throwing everywhere
16
+ // else. Re-implementing the open/fsync/close sequence inline re-creates the gap
17
+ // without the exception, and it fails on Windows and nowhere else — invisible to
18
+ // every local run, and behind CI's `--bail` it costs one full matrix round to
19
+ // surface. That is exactly how `tests/core/model-policy/store.test.ts` broke the
20
+ // Windows legs of the matrix while all four POSIX legs stayed green.
21
+ //
22
+ // So the rule is about the CALL, not about any one call site: a directory fd may
23
+ // be flushed only through `fsyncDirSync`. This scans `tests` as well as `src` —
24
+ // the regression that motivated it lived in a test double, and a guard that only
25
+ // watched `src` would have reported a clean bill of health.
26
+ const fs_1 = __importDefault(require("fs"));
27
+ const path_1 = __importDefault(require("path"));
28
+ const CLI = path_1.default.join(__dirname, '..', '..');
29
+ const ROOTS = ['src', 'tests'];
30
+ /** The single source of the policy: the one file allowed to flush a directory fd. */
31
+ const SINGLE_SOURCE = path_1.default.join('src', 'core', 'atomic-file.ts');
32
+ /** This guard itself, which must quote the forbidden shape to describe and test it. */
33
+ const THIS_GUARD = path_1.default.join('tests', 'structural', 'directory-fsync-is-single-source.test.ts');
34
+ function sourceFiles(dir) {
35
+ const out = [];
36
+ for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
37
+ const full = path_1.default.join(dir, entry.name);
38
+ if (entry.isDirectory())
39
+ out.push(...sourceFiles(full));
40
+ else if (entry.name.endsWith('.ts'))
41
+ out.push(full);
42
+ }
43
+ return out;
44
+ }
45
+ /** True when `lines[i]` opens something that reads as a directory. Deliberately
46
+ * shallow — it recognises the shapes this codebase actually writes. Something
47
+ * cleverer that defeats it should be calling `fsyncDirSync` instead. */
48
+ function opensADirectory(line) {
49
+ const open = /\bfs\.openSync\s*\(\s*([^,]+),/.exec(line);
50
+ if (!open)
51
+ return false;
52
+ const target = open[1];
53
+ return /\bpath\.dirname\s*\(|\bdirname\s*\(/.test(target) || /\b(dir|Dir|root|Root)\b|Dir\b/.test(target);
54
+ }
55
+ /** True when the fd opened at `lines[i]` is flushed within the next few lines. */
56
+ function flushedNearby(lines, i) {
57
+ return /\bfsyncSync\s*\(/.test(lines.slice(i, i + 4).join('\n'));
58
+ }
59
+ describe('directory fsync has exactly one implementation', () => {
60
+ it('no file outside atomic-file.ts opens a directory and flushes it inline', () => {
61
+ const offenders = [];
62
+ for (const root of ROOTS) {
63
+ for (const file of sourceFiles(path_1.default.join(CLI, root))) {
64
+ const relative = path_1.default.relative(CLI, file);
65
+ if (relative === SINGLE_SOURCE || relative === THIS_GUARD)
66
+ continue;
67
+ const lines = fs_1.default.readFileSync(file, 'utf-8').split('\n');
68
+ lines.forEach((line, i) => {
69
+ if (!opensADirectory(line) || !flushedNearby(lines, i))
70
+ return;
71
+ offenders.push(`${relative}:${i + 1}: ${line.trim()}`);
72
+ });
73
+ }
74
+ }
75
+ expect(offenders).toEqual([]);
76
+ });
77
+ it('the guard actually rejects the shape that broke the Windows matrix', () => {
78
+ // Without this, a guard whose regex silently stopped matching would read as
79
+ // a clean bill of health forever — the failure mode of every structural test.
80
+ const rejects = (lines, i) => opensADirectory(lines[i]) && flushedNearby(lines, i);
81
+ // Verbatim shape of the store.test.ts regression this guard exists to stop.
82
+ const regression = [
83
+ 'fs.renameSync(temporary, file);',
84
+ "const parent = fs.openSync(path.dirname(file), 'r');",
85
+ 'try { fs.fsyncSync(parent); } finally { fs.closeSync(parent); }',
86
+ ];
87
+ expect(rejects(regression, 1)).toBe(true);
88
+ // The sanctioned replacement carries no directory open at all.
89
+ expect(regression.map((_, i) => i).some(i => rejects(['fsyncDirSync(path.dirname(file));'], 0))).toBe(false);
90
+ // Flushing a FILE descriptor stays legal — that is a real, portable fsync.
91
+ const fileFlush = ["const fd = fs.openSync(temporary, 'wx', 0o600);", 'fs.fsyncSync(fd);'];
92
+ expect(rejects(fileFlush, 0)).toBe(false);
93
+ // Opening a directory without flushing it (readdir-style handles) stays legal.
94
+ const openOnly = ["const parent = fs.openSync(path.dirname(file), 'r');", 'fs.closeSync(parent);'];
95
+ expect(rejects(openOnly, 0)).toBe(false);
96
+ });
97
+ });
@@ -35,4 +35,18 @@ describe('native release artifacts', () => {
35
35
  expect(gitignore).toContain('cli/native-artifacts/');
36
36
  expect(tracked).toBe('');
37
37
  });
38
+ it('builds the fault-injection addon before every Jest suite without publishing it', () => {
39
+ const pkg = JSON.parse(fs_1.default.readFileSync(path_1.default.join(CLI_ROOT, 'package.json'), 'utf8'));
40
+ expect(pkg.scripts['native:test-build']).toContain('secure_fs_test');
41
+ for (const name of ['ci.yml', 'release.yml']) {
42
+ const workflow = fs_1.default.readFileSync(path_1.default.join(REPO_ROOT, '.github', 'workflows', name), 'utf8');
43
+ const testBuild = workflow.indexOf('run: npm run native:test-build');
44
+ const jest = workflow.indexOf('run: npx jest --runInBand --bail');
45
+ expect(testBuild).toBeGreaterThanOrEqual(0);
46
+ expect(testBuild).toBeLessThan(jest);
47
+ expect(workflow).toContain("'native/build/Release', 'secure_fs_test.node'");
48
+ expect(workflow).not.toContain('path: cli/prebuilds/${{ matrix.target }}/secure_fs_test.node');
49
+ expect(workflow).not.toContain('secure_fs_test.node\n');
50
+ }
51
+ });
38
52
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "9.8.0",
3
+ "version": "9.9.0",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"
@@ -18,7 +18,8 @@
18
18
  "doc": "docs"
19
19
  },
20
20
  "scripts": {
21
- "native:build": "node-gyp rebuild --directory native && node scripts/package-native.js",
21
+ "native:build": "node-gyp configure --directory native && node-gyp build --directory native -- secure_fs && node scripts/package-native.js",
22
+ "native:test-build": "node-gyp configure --directory native && node-gyp build --directory native -- secure_fs_test",
22
23
  "native:package-check": "node scripts/assert-native-package.js",
23
24
  "build": "tsc && chmod +x dist/src/index.js",
24
25
  "typecheck": "tsc --noEmit",
Binary file
Binary file
Binary file