agents-handoff 0.0.0-stage → 2.0.2
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/CHANGELOG.md +150 -0
- package/LICENSE +21 -0
- package/README.md +110 -2
- package/SKILL.md +147 -0
- package/capability-registry.json +27 -0
- package/docs/ARCHITECTURE.md +164 -0
- package/docs/CHANGELOG.md +151 -0
- package/docs/CLI.md +196 -0
- package/docs/COMPATIBILITY.md +124 -0
- package/docs/CONTRIBUTING.md +134 -0
- package/docs/FORMAT.md +157 -0
- package/docs/INSTALL.md +179 -0
- package/docs/INTEGRATION.md +188 -0
- package/docs/LEVEL4.md +202 -0
- package/docs/LEVEL5.md +96 -0
- package/docs/PERMISSIONS.md +145 -0
- package/docs/PROVENANCE.md +83 -0
- package/docs/SECURITY.md +93 -0
- package/docs/SESSIONS.md +66 -0
- package/docs/TROUBLESHOOTING.md +158 -0
- package/docs/UNINSTALL.md +122 -0
- package/docs/UPGRADE.md +139 -0
- package/docs/_config.yml +16 -0
- package/docs/_data/nav.yml +36 -0
- package/docs/_layouts/default.html +31 -0
- package/docs/assets/style.css +88 -0
- package/docs/index.md +83 -0
- package/handoff.config.example.json +35 -0
- package/handoff.config.schema.json +117 -0
- package/install/CHANGELOG.md +48 -0
- package/install/README.md +76 -0
- package/install/install.mjs +856 -0
- package/install/package.json +39 -0
- package/package.json +66 -4
- package/permission-policy.json +33 -0
- package/refs/ADAPTERS.md +33 -0
- package/refs/bootstrap.md +59 -0
- package/refs/brief-checklist.md +79 -0
- package/refs/handbook.md +58 -0
- package/refs/protocol.md +117 -0
- package/refs/roles.md +75 -0
- package/refs/validator.md +73 -0
- package/schemas/handoff.schema.json +275 -0
- package/skill.json +147 -0
- package/templates/HANDOFF.llm.schema.json +144 -0
- package/templates/HANDOFF.template.md +40 -0
- package/tests/acceptance/acceptance.yaml +209 -0
- package/tests/fixtures/minimal-transcript.jsonl +2 -0
- package/tools/agent-handoff.mjs +410 -0
- package/tools/capability-registry.mjs +120 -0
- package/tools/handoff.mjs +398 -0
- package/tools/handoff.test.mjs +465 -0
- package/tools/lib/handoff-root.mjs +161 -0
- package/tools/runtime-engine.mjs +330 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// handoff.test.mjs v1.3.0 — zero-dep suite for the canonical engine + runtime MVP.
|
|
3
|
+
// Run: node tools/handoff.test.mjs (also wired as repo-upstream npm test)
|
|
4
|
+
// Exercises the REAL CLI through spawnSync, hermetically: every test gets a scratch
|
|
5
|
+
// HANDOFFS_ROOT (tmpdir) removed afterwards. Covers plan Task 3 (engine: build,
|
|
6
|
+
// verify integrity incl. tamper, list, error exits, idempotence), Task 4 (capability
|
|
7
|
+
// registry: honest health verdicts), Task 5 (permission + execution engine:
|
|
8
|
+
// evaluate-before-execute, denial, needs-auth, bounded exit-code capture) and
|
|
9
|
+
// Task 6 (checkpoint/resume: interrupt-resume without duplication or loss, honest
|
|
10
|
+
// missing/corrupt checkpoint failures). node:test + assert only.
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
const REPO = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), '..');
|
|
19
|
+
const ENGINE = path.join(REPO, 'tools', 'handoff.mjs');
|
|
20
|
+
const FIXTURE = path.join(REPO, 'tests', 'fixtures', 'minimal-transcript.jsonl');
|
|
21
|
+
|
|
22
|
+
// Hermetic by construction: this suite never writes into the repo. When the
|
|
23
|
+
// checker supplies AGENT_HANDOFF_STATE_DIR it is used as-is (the checker owns
|
|
24
|
+
// cleanup); otherwise the suite makes its own scratch root and removes it on
|
|
25
|
+
// exit, so a bare `node tools/handoff.test.mjs` is side-effect-free too.
|
|
26
|
+
const STATE_DIR = process.env.AGENT_HANDOFF_STATE_DIR
|
|
27
|
+
? path.resolve(process.env.AGENT_HANDOFF_STATE_DIR)
|
|
28
|
+
: fs.mkdtempSync(path.join(os.tmpdir(), 'ah-suite-'));
|
|
29
|
+
const OWN_STATE_DIR = process.env.AGENT_HANDOFF_STATE_DIR ? null : STATE_DIR;
|
|
30
|
+
process.on('exit', () => {
|
|
31
|
+
if (OWN_STATE_DIR) { try { fs.rmSync(OWN_STATE_DIR, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
32
|
+
});
|
|
33
|
+
const stateEnv = () => Object.assign({}, process.env, { AGENT_HANDOFF_STATE_DIR: STATE_DIR });
|
|
34
|
+
|
|
35
|
+
function run(args, root) {
|
|
36
|
+
return spawnSync(process.execPath, [ENGINE, ...args], {
|
|
37
|
+
cwd: REPO,
|
|
38
|
+
env: Object.assign({}, process.env, root ? { HANDOFFS_ROOT: root } : {}),
|
|
39
|
+
encoding: 'utf8',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function scratch() {
|
|
43
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'ah-test-'));
|
|
44
|
+
}
|
|
45
|
+
function sessionDir(root, proj) {
|
|
46
|
+
return path.join(root, 'projects', proj, 'minimal-transcript');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test('build from deterministic fixture succeeds with expected marker', () => {
|
|
50
|
+
const root = scratch();
|
|
51
|
+
try {
|
|
52
|
+
const r = run(['build', '--source', FIXTURE, '--project', 'test-suite-proj'], root);
|
|
53
|
+
assert.equal(r.status, 0, 'exit 0, stderr: ' + r.stderr);
|
|
54
|
+
assert.ok(r.stdout.includes('handoff: built'), 'marker: ' + r.stdout);
|
|
55
|
+
assert.ok(r.stdout.includes('turns=2'), 'fixture turn count: ' + r.stdout);
|
|
56
|
+
const man = JSON.parse(fs.readFileSync(path.join(sessionDir(root, 'test-suite-proj'), 'manifest.json'), 'utf8'));
|
|
57
|
+
assert.equal(man.session, 'minimal-transcript');
|
|
58
|
+
assert.equal(man.project, 'test-suite-proj');
|
|
59
|
+
assert.equal(man.turn_count, 2);
|
|
60
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('verify detects manifest tampering (integrity oracle)', () => {
|
|
64
|
+
const root = scratch();
|
|
65
|
+
try {
|
|
66
|
+
const b = run(['build', '--source', FIXTURE, '--project', 'test-suite-proj'], root);
|
|
67
|
+
assert.equal(b.status, 0, 'setup build failed: ' + b.stderr);
|
|
68
|
+
const manP = path.join(sessionDir(root, 'test-suite-proj'), 'manifest.json');
|
|
69
|
+
const man = JSON.parse(fs.readFileSync(manP, 'utf8'));
|
|
70
|
+
man.turn_count = 99; // tamper WITHOUT fixing manifest_sha256
|
|
71
|
+
fs.writeFileSync(manP, JSON.stringify(man, null, 2));
|
|
72
|
+
const v = run(['verify', 'minimal-transcript'], root);
|
|
73
|
+
assert.equal(v.status, 1, 'tampered verify must exit 1, got ' + v.status + ' out=' + v.stdout);
|
|
74
|
+
assert.ok(/FAIL .*manifest tampered/.test(v.stdout + v.stderr), 'tamper message: ' + v.stdout + v.stderr);
|
|
75
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('verify passes on an untampered handoff', () => {
|
|
79
|
+
const root = scratch();
|
|
80
|
+
try {
|
|
81
|
+
const b = run(['build', '--source', FIXTURE, '--project', 'test-suite-proj'], root);
|
|
82
|
+
assert.equal(b.status, 0, 'setup build failed: ' + b.stderr);
|
|
83
|
+
const v = run(['verify', 'minimal-transcript'], root);
|
|
84
|
+
assert.equal(v.status, 0, 'verify exit: ' + v.status + ' out=' + v.stdout + v.stderr);
|
|
85
|
+
assert.ok(v.stdout.includes('PASS '), 'PASS marker: ' + v.stdout);
|
|
86
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('list shows the built session under its project', () => {
|
|
90
|
+
const root = scratch();
|
|
91
|
+
try {
|
|
92
|
+
const b = run(['build', '--source', FIXTURE, '--project', 'test-suite-proj'], root);
|
|
93
|
+
assert.equal(b.status, 0, 'setup build failed: ' + b.stderr);
|
|
94
|
+
const l = run(['list', 'test-suite-proj'], root);
|
|
95
|
+
assert.equal(l.status, 0, 'list exit: ' + l.status + ' err=' + l.stderr);
|
|
96
|
+
assert.ok(l.stdout.includes('[test-suite-proj]'), 'project row: ' + l.stdout);
|
|
97
|
+
assert.ok(l.stdout.includes('minimal-transcript'), 'session row: ' + l.stdout);
|
|
98
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('malformed .jsonl source is rejected with non-zero exit', () => {
|
|
102
|
+
const root = scratch();
|
|
103
|
+
try {
|
|
104
|
+
const bad = path.join(root, 'garbage.jsonl');
|
|
105
|
+
fs.writeFileSync(bad, 'not json at all\n{"broken": tru}\nalso junk\n');
|
|
106
|
+
const r = run(['build', '--source', bad, '--project', 'test-suite-proj'], root);
|
|
107
|
+
assert.equal(r.status, 4, 'malformed source must exit 4, got ' + r.status + ' out=' + r.stdout);
|
|
108
|
+
assert.ok(/no usable turns/.test(r.stderr), 'rejection message: ' + r.stderr);
|
|
109
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('missing source file is rejected with non-zero exit', () => {
|
|
113
|
+
const root = scratch();
|
|
114
|
+
try {
|
|
115
|
+
const r = run(['build', '--source', path.join(root, 'does-not-exist.jsonl'), '--project', 'p'], root);
|
|
116
|
+
assert.equal(r.status, 2, 'missing source must exit 2, got ' + r.status + ' out=' + r.stdout);
|
|
117
|
+
assert.ok(/source not found/.test(r.stderr), 'message: ' + r.stderr);
|
|
118
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('usage errors exit non-zero (verify without prefix, bad command)', () => {
|
|
122
|
+
const root = scratch();
|
|
123
|
+
try {
|
|
124
|
+
const v = run(['verify'], root);
|
|
125
|
+
assert.equal(v.status, 2, 'verify-without-prefix must exit 2, got ' + v.status);
|
|
126
|
+
const x = run(['definitely-not-a-command'], root);
|
|
127
|
+
assert.equal(x.status, 2, 'unknown command must exit 2, got ' + x.status);
|
|
128
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ---- Plan Task 4: capability registry (tools/capability-registry.mjs) ----
|
|
132
|
+
|
|
133
|
+
const REG = path.join(REPO, 'capability-registry.json');
|
|
134
|
+
const REGCLI = path.join(REPO, 'tools', 'capability-registry.mjs');
|
|
135
|
+
|
|
136
|
+
function runReg(args) {
|
|
137
|
+
return spawnSync(process.execPath, [REGCLI, ...args], { cwd: REPO, env: stateEnv(), encoding: 'utf8' });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
test('capability registry check: all declared capabilities probe healthy through the CLI', () => {
|
|
141
|
+
const r = runReg(['check', '--registry', REG]);
|
|
142
|
+
assert.equal(r.status, 0, 'check exit: ' + r.status + ' out=' + r.stdout + r.stderr);
|
|
143
|
+
for (const id of ['filesystem.read', 'filesystem.write', 'process.execute'])
|
|
144
|
+
assert.ok(r.stdout.includes('[healthy] ' + id), 'healthy verdict for ' + id + ': ' + r.stdout);
|
|
145
|
+
const state = JSON.parse(fs.readFileSync(path.join(STATE_DIR, 'capability-state.json'), 'utf8'));
|
|
146
|
+
assert.ok(Array.isArray(state.results) && state.results.length >= 3, 'filesystem-backed state written');
|
|
147
|
+
for (const res of state.results) assert.ok(['healthy', 'unhealthy', 'unknown'].includes(res.verdict), 'verdict class: ' + res.verdict);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('capability registry: unknown capability id reported honestly, non-zero exit', () => {
|
|
151
|
+
const r = runReg(['check', '--registry', REG, 'no-such-capability']);
|
|
152
|
+
assert.equal(r.status, 4, 'unknown id must exit 4, got ' + r.status);
|
|
153
|
+
assert.ok(/unknown capability id/.test(r.stdout + r.stderr), 'message: ' + r.stdout + r.stderr);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('capability registry: unknown probe kind is never guessed healthy', () => {
|
|
157
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-reg-'));
|
|
158
|
+
try {
|
|
159
|
+
const bad = path.join(root, 'bad-registry.json');
|
|
160
|
+
fs.writeFileSync(bad, JSON.stringify({ schema_version: 'x', capabilities: [
|
|
161
|
+
{ id: 'telepathy.link', kind: 'telepathy', required: true },
|
|
162
|
+
{ id: 'optional.extra', kind: 'file-exists', target: 'does-not-matter.txt', required: false },
|
|
163
|
+
] }));
|
|
164
|
+
const r = runReg(['check', '--registry', bad]);
|
|
165
|
+
assert.equal(r.status, 1, 'unknown kind on a REQUIRED capability must fail the gate, got ' + r.status);
|
|
166
|
+
assert.ok(r.stdout.includes('[unknown] telepathy.link'), 'unknown verdict: ' + r.stdout);
|
|
167
|
+
assert.ok(r.stdout.includes('no guessed verdicts'), 'honesty note: ' + r.stdout);
|
|
168
|
+
assert.ok(r.stdout.includes('[healthy] optional.extra') || r.stdout.includes('[unhealthy] optional.extra'), 'optional probed anyway: ' + r.stdout);
|
|
169
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('capability registry: missing registry file is a config error (exit 2)', () => {
|
|
173
|
+
const r = runReg(['check', '--registry', path.join(os.tmpdir(), 'ah-no-such-registry.json')]);
|
|
174
|
+
assert.equal(r.status, 2, 'missing registry must exit 2, got ' + r.status);
|
|
175
|
+
assert.ok(/registry not found/.test(r.stdout + r.stderr), 'message: ' + r.stdout + r.stderr);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ---- Plan Task 5: permission engine + execution engine skeleton ----
|
|
179
|
+
|
|
180
|
+
const ENGINECLI = path.join(REPO, 'tools', 'runtime-engine.mjs');
|
|
181
|
+
const EXEC_DIR = path.join(STATE_DIR, 'executions');
|
|
182
|
+
|
|
183
|
+
function runEng(args) {
|
|
184
|
+
return spawnSync(process.execPath, [ENGINECLI, ...args], { cwd: REPO, env: stateEnv(), encoding: 'utf8' });
|
|
185
|
+
}
|
|
186
|
+
// record_path is REPO-relative when it can be expressed that way, and ABSOLUTE when the
|
|
187
|
+
// state dir lives on another volume (cross-drive path.relative returns an absolute path).
|
|
188
|
+
// Resolve both honestly instead of assuming relative.
|
|
189
|
+
const resolveRecord = rp => (path.isAbsolute(rp) ? rp : path.join(REPO, rp));
|
|
190
|
+
|
|
191
|
+
function latestRecord(filterFn) {
|
|
192
|
+
const files = fs.readdirSync(EXEC_DIR).filter(f => f.endsWith('.json')).sort().reverse();
|
|
193
|
+
for (const f of files) {
|
|
194
|
+
const r = JSON.parse(fs.readFileSync(path.join(EXEC_DIR, f), 'utf8'));
|
|
195
|
+
if (filterFn(r)) return r;
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Acceptance scenario D — safe execution through the runtime.
|
|
201
|
+
test('runtime: safe execution path (D) — allowed R0 read executes with captured record', () => {
|
|
202
|
+
const r = runEng(['run', '--risk', 'R0', '--op', 'read', '--target', 'tests/fixtures/minimal-transcript.jsonl']);
|
|
203
|
+
assert.equal(r.status, 0, 'safe read exit: ' + r.status + ' out=' + r.stdout + r.stderr);
|
|
204
|
+
const o = JSON.parse(r.stdout);
|
|
205
|
+
assert.equal(o.executed, true);
|
|
206
|
+
assert.ok(o.content_sha256 && o.bytes > 0, 'digest captured');
|
|
207
|
+
const rec = JSON.parse(fs.readFileSync(resolveRecord(o.record), 'utf8'));
|
|
208
|
+
assert.equal(rec.verdict, 'ALLOWED');
|
|
209
|
+
assert.equal(rec.enforced_before_execution, true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Acceptance scenario C — permission denial through the runtime.
|
|
213
|
+
test('runtime: permission denial path (C) — personal-data read DENIED, never executed', () => {
|
|
214
|
+
const personal = path.join(os.homedir(), 'secret-test.txt');
|
|
215
|
+
const v = runEng(['evaluate', '--risk', 'R4', '--target', personal]);
|
|
216
|
+
assert.equal(v.status, 3, 'evaluate DENIED must exit 3, got ' + v.status);
|
|
217
|
+
assert.ok(v.stdout.includes('"DENIED"'), 'verdict: ' + v.stdout);
|
|
218
|
+
const r = runEng(['run', '--risk', 'R4', '--op', 'read', '--target', personal]);
|
|
219
|
+
assert.equal(r.status, 3, 'run DENIED must exit 3, got ' + r.status);
|
|
220
|
+
const o = JSON.parse(r.stdout);
|
|
221
|
+
assert.equal(o.executed, false, 'denied op must not execute: ' + r.stdout);
|
|
222
|
+
const rec = latestRecord(x => x.op === 'read' && x.risk === 'R4');
|
|
223
|
+
assert.ok(rec, 'denial record written');
|
|
224
|
+
assert.equal(rec.verdict, 'DENIED');
|
|
225
|
+
assert.equal(rec.executed, false);
|
|
226
|
+
assert.equal(rec.enforced_before_execution, true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('runtime: R2 on workspace is NEEDS_AUTH (exit 4), not silently allowed', () => {
|
|
230
|
+
// README.md is a file every layout has — a clone, a release archive and the development
|
|
231
|
+
// tree — so the target stays valid wherever this suite runs.
|
|
232
|
+
const r = runEng(['evaluate', '--risk', 'R2', '--target', 'README.md']);
|
|
233
|
+
assert.equal(r.status, 4, 'NEEDS_AUTH must exit 4, got ' + r.status);
|
|
234
|
+
assert.ok(r.stdout.includes('"NEEDS_AUTH"'), 'verdict: ' + r.stdout);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('runtime: bounded spawn captures the child exit code exactly', () => {
|
|
238
|
+
const r = runEng(['run', '--risk', 'R1', '--op', 'spawn', '--args', '-e process.exit(7)']);
|
|
239
|
+
assert.equal(r.status, 7, 'engine must propagate child exit 7, got ' + r.status);
|
|
240
|
+
const o = JSON.parse(r.stdout);
|
|
241
|
+
assert.equal(o.exit_code, 7);
|
|
242
|
+
const rec = JSON.parse(fs.readFileSync(resolveRecord(o.record), 'utf8'));
|
|
243
|
+
assert.equal(rec.exit_code, 7);
|
|
244
|
+
assert.ok(rec.stdout_sha256, 'stdout digest captured');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('runtime: unknown risk class is default-denied; unknown op is usage error', () => {
|
|
248
|
+
const r1 = runEng(['evaluate', '--risk', 'RX', '--target', 'README.md']);
|
|
249
|
+
assert.equal(r1.status, 3, 'unknown risk must DENIED exit 3, got ' + r1.status);
|
|
250
|
+
assert.ok(r1.stdout.includes('default denial'), 'reason: ' + r1.stdout);
|
|
251
|
+
const r2 = runEng(['run', '--risk', 'R1', '--op', 'format-c-drive']);
|
|
252
|
+
assert.equal(r2.status, 2, 'unknown op must exit 2, got ' + r2.status);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ---- Plan Task 6: durable checkpoint + resume ----
|
|
256
|
+
|
|
257
|
+
const CKPT_FILE = s => path.join(STATE_DIR, 'checkpoints', s + '.json');
|
|
258
|
+
const WORK_LOG = s => path.join(STATE_DIR, 'jobs', s, 'work.log');
|
|
259
|
+
|
|
260
|
+
function logSteps(session) {
|
|
261
|
+
if (!fs.existsSync(WORK_LOG(session))) return [];
|
|
262
|
+
return fs.readFileSync(WORK_LOG(session), 'utf8').split(/\r?\n/).filter(Boolean)
|
|
263
|
+
.map(l => Number(l.match(/^step (\d+) /)[1]));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// The plan's scripted interrupt-resume oracle.
|
|
267
|
+
test('runtime: kill mid-action then resume completes without duplication or loss', () => {
|
|
268
|
+
const s = 'ckpt-oracle-' + Date.now();
|
|
269
|
+
// 1. abrupt kill before step 3 of 5
|
|
270
|
+
const job = runEng(['job', '--session', s, '--steps', '5', '--fail-at', '3']);
|
|
271
|
+
assert.equal(job.status, 137, 'simulated kill exit, got ' + job.status + ' out=' + job.stdout + job.stderr);
|
|
272
|
+
assert.ok(job.stdout.includes('killed_at_step":3'), 'kill point: ' + job.stdout);
|
|
273
|
+
assert.deepEqual(logSteps(s), [1, 2], 'exactly the pre-kill steps ran');
|
|
274
|
+
// 2. fresh process resumes purely from disk state (zero shared memory)
|
|
275
|
+
const res = runEng(['resume', '--session', s, '--steps', '5']);
|
|
276
|
+
assert.equal(res.status, 0, 'resume exit: ' + res.status + ' out=' + res.stdout + res.stderr);
|
|
277
|
+
const steps = logSteps(s);
|
|
278
|
+
assert.equal(steps.length, 5, 'no loss: ' + steps.length + ' steps');
|
|
279
|
+
assert.deepEqual([...new Set(steps)].sort((a, b) => a - b), [1, 2, 3, 4, 5], 'no duplication: unique steps ' + steps);
|
|
280
|
+
const st = JSON.parse(runEng(['status', '--session', s]).stdout);
|
|
281
|
+
assert.equal(st.completed, true);
|
|
282
|
+
assert.equal(st.work_log_lines, 5);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('runtime: clean job completes with durable checkpoint per step', () => {
|
|
286
|
+
const s = 'ckpt-clean-' + Date.now();
|
|
287
|
+
const r = runEng(['job', '--session', s, '--steps', '4']);
|
|
288
|
+
assert.equal(r.status, 0, 'job exit: ' + r.status + ' out=' + r.stdout + r.stderr);
|
|
289
|
+
assert.deepEqual(logSteps(s), [1, 2, 3, 4]);
|
|
290
|
+
const ck = JSON.parse(fs.readFileSync(CKPT_FILE(s), 'utf8'));
|
|
291
|
+
assert.equal(ck.seq, 4);
|
|
292
|
+
assert.equal(ck.completed, true);
|
|
293
|
+
assert.ok(ck.checkpoint_sha256, 'integrity seal present');
|
|
294
|
+
assert.equal(ck.steps.length, 4, 'checkpoint per step');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('runtime: resume from missing checkpoint fails honestly (never guesses)', () => {
|
|
298
|
+
const r = runEng(['resume', '--session', 'no-such-session-' + Date.now(), '--steps', '3']);
|
|
299
|
+
assert.equal(r.status, 4, 'missing checkpoint must exit 4, got ' + r.status);
|
|
300
|
+
assert.ok(/no checkpoint for session/.test(r.stdout), 'message: ' + r.stdout);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('runtime: corrupt (tampered) checkpoint fails the integrity seal', () => {
|
|
304
|
+
const s = 'ckpt-tamper-' + Date.now();
|
|
305
|
+
const j = runEng(['job', '--session', s, '--steps', '3', '--fail-at', '2']);
|
|
306
|
+
assert.equal(j.status, 137, 'setup kill: ' + j.status);
|
|
307
|
+
const p = CKPT_FILE(s);
|
|
308
|
+
const ck = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
309
|
+
ck.seq = 99; // tamper without re-sealing
|
|
310
|
+
fs.writeFileSync(p, JSON.stringify(ck, null, 2));
|
|
311
|
+
const r = runEng(['resume', '--session', s, '--steps', '3']);
|
|
312
|
+
assert.equal(r.status, 5, 'tampered checkpoint must exit 5, got ' + r.status);
|
|
313
|
+
assert.ok(/integrity seal mismatch/.test(r.stdout), 'message: ' + r.stdout);
|
|
314
|
+
assert.deepEqual(logSteps(s), [1], 'tampered resume must not have executed any step');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('runtime: job refuses to overwrite an existing session checkpoint', () => {
|
|
318
|
+
const s = 'ckpt-dup-' + Date.now();
|
|
319
|
+
const j1 = runEng(['job', '--session', s, '--steps', '2']);
|
|
320
|
+
assert.equal(j1.status, 0, 'first job: ' + j1.status);
|
|
321
|
+
const j2 = runEng(['job', '--session', s, '--steps', '2']);
|
|
322
|
+
assert.equal(j2.status, 2, 'second job must refuse, got ' + j2.status);
|
|
323
|
+
assert.ok(/already exists/.test(j2.stdout), 'message: ' + j2.stdout);
|
|
324
|
+
assert.deepEqual(logSteps(s), [1, 2], 'no duplicate execution');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('runtime: resume already_completed reports the CHECKPOINT steps_total, not the caller flag', () => {
|
|
328
|
+
const s = 'ckpt-fin-' + Date.now();
|
|
329
|
+
const j = runEng(['job', '--session', s, '--steps', '4']);
|
|
330
|
+
assert.equal(j.status, 0, 'setup job: ' + j.status);
|
|
331
|
+
const r = runEng(['resume', '--session', s, '--steps', '4']);
|
|
332
|
+
assert.equal(r.status, 0, 'resume exit: ' + r.status);
|
|
333
|
+
const o = JSON.parse(r.stdout);
|
|
334
|
+
assert.equal(o.already_completed, true);
|
|
335
|
+
assert.equal(o.steps_total, 4, 'steps_total from durable checkpoint: ' + r.stdout);
|
|
336
|
+
assert.deepEqual(logSteps(s), [1, 2, 3, 4], 'no extra execution');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('runtime: resume with mismatched --steps is refused (job shape must match durable state)', () => {
|
|
340
|
+
const s = 'ckpt-mismatch-' + Date.now();
|
|
341
|
+
const j = runEng(['job', '--session', s, '--steps', '5', '--fail-at', '3']);
|
|
342
|
+
assert.equal(j.status, 137, 'setup kill: ' + j.status);
|
|
343
|
+
const r = runEng(['resume', '--session', s, '--steps', '3']);
|
|
344
|
+
assert.equal(r.status, 2, 'mismatch must exit 2, got ' + r.status);
|
|
345
|
+
assert.ok(/mismatches the durable checkpoint/.test(r.stdout), 'message: ' + r.stdout);
|
|
346
|
+
assert.deepEqual(logSteps(s), [1, 2], 'refused resume executes nothing');
|
|
347
|
+
const res = runEng(['resume', '--session', s, '--steps', '5']);
|
|
348
|
+
assert.equal(res.status, 0, 'matching resume still works: ' + res.status);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('runtime: whitespace-only spawn args are a usage error, not a silent no-op', () => {
|
|
352
|
+
const r = runEng(['run', '--risk', 'R1', '--op', 'spawn', '--args', ' ']);
|
|
353
|
+
assert.equal(r.status, 2, 'whitespace args must exit 2, got ' + r.status);
|
|
354
|
+
assert.ok(/requires --args/.test(r.stdout), 'message: ' + r.stdout);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('runtime: path traversal outside the workspace is DENIED before execution', () => {
|
|
358
|
+
const r = runEng(['run', '--risk', 'R1', '--op', 'read', '--target', '../../Windows/win.ini']);
|
|
359
|
+
assert.equal(r.status, 3, 'traversal must DENIED exit 3, got ' + r.status);
|
|
360
|
+
assert.ok(r.stdout.includes('"DENIED"'), 'verdict: ' + r.stdout);
|
|
361
|
+
// The CONTRACT under test is denial-before-execution for a target outside the workspace.
|
|
362
|
+
// WHICH class the denial names depends on where this copy lives: `../../Windows/win.ini`
|
|
363
|
+
// resolves under C:\Windows from a shallow install path, but under C:\Users (a personal
|
|
364
|
+
// root, denied at any risk) when the skill itself sits under a personal root - which is
|
|
365
|
+
// every os.tmpdir() copy on Windows, i.e. every installed or relocated one. Pinning
|
|
366
|
+
// EXTERNAL_PATH made this suite fail for a relocation reason rather than a defect, which
|
|
367
|
+
// is exactly what the clean-checkout test (acceptance J) exists to catch. Any
|
|
368
|
+
// outside-workspace class still proves the denial; APPROVED_WORKSPACE would not.
|
|
369
|
+
const rec = latestRecord(x => x.op === 'read' && x.risk === 'R1' && x.executed === false && /EXTERNAL_PATH|PERSONAL_DATA_PATH|SYSTEM_PATH/.test(x.reason || ''));
|
|
370
|
+
assert.ok(rec, 'denial record naming an outside-workspace class, executed=false');
|
|
371
|
+
assert.ok(!/APPROVED_WORKSPACE/.test(rec.reason || ''), 'traversal must not be treated as inside the workspace: ' + rec.reason);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// A workspace is approved by CONFIG, not by where it happens to live. `personal_data_roots`
|
|
375
|
+
// is a BROAD default (C:\Users, /home/<user>) and a real install's workspace is INSIDE it:
|
|
376
|
+
// ~/.agents/skills, %APPDATA%/... and ~/.config/... are all under the user's home. If the
|
|
377
|
+
// broad default won, every in-workspace operation of an installed copy would be denied at any
|
|
378
|
+
// risk - the acceptance-D read of the shipped fixture exited 3 instead of 0 from every global
|
|
379
|
+
// install, while passing in the dev tree at E:\..., so no test there could see it. This
|
|
380
|
+
// reproduces that layout PORTABLY by pointing --policy at a temp policy that declares the
|
|
381
|
+
// repo's own parent a personal-data root, and asserts all three precedence steps.
|
|
382
|
+
test('runtime: an approved workspace wins over a broad personal-data root it lives inside', () => {
|
|
383
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-pol-'));
|
|
384
|
+
try {
|
|
385
|
+
const base = JSON.parse(fs.readFileSync(path.join(REPO, 'permission-policy.json'), 'utf8'));
|
|
386
|
+
base.personal_data_roots = [path.dirname(REPO)]; // the skill now sits INSIDE a personal root
|
|
387
|
+
const polPath = path.join(root, 'policy.json');
|
|
388
|
+
fs.writeFileSync(polPath, JSON.stringify(base, null, 2));
|
|
389
|
+
|
|
390
|
+
// 1. Inside the workspace: the explicit approval wins, so the shipped fixture is readable.
|
|
391
|
+
const inside = runEng(['evaluate', '--policy', polPath, '--risk', 'R0', '--target', 'tests/fixtures/minimal-transcript.jsonl']);
|
|
392
|
+
assert.equal(inside.status, 0, 'approved workspace must win: exit ' + inside.status + ' out=' + inside.stdout);
|
|
393
|
+
assert.ok(inside.stdout.includes('APPROVED_WORKSPACE'), 'classified as the workspace: ' + inside.stdout);
|
|
394
|
+
const realRun = runEng(['run', '--policy', polPath, '--risk', 'R0', '--op', 'read', '--target', 'tests/fixtures/minimal-transcript.jsonl']);
|
|
395
|
+
assert.equal(realRun.status, 0, 'an in-workspace read must actually execute: ' + realRun.stdout + realRun.stderr);
|
|
396
|
+
assert.equal(JSON.parse(realRun.stdout).executed, true, 'executed: ' + realRun.stdout);
|
|
397
|
+
|
|
398
|
+
// 2. Outside it: the broad root still governs, so the boundary is not weakened.
|
|
399
|
+
const outside = runEng(['evaluate', '--policy', polPath, '--risk', 'R0', '--target', path.join(path.dirname(REPO), 'not-in-workspace.txt')]);
|
|
400
|
+
assert.equal(outside.status, 3, 'outside the workspace it is still personal data: ' + outside.stdout);
|
|
401
|
+
assert.ok(outside.stdout.includes('PERSONAL_DATA_PATH'), 'classification: ' + outside.stdout);
|
|
402
|
+
|
|
403
|
+
// 3. An explicit denial outranks the workspace approval.
|
|
404
|
+
const denied = JSON.parse(JSON.stringify(base));
|
|
405
|
+
denied.denied_roots = [REPO];
|
|
406
|
+
const deniedPath = path.join(root, 'policy-denied.json');
|
|
407
|
+
fs.writeFileSync(deniedPath, JSON.stringify(denied, null, 2));
|
|
408
|
+
const d = runEng(['evaluate', '--policy', deniedPath, '--risk', 'R0', '--target', 'README.md']);
|
|
409
|
+
assert.equal(d.status, 3, 'an explicit denial must outrank the workspace approval: ' + d.stdout);
|
|
410
|
+
assert.ok(d.stdout.includes('DENIED_ROOT'), 'classification: ' + d.stdout);
|
|
411
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// The installer's manifest is the one list that decides what an installed copy contains, and
|
|
415
|
+
// it is written in the PUBLIC tree's terms so that a clone and a release archive resolve it at
|
|
416
|
+
// the same paths. Two drifts are possible, and both happened: a source addressed under a path
|
|
417
|
+
// only the development tree has (the guides, which installed as fourteen "Source file not
|
|
418
|
+
// found" warnings and no docs/ at all), and a fallback version a release bumps in one place
|
|
419
|
+
// but not the other. This test pins both against the tree it runs in.
|
|
420
|
+
// An INSTALLED copy does not ship install/ — the manifest deliberately leaves the installer
|
|
421
|
+
// out — so this test is about the tree that has one, and an installed copy skips it by name
|
|
422
|
+
// rather than failing the suite that proves the installed copy works.
|
|
423
|
+
const INSTALLER_SRC = path.join(REPO, 'install', 'install.mjs');
|
|
424
|
+
test('installer: every manifest source resolves here, and the fallback version matches SKILL.md', (t) => {
|
|
425
|
+
if (!fs.existsSync(INSTALLER_SRC)) {
|
|
426
|
+
return t.skip('no install/ in this tree — an installed copy omits the installer by design');
|
|
427
|
+
}
|
|
428
|
+
const src = fs.readFileSync(INSTALLER_SRC, 'utf8');
|
|
429
|
+
const list = src.match(/const SKILL_FILES = \[([\s\S]*?)\n\];/);
|
|
430
|
+
assert.ok(list, 'SKILL_FILES must be readable from the installer source');
|
|
431
|
+
const froms = [...list[1].matchAll(/from: '([^']+)'/g)].map((m) => m[1]);
|
|
432
|
+
assert.ok(froms.length >= 30, 'non-vacuous manifest, entries=' + froms.length);
|
|
433
|
+
// Resolved exactly as the installer resolves it: repo-upstream/<from> first, then <from>.
|
|
434
|
+
for (const from of froms) {
|
|
435
|
+
const dev = path.join(REPO, 'repo-upstream', from);
|
|
436
|
+
const pub = path.join(REPO, from);
|
|
437
|
+
assert.ok(fs.existsSync(dev) || fs.existsSync(pub),
|
|
438
|
+
'install manifest source resolves nowhere (' + from + ') — an installed copy would silently lose it');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// The fallback is what the published package uses before it has a tree, so a release that
|
|
442
|
+
// bumps SKILL.md without bumping it would install and report the previous version.
|
|
443
|
+
const fallback = /const FALLBACK_SKILL_VERSION = '([^']+)'/.exec(src);
|
|
444
|
+
assert.ok(fallback, 'the installer must declare its fallback version');
|
|
445
|
+
const skillMd = fs.readFileSync(path.join(REPO, 'SKILL.md'), 'utf8');
|
|
446
|
+
const version = /^version:\s*(\S+)/m.exec(skillMd);
|
|
447
|
+
assert.ok(version, 'SKILL.md must declare a version');
|
|
448
|
+
assert.equal(fallback[1], version[1],
|
|
449
|
+
'installer fallback version must equal SKILL.md version');
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test('rebuild of identical source is idempotent (up-to-date, revision stable)', () => {
|
|
453
|
+
const root = scratch();
|
|
454
|
+
try {
|
|
455
|
+
const b1 = run(['build', '--source', FIXTURE, '--project', 'test-suite-proj'], root);
|
|
456
|
+
assert.equal(b1.status, 0, 'first build failed: ' + b1.stderr);
|
|
457
|
+
const manP = path.join(sessionDir(root, 'test-suite-proj'), 'manifest.json');
|
|
458
|
+
const rev1 = JSON.parse(fs.readFileSync(manP, 'utf8')).revisions;
|
|
459
|
+
const b2 = run(['build', '--source', FIXTURE, '--project', 'test-suite-proj'], root);
|
|
460
|
+
assert.equal(b2.status, 0, 'second build failed: ' + b2.stderr);
|
|
461
|
+
assert.ok(b2.stdout.includes('up-to-date'), 'idempotent marker: ' + b2.stdout);
|
|
462
|
+
const rev2 = JSON.parse(fs.readFileSync(manP, 'utf8')).revisions;
|
|
463
|
+
assert.equal(rev2, rev1, 'revision must not bump on identical rebuild');
|
|
464
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
465
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// tools/lib/handoff-root.mjs — ONE definition of where handoffs are stored.
|
|
2
|
+
//
|
|
3
|
+
// Every tool that writes handoffs (tools/handoff.mjs, tools/agent-handoff.mjs)
|
|
4
|
+
// resolves its root through this module, so the precedence below has exactly one
|
|
5
|
+
// implementation:
|
|
6
|
+
//
|
|
7
|
+
// 1. HANDOFFS_ROOT ................... explicit environment override; always wins,
|
|
8
|
+
// which is what keeps the test suite hermetic.
|
|
9
|
+
// 2. handoff.config.json ............. a project config found by walking up from cwd.
|
|
10
|
+
// storage.path beats handoff_dir; a relative
|
|
11
|
+
// handoff_dir resolves against the config's own
|
|
12
|
+
// directory, so a config is location-independent.
|
|
13
|
+
// 3. <dir>/handoffs/ ................. zero-config project-local convention.
|
|
14
|
+
// 4. the skill directory ............. the default store. With no env var, no config
|
|
15
|
+
// and no handoffs/ directory, this is unreachable
|
|
16
|
+
// as a behaviour change: it is what the engine
|
|
17
|
+
// did before this module existed.
|
|
18
|
+
//
|
|
19
|
+
// The walk is bounded and stops at the filesystem root. A config file that is present
|
|
20
|
+
// but invalid fails the run (exit 2) rather than being ignored: silently falling back
|
|
21
|
+
// would send a session's handoff to a different store than the one the user asked for.
|
|
22
|
+
//
|
|
23
|
+
// Zero dependency (node:* only, Node 18+).
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
|
|
27
|
+
const HERE = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'));
|
|
28
|
+
export const SKILL_ROOT = path.resolve(HERE, '..', '..'); // tools/lib -> tools -> skill root
|
|
29
|
+
|
|
30
|
+
export const CONFIG_NAME = 'handoff.config.json';
|
|
31
|
+
export const SCHEMA_NAME = 'handoff.config.schema.json';
|
|
32
|
+
export const DEFAULT_HANDOFF_DIR = 'handoffs';
|
|
33
|
+
const MAX_WALK = 10;
|
|
34
|
+
|
|
35
|
+
export class ConfigError extends Error {
|
|
36
|
+
constructor(message) { super(message); this.name = 'ConfigError'; this.code = 'CONFIG_INVALID'; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function schemaPath() { return path.join(SKILL_ROOT, SCHEMA_NAME); }
|
|
40
|
+
|
|
41
|
+
export function loadSchema() {
|
|
42
|
+
const p = schemaPath();
|
|
43
|
+
if (!fs.existsSync(p)) return null;
|
|
44
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { throw new ConfigError(SCHEMA_NAME + ' is not valid JSON: ' + e.message); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A small validator for the keyword set this schema actually uses (type, enum,
|
|
48
|
+
// minimum/maximum, minLength, items, properties, additionalProperties, required).
|
|
49
|
+
// It is deliberately not a general JSON-Schema engine: it validates exactly the
|
|
50
|
+
// shipped schema, and the test suite checks the shipped example against it.
|
|
51
|
+
// JSON Schema type names, not JS typeof: `integer` is a number with no fractional part,
|
|
52
|
+
// and `object` excludes arrays (which typeof would call "object").
|
|
53
|
+
function typeOf(v) {
|
|
54
|
+
if (Array.isArray(v)) return 'array';
|
|
55
|
+
if (v === null) return 'null';
|
|
56
|
+
if (typeof v === 'number') return Number.isInteger(v) ? 'integer' : 'number';
|
|
57
|
+
return typeof v; // string | boolean | object
|
|
58
|
+
}
|
|
59
|
+
function matchesType(v, t) {
|
|
60
|
+
if (t === 'number') return typeof v === 'number';
|
|
61
|
+
if (t === 'object') return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
62
|
+
return typeOf(v) === t;
|
|
63
|
+
}
|
|
64
|
+
function validateNode(value, spec, at, errors) {
|
|
65
|
+
if (!spec || typeof spec !== 'object') return;
|
|
66
|
+
const types = spec.type ? (Array.isArray(spec.type) ? spec.type : [spec.type]) : null;
|
|
67
|
+
if (types && !types.some(t => matchesType(value, t))) {
|
|
68
|
+
errors.push(at + ': expected ' + types.join('|') + ', got ' + typeOf(value));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (spec.enum && !spec.enum.includes(value)) errors.push(at + ': must be one of ' + JSON.stringify(spec.enum) + ', got ' + JSON.stringify(value));
|
|
72
|
+
if (typeof value === 'number') {
|
|
73
|
+
if (typeof spec.minimum === 'number' && value < spec.minimum) errors.push(at + ': ' + value + ' is below minimum ' + spec.minimum);
|
|
74
|
+
if (typeof spec.maximum === 'number' && value > spec.maximum) errors.push(at + ': ' + value + ' is above maximum ' + spec.maximum);
|
|
75
|
+
}
|
|
76
|
+
if (typeof value === 'string' && typeof spec.minLength === 'number' && value.length < spec.minLength) {
|
|
77
|
+
errors.push(at + ': must be at least ' + spec.minLength + ' character(s)');
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(value) && spec.items) value.forEach((v, i) => validateNode(v, spec.items, at + '[' + i + ']', errors));
|
|
80
|
+
if (typeOf(value) === 'object' && value !== null) {
|
|
81
|
+
const props = spec.properties || {};
|
|
82
|
+
for (const req of Array.isArray(spec.required) ? spec.required : []) {
|
|
83
|
+
if (!(req in value)) errors.push(at + ': missing required key "' + req + '"');
|
|
84
|
+
}
|
|
85
|
+
for (const [k, v] of Object.entries(value)) {
|
|
86
|
+
if (props[k]) validateNode(v, props[k], at + '.' + k, errors);
|
|
87
|
+
else if (spec.additionalProperties === false) errors.push(at + ': unknown key "' + k + '"');
|
|
88
|
+
}
|
|
89
|
+
for (const k of Object.keys(props)) {
|
|
90
|
+
if (!(k in value) && props[k] && props[k].required) errors.push(at + ': missing required key "' + k + '"');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function validateConfig(config, schema) {
|
|
96
|
+
const errors = [];
|
|
97
|
+
if (!schema) return errors;
|
|
98
|
+
validateNode(config, schema, CONFIG_NAME, errors);
|
|
99
|
+
return errors;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readConfig(p) {
|
|
103
|
+
let raw;
|
|
104
|
+
try { raw = JSON.parse(fs.readFileSync(p, 'utf8')); }
|
|
105
|
+
catch (e) { throw new ConfigError('cannot parse ' + p + ': ' + e.message); }
|
|
106
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new ConfigError(p + ' must contain a JSON object');
|
|
107
|
+
return raw;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function declaredDir(config) {
|
|
111
|
+
if (config.storage && typeof config.storage.path === 'string' && config.storage.path) return config.storage.path;
|
|
112
|
+
if (typeof config.handoff_dir === 'string' && config.handoff_dir) return config.handoff_dir;
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Returns { root, source, configPath, config, schemaPath }.
|
|
117
|
+
// source is one of: env | config | discover | default.
|
|
118
|
+
export function resolveRoot(opts = {}) {
|
|
119
|
+
const cwd = path.resolve(opts.cwd || process.cwd());
|
|
120
|
+
|
|
121
|
+
if (process.env.HANDOFFS_ROOT) {
|
|
122
|
+
return { root: path.resolve(process.env.HANDOFFS_ROOT), source: 'env', configPath: null, config: null, schemaPath: schemaPath() };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const schema = loadSchema();
|
|
126
|
+
let foundConfig = null; // a config that pins no directory: still useful for other options
|
|
127
|
+
let foundConfigPath = null;
|
|
128
|
+
let dir = cwd;
|
|
129
|
+
for (let i = 0; i <= MAX_WALK; i++) {
|
|
130
|
+
const cfgPath = path.join(dir, CONFIG_NAME);
|
|
131
|
+
if (fs.existsSync(cfgPath)) {
|
|
132
|
+
const config = readConfig(cfgPath);
|
|
133
|
+
const errors = validateConfig(config, schema);
|
|
134
|
+
if (errors.length) throw new ConfigError(cfgPath + ' is invalid against ' + SCHEMA_NAME + ':\n - ' + errors.join('\n - '));
|
|
135
|
+
const declared = declaredDir(config);
|
|
136
|
+
if (declared) return { root: path.resolve(dir, declared), source: 'config', configPath: cfgPath, config, schemaPath: schemaPath() };
|
|
137
|
+
foundConfig = config;
|
|
138
|
+
foundConfigPath = cfgPath;
|
|
139
|
+
}
|
|
140
|
+
const hd = path.join(dir, DEFAULT_HANDOFF_DIR);
|
|
141
|
+
try {
|
|
142
|
+
if (fs.statSync(hd).isDirectory()) {
|
|
143
|
+
return { root: hd, source: 'discover', configPath: foundConfigPath, config: foundConfig, schemaPath: schemaPath() };
|
|
144
|
+
}
|
|
145
|
+
} catch { /* not a directory: keep walking */ }
|
|
146
|
+
const up = path.dirname(dir);
|
|
147
|
+
if (up === dir) break;
|
|
148
|
+
dir = up;
|
|
149
|
+
}
|
|
150
|
+
return { root: SKILL_ROOT, source: 'default', configPath: foundConfigPath, config: foundConfig, schemaPath: schemaPath() };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// HONOURED options, read from whichever config was found (env roots carry none).
|
|
154
|
+
export function configProjectName(res) {
|
|
155
|
+
const n = res && res.config && res.config.project_name;
|
|
156
|
+
return typeof n === 'string' && n.trim() ? n.trim() : null;
|
|
157
|
+
}
|
|
158
|
+
export function linkingEnabled(res) {
|
|
159
|
+
const l = res && res.config && res.config.linking;
|
|
160
|
+
return !(l && l.enabled === false);
|
|
161
|
+
}
|