@jamaynor/hal-config 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +84 -0
- package/index.js +3 -0
- package/lib/config.js +675 -0
- package/package.json +23 -0
- package/publish.ps1 +30 -0
- package/security/access-control.js +308 -0
- package/security/governor.js +313 -0
- package/security/index.js +31 -0
- package/security/redactor.js +129 -0
- package/security/sanitizer.js +571 -0
- package/test/config-io.test.js +326 -0
- package/test/security.test.js +488 -0
- package/test/test-utils.test.js +360 -0
- package/test/test.js +586 -0
- package/test-utils.js +255 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config-io.test
|
|
3
|
+
*
|
|
4
|
+
* Responsibility: Tests for resolveWorkspace, loadSkillConfig, and
|
|
5
|
+
* saveSkillConfig added in M-2.5/2.6.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { dirname } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { makeTmpDir, writeJson, cleanup } from '../test-utils.js';
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
// Fresh dynamic import for each test to reset module-level cache
|
|
20
|
+
async function freshImport() {
|
|
21
|
+
const modPath = path.resolve(__dirname, '../lib/config.js');
|
|
22
|
+
const url = `file:///${modPath.replace(/\\/g, '/')}?t=${Date.now()}`;
|
|
23
|
+
return import(url);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// resolveWorkspace
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
describe('resolveWorkspace()', () => {
|
|
31
|
+
let hal;
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
hal = await freshImport();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns explicit opts.workspace when provided', () => {
|
|
38
|
+
const result = hal.resolveWorkspace({ workspace: '/explicit/path' });
|
|
39
|
+
assert.equal(result, path.resolve('/explicit/path'));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('explicit opts.workspace takes precedence over all env vars', () => {
|
|
43
|
+
const origSkill = process.env.HAL_TEST_SKILL_MASTER_WORKSPACE;
|
|
44
|
+
const origAgent = process.env.HAL_AGENT_WORKSPACE;
|
|
45
|
+
process.env.HAL_TEST_SKILL_MASTER_WORKSPACE = '/skill/env/path';
|
|
46
|
+
process.env.HAL_AGENT_WORKSPACE = '/agent/env/path';
|
|
47
|
+
try {
|
|
48
|
+
const result = hal.resolveWorkspace({
|
|
49
|
+
workspace: '/explicit/wins',
|
|
50
|
+
envPrefix: 'TEST_SKILL',
|
|
51
|
+
fallback: '/fallback/path',
|
|
52
|
+
});
|
|
53
|
+
assert.equal(result, path.resolve('/explicit/wins'));
|
|
54
|
+
} finally {
|
|
55
|
+
if (origSkill === undefined) delete process.env.HAL_TEST_SKILL_MASTER_WORKSPACE;
|
|
56
|
+
else process.env.HAL_TEST_SKILL_MASTER_WORKSPACE = origSkill;
|
|
57
|
+
if (origAgent === undefined) delete process.env.HAL_AGENT_WORKSPACE;
|
|
58
|
+
else process.env.HAL_AGENT_WORKSPACE = origAgent;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('reads HAL_{envPrefix}_MASTER_WORKSPACE when envPrefix is set', () => {
|
|
63
|
+
const key = 'HAL_TEST_SKILL_MASTER_WORKSPACE';
|
|
64
|
+
const orig = process.env[key];
|
|
65
|
+
process.env[key] = '/from/skill/env';
|
|
66
|
+
try {
|
|
67
|
+
const result = hal.resolveWorkspace({ envPrefix: 'TEST_SKILL' });
|
|
68
|
+
assert.equal(result, path.resolve('/from/skill/env'));
|
|
69
|
+
} finally {
|
|
70
|
+
if (orig === undefined) delete process.env[key];
|
|
71
|
+
else process.env[key] = orig;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('falls through to HAL_AGENT_WORKSPACE when skill-specific env var is unset', () => {
|
|
76
|
+
const skillKey = 'HAL_PLAN_DAY_MASTER_WORKSPACE';
|
|
77
|
+
const agentKey = 'HAL_AGENT_WORKSPACE';
|
|
78
|
+
const origSkill = process.env[skillKey];
|
|
79
|
+
const origAgent = process.env[agentKey];
|
|
80
|
+
delete process.env[skillKey];
|
|
81
|
+
process.env[agentKey] = '/from/agent/env';
|
|
82
|
+
try {
|
|
83
|
+
const result = hal.resolveWorkspace({ envPrefix: 'PLAN_DAY' });
|
|
84
|
+
assert.equal(result, path.resolve('/from/agent/env'));
|
|
85
|
+
} finally {
|
|
86
|
+
if (origSkill === undefined) delete process.env[skillKey];
|
|
87
|
+
else process.env[skillKey] = origSkill;
|
|
88
|
+
if (origAgent === undefined) delete process.env[agentKey];
|
|
89
|
+
else process.env[agentKey] = origAgent;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('falls through to opts.fallback when no env vars are set', () => {
|
|
94
|
+
const skillKey = 'HAL_TEST_SKILL_MASTER_WORKSPACE';
|
|
95
|
+
const agentKey = 'HAL_AGENT_WORKSPACE';
|
|
96
|
+
const origSkill = process.env[skillKey];
|
|
97
|
+
const origAgent = process.env[agentKey];
|
|
98
|
+
delete process.env[skillKey];
|
|
99
|
+
delete process.env[agentKey];
|
|
100
|
+
try {
|
|
101
|
+
const result = hal.resolveWorkspace({
|
|
102
|
+
envPrefix: 'TEST_SKILL',
|
|
103
|
+
fallback: '/my/fallback',
|
|
104
|
+
});
|
|
105
|
+
assert.equal(result, path.resolve('/my/fallback'));
|
|
106
|
+
} finally {
|
|
107
|
+
if (origSkill === undefined) delete process.env[skillKey];
|
|
108
|
+
else process.env[skillKey] = origSkill;
|
|
109
|
+
if (origAgent === undefined) delete process.env[agentKey];
|
|
110
|
+
else process.env[agentKey] = origAgent;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('falls through to process.cwd() when nothing is configured', () => {
|
|
115
|
+
const skillKey = 'HAL_NOTHING_MASTER_WORKSPACE';
|
|
116
|
+
const agentKey = 'HAL_AGENT_WORKSPACE';
|
|
117
|
+
const origSkill = process.env[skillKey];
|
|
118
|
+
const origAgent = process.env[agentKey];
|
|
119
|
+
delete process.env[skillKey];
|
|
120
|
+
delete process.env[agentKey];
|
|
121
|
+
try {
|
|
122
|
+
const result = hal.resolveWorkspace({ envPrefix: 'NOTHING' });
|
|
123
|
+
assert.equal(result, path.resolve(process.cwd()));
|
|
124
|
+
} finally {
|
|
125
|
+
if (origSkill === undefined) delete process.env[skillKey];
|
|
126
|
+
else process.env[skillKey] = origSkill;
|
|
127
|
+
if (origAgent === undefined) delete process.env[agentKey];
|
|
128
|
+
else process.env[agentKey] = origAgent;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('returns cwd when opts is empty', () => {
|
|
133
|
+
const agentKey = 'HAL_AGENT_WORKSPACE';
|
|
134
|
+
const origAgent = process.env[agentKey];
|
|
135
|
+
delete process.env[agentKey];
|
|
136
|
+
try {
|
|
137
|
+
const result = hal.resolveWorkspace({});
|
|
138
|
+
assert.equal(result, path.resolve(process.cwd()));
|
|
139
|
+
} finally {
|
|
140
|
+
if (origAgent === undefined) delete process.env[agentKey];
|
|
141
|
+
else process.env[agentKey] = origAgent;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns cwd when opts is omitted', () => {
|
|
146
|
+
const agentKey = 'HAL_AGENT_WORKSPACE';
|
|
147
|
+
const origAgent = process.env[agentKey];
|
|
148
|
+
delete process.env[agentKey];
|
|
149
|
+
try {
|
|
150
|
+
const result = hal.resolveWorkspace();
|
|
151
|
+
assert.equal(result, path.resolve(process.cwd()));
|
|
152
|
+
} finally {
|
|
153
|
+
if (origAgent === undefined) delete process.env[agentKey];
|
|
154
|
+
else process.env[agentKey] = origAgent;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('all returned paths are absolute', () => {
|
|
159
|
+
const result = hal.resolveWorkspace({ workspace: 'relative/path' });
|
|
160
|
+
assert.ok(path.isAbsolute(result), `Expected absolute path, got: ${result}`);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('env var path is resolved to absolute', () => {
|
|
164
|
+
const key = 'HAL_AGENT_WORKSPACE';
|
|
165
|
+
const orig = process.env[key];
|
|
166
|
+
process.env[key] = 'relative/agent/path';
|
|
167
|
+
try {
|
|
168
|
+
const result = hal.resolveWorkspace({});
|
|
169
|
+
assert.ok(path.isAbsolute(result), `Expected absolute path, got: ${result}`);
|
|
170
|
+
} finally {
|
|
171
|
+
if (orig === undefined) delete process.env[key];
|
|
172
|
+
else process.env[key] = orig;
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ============================================================================
|
|
178
|
+
// loadSkillConfig
|
|
179
|
+
// ============================================================================
|
|
180
|
+
|
|
181
|
+
describe('loadSkillConfig()', () => {
|
|
182
|
+
let hal;
|
|
183
|
+
let tmpDir;
|
|
184
|
+
|
|
185
|
+
beforeEach(async () => {
|
|
186
|
+
hal = await freshImport();
|
|
187
|
+
tmpDir = makeTmpDir('hsc-load-');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
afterEach(() => {
|
|
191
|
+
cleanup(tmpDir);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('returns {} for a missing config file', () => {
|
|
195
|
+
const result = hal.loadSkillConfig('no-such-skill', tmpDir);
|
|
196
|
+
assert.deepStrictEqual(result, {});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('returns parsed JSON for an existing config file', () => {
|
|
200
|
+
const configPath = path.join(tmpDir, 'hal', 'config', 'my-skill.json');
|
|
201
|
+
writeJson(configPath, { version: '1.0', key: 'value' });
|
|
202
|
+
const result = hal.loadSkillConfig('my-skill', tmpDir);
|
|
203
|
+
assert.equal(result.version, '1.0');
|
|
204
|
+
assert.equal(result.key, 'value');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('reads from {workspace}/hal/config/{skillName}.json', () => {
|
|
208
|
+
const expected = { configured: true, count: 42 };
|
|
209
|
+
const configPath = path.join(tmpDir, 'hal', 'config', 'test-skill.json');
|
|
210
|
+
writeJson(configPath, expected);
|
|
211
|
+
const result = hal.loadSkillConfig('test-skill', tmpDir);
|
|
212
|
+
assert.deepStrictEqual(result, expected);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('throws SyntaxError on corrupt JSON', () => {
|
|
216
|
+
const configDir = path.join(tmpDir, 'hal', 'config');
|
|
217
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
218
|
+
fs.writeFileSync(path.join(configDir, 'bad-skill.json'), '{not valid json', 'utf8');
|
|
219
|
+
assert.throws(
|
|
220
|
+
() => hal.loadSkillConfig('bad-skill', tmpDir),
|
|
221
|
+
{ name: 'SyntaxError' }
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ============================================================================
|
|
227
|
+
// saveSkillConfig
|
|
228
|
+
// ============================================================================
|
|
229
|
+
|
|
230
|
+
describe('saveSkillConfig()', () => {
|
|
231
|
+
let hal;
|
|
232
|
+
let tmpDir;
|
|
233
|
+
|
|
234
|
+
beforeEach(async () => {
|
|
235
|
+
hal = await freshImport();
|
|
236
|
+
tmpDir = makeTmpDir('hsc-save-');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
afterEach(() => {
|
|
240
|
+
cleanup(tmpDir);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('creates the file and parent directories on first write', () => {
|
|
244
|
+
hal.saveSkillConfig('new-skill', tmpDir, { initialized: true });
|
|
245
|
+
const filePath = path.join(tmpDir, 'hal', 'config', 'new-skill.json');
|
|
246
|
+
assert.ok(fs.existsSync(filePath), 'config file must be created');
|
|
247
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
248
|
+
assert.equal(parsed.initialized, true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('deep merge: new top-level keys are added, existing keys preserved', () => {
|
|
252
|
+
const configPath = path.join(tmpDir, 'hal', 'config', 'test-skill.json');
|
|
253
|
+
writeJson(configPath, { existing: 'keep-me', count: 1 });
|
|
254
|
+
|
|
255
|
+
hal.saveSkillConfig('test-skill', tmpDir, { newKey: 'added' });
|
|
256
|
+
|
|
257
|
+
const result = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
258
|
+
assert.equal(result.existing, 'keep-me');
|
|
259
|
+
assert.equal(result.count, 1);
|
|
260
|
+
assert.equal(result.newKey, 'added');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('deep merge: overwritten top-level keys are replaced', () => {
|
|
264
|
+
const configPath = path.join(tmpDir, 'hal', 'config', 'test-skill.json');
|
|
265
|
+
writeJson(configPath, { key: 'old-value', other: 'unchanged' });
|
|
266
|
+
|
|
267
|
+
hal.saveSkillConfig('test-skill', tmpDir, { key: 'new-value' });
|
|
268
|
+
|
|
269
|
+
const result = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
270
|
+
assert.equal(result.key, 'new-value');
|
|
271
|
+
assert.equal(result.other, 'unchanged');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('deep merge: nested objects are merged recursively', () => {
|
|
275
|
+
const configPath = path.join(tmpDir, 'hal', 'config', 'test-skill.json');
|
|
276
|
+
writeJson(configPath, {
|
|
277
|
+
governor: {
|
|
278
|
+
callers: ['agent-a', 'agent-b'],
|
|
279
|
+
spendWarnThreshold: 5,
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
hal.saveSkillConfig('test-skill', tmpDir, {
|
|
284
|
+
governor: { spendWarnThreshold: 10 },
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const result = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
288
|
+
// Existing nested key preserved
|
|
289
|
+
assert.deepStrictEqual(result.governor.callers, ['agent-a', 'agent-b']);
|
|
290
|
+
// Nested key updated
|
|
291
|
+
assert.equal(result.governor.spendWarnThreshold, 10);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('deep merge: arrays are replaced atomically', () => {
|
|
295
|
+
const configPath = path.join(tmpDir, 'hal', 'config', 'test-skill.json');
|
|
296
|
+
writeJson(configPath, { tags: ['alpha', 'beta', 'gamma'] });
|
|
297
|
+
|
|
298
|
+
hal.saveSkillConfig('test-skill', tmpDir, { tags: ['delta'] });
|
|
299
|
+
|
|
300
|
+
const result = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
301
|
+
assert.deepStrictEqual(result.tags, ['delta']);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('writes valid JSON with 2-space indentation', () => {
|
|
305
|
+
hal.saveSkillConfig('indent-skill', tmpDir, { a: 1, b: { c: 2 } });
|
|
306
|
+
const filePath = path.join(tmpDir, 'hal', 'config', 'indent-skill.json');
|
|
307
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
308
|
+
// 2-space indent: second line should start with two spaces
|
|
309
|
+
const lines = raw.split('\n');
|
|
310
|
+
assert.ok(lines.length > 1, 'should be multi-line JSON');
|
|
311
|
+
assert.ok(lines[1].startsWith(' '), `second line should start with 2 spaces: "${lines[1]}"`);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('written file ends with a trailing newline', () => {
|
|
315
|
+
hal.saveSkillConfig('newline-skill', tmpDir, { x: true });
|
|
316
|
+
const filePath = path.join(tmpDir, 'hal', 'config', 'newline-skill.json');
|
|
317
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
318
|
+
assert.ok(raw.endsWith('\n'), 'file must end with a newline character');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('always writes to {workspace}/hal/config/{skillName}.json', () => {
|
|
322
|
+
hal.saveSkillConfig('location-check', tmpDir, { ok: true });
|
|
323
|
+
const expected = path.join(tmpDir, 'hal', 'config', 'location-check.json');
|
|
324
|
+
assert.ok(fs.existsSync(expected), `file must be at ${expected}`);
|
|
325
|
+
});
|
|
326
|
+
});
|