@ijfw/memory-server 1.5.3 → 1.5.5
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/package.json +15 -1
- package/src/brain/dream-pipeline.js +78 -15
- package/src/brain/dump-ingest.js +32 -0
- package/src/brain/entity-collapse.js +2 -2
- package/src/brain/export.js +71 -8
- package/src/brain/extractors/markdown.js +28 -2
- package/src/brain/layout-sentinel.js +19 -14
- package/src/brain/path-guard.js +17 -0
- package/src/brain/wiki-compiler.js +35 -39
- package/src/codex-agents.js +25 -2
- package/src/cross-orchestrator-cli.js +176 -18
- package/src/dashboard-server.js +36 -3
- package/src/dispatch/override.js +18 -2
- package/src/dispatch/signer-cli.js +14 -9
- package/src/dream/stage-runner.js +17 -0
- package/src/dream/state-file.js +15 -1
- package/src/extension-installer.js +91 -2
- package/src/extension-registry.js +15 -4
- package/src/handlers/brain-handler.js +45 -6
- package/src/lib/atomic-io.js +69 -12
- package/src/lib/shasum-verify.js +46 -22
- package/src/lib/ui-review-runner.js +7 -2
- package/src/lib/uispec-drift.js +8 -3
- package/src/lib/uispec-intake.js +5 -2
- package/src/memory/layout-migrations/001-visible-layer.js +71 -7
- package/src/memory/reader.js +111 -58
- package/src/orchestrator/discipline-selector.js +0 -17
- package/src/orchestrator/merge-block-aware.js +75 -37
- package/src/orchestrator/post-done-runner.js +6 -1
- package/src/orchestrator/state-sdk.js +242 -14
- package/src/orchestrator/wave-state.js +22 -69
- package/src/recovery/checkpoint.js +30 -6
- package/src/recovery/code-fixer.js +52 -7
- package/src/runtime-mediator.js +2 -2
- package/src/server.js +59 -10
- package/src/swarm/planner.js +46 -1
- package/src/update-apply.js +27 -35
- package/src/update-check.js +6 -2
|
@@ -1614,6 +1614,111 @@ function writeStateFields(updates) {
|
|
|
1614
1614
|
});
|
|
1615
1615
|
}
|
|
1616
1616
|
|
|
1617
|
+
// V155-001 (BLOCKER) — `ijfw update` was writing `installed_version` /
|
|
1618
|
+
// `last_applied_version` based on the install subprocess returning exit 0
|
|
1619
|
+
// across all 3 install methods (npm-global, git-clone, manual). Exit 0 from
|
|
1620
|
+
// `npm install -g @ijfw/install@X` is "npm did not crash" — NOT "@ijfw/install
|
|
1621
|
+
// on disk is now version X". On registry blips, npm-cache poisoning, or a
|
|
1622
|
+
// no-op install (already-installed, lockfile pin), `state.json` would advance
|
|
1623
|
+
// to a version that does not exist on the filesystem. Every subsequent
|
|
1624
|
+
// `ijfw update --check` then saw "we're up to date" and refused to retry.
|
|
1625
|
+
//
|
|
1626
|
+
// Fix: AFTER the install subprocess returns 0, re-read the *actual*
|
|
1627
|
+
// installed package.json (preferring npm-global root for the npm-global path,
|
|
1628
|
+
// repo-tree installer/package.json for git-clone, and either for manual)
|
|
1629
|
+
// and confirm `pkg.version === expectedVersion` before writing state.json.
|
|
1630
|
+
// If they disagree, refuse the state write and surface the discrepancy.
|
|
1631
|
+
//
|
|
1632
|
+
// Returns { ok: true, actualVersion } on success, { ok: false, actualVersion,
|
|
1633
|
+
// reason } on mismatch / unreadable.
|
|
1634
|
+
//
|
|
1635
|
+
// Exported (v1.5.5 V155-001 follow-up) so the regression test
|
|
1636
|
+
// `test-v155-update-flow.js` can drive the function directly without spawning
|
|
1637
|
+
// the full CLI. Behavior unchanged.
|
|
1638
|
+
export function verifyInstallSucceeded({ method, repoRoot, expectedVersion }) {
|
|
1639
|
+
// TR-006: type-validate `expectedVersion` at function entry. Without this,
|
|
1640
|
+
// a stray trailing newline (from sloppy `npmViewVersion` parse) makes the
|
|
1641
|
+
// strict `pkg.version === expectedVersion` check fail for the wrong reason
|
|
1642
|
+
// and surfaces operator-hostile mismatch messages like
|
|
1643
|
+
// "expected v1.5.5\n got v1.5.5". Normalize + validate before any probe.
|
|
1644
|
+
expectedVersion = String(expectedVersion || '').trim();
|
|
1645
|
+
if (!isVersionStringValid(expectedVersion)) {
|
|
1646
|
+
return {
|
|
1647
|
+
ok: false,
|
|
1648
|
+
actualVersion: null,
|
|
1649
|
+
reason: `expectedVersion not a valid semver string (got: ${JSON.stringify(expectedVersion)})`,
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
// TR-002: probe candidates are now method-strict. The previous shape
|
|
1653
|
+
// silently fell back to `repoRoot/installer/package.json` for ALL methods,
|
|
1654
|
+
// so a stale git clone at the expected version would mask an npm-global
|
|
1655
|
+
// install that never happened. Each method now declares its own source of
|
|
1656
|
+
// truth; `manual` keeps the multi-candidate fallback because dev-mode
|
|
1657
|
+
// installs legitimately ride on either npm-global or a sibling git clone.
|
|
1658
|
+
const candidates = [];
|
|
1659
|
+
const probeNpmGlobal = () => {
|
|
1660
|
+
try {
|
|
1661
|
+
const r = spawnSync('npm', ['root', '-g'], {
|
|
1662
|
+
encoding: 'utf8',
|
|
1663
|
+
timeout: 5_000,
|
|
1664
|
+
shell: process.platform === 'win32',
|
|
1665
|
+
});
|
|
1666
|
+
if (r.status === 0) {
|
|
1667
|
+
const globalRoot = (r.stdout || '').trim();
|
|
1668
|
+
if (globalRoot) {
|
|
1669
|
+
candidates.push(join(globalRoot, '@ijfw', 'install', 'package.json'));
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
} catch { /* ignore — surfaces as no-candidates */ }
|
|
1673
|
+
};
|
|
1674
|
+
if (method === 'npm-global') {
|
|
1675
|
+
// npm-global verification MUST read the npm-global package.json
|
|
1676
|
+
// exclusively. A sibling repo tree at the expected version is a category
|
|
1677
|
+
// error here — it would mean we record "npm-global install succeeded"
|
|
1678
|
+
// when the only thing on disk at that version is an unrelated git clone.
|
|
1679
|
+
probeNpmGlobal();
|
|
1680
|
+
} else if (method === 'git-clone' && repoRoot) {
|
|
1681
|
+
candidates.push(join(repoRoot, 'installer', 'package.json'));
|
|
1682
|
+
} else if (method === 'manual') {
|
|
1683
|
+
// Dev-mode: try npm-global first, then the repo tree. Either is a
|
|
1684
|
+
// legitimate source of truth for a manually-driven install.
|
|
1685
|
+
probeNpmGlobal();
|
|
1686
|
+
if (repoRoot) {
|
|
1687
|
+
const fallback = join(repoRoot, 'installer', 'package.json');
|
|
1688
|
+
if (!candidates.includes(fallback)) candidates.push(fallback);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
if (candidates.length === 0) {
|
|
1693
|
+
return {
|
|
1694
|
+
ok: false,
|
|
1695
|
+
actualVersion: null,
|
|
1696
|
+
reason:
|
|
1697
|
+
method === 'npm-global'
|
|
1698
|
+
? 'npm-global package.json not readable (npm root -g failed)'
|
|
1699
|
+
: `no package.json candidates for method=${method}`,
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
const seen = [];
|
|
1704
|
+
for (const p of candidates) {
|
|
1705
|
+
const pkg = readJsonSafe(p);
|
|
1706
|
+
if (!pkg) {
|
|
1707
|
+
seen.push(`${p} (unreadable)`);
|
|
1708
|
+
continue;
|
|
1709
|
+
}
|
|
1710
|
+
seen.push(`${p} (v${pkg.version || 'unknown'})`);
|
|
1711
|
+
if (pkg.version === expectedVersion) {
|
|
1712
|
+
return { ok: true, actualVersion: pkg.version, source: p };
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
return {
|
|
1716
|
+
ok: false,
|
|
1717
|
+
actualVersion: null,
|
|
1718
|
+
reason: `no package.json reported version v${expectedVersion}. Probed: ${seen.join(', ')}`,
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1617
1722
|
function cmdUpdateCheck() {
|
|
1618
1723
|
const state = readState();
|
|
1619
1724
|
const current = state.installed_version || '0.0.0';
|
|
@@ -1631,7 +1736,7 @@ function cmdUpdateCheck() {
|
|
|
1631
1736
|
// rely on exit codes. Previously exited 3 which broke POSIX if-statements.
|
|
1632
1737
|
console.log(`update-available: ${r.version}`);
|
|
1633
1738
|
console.log(`Update available: v${current} -> v${r.version}`);
|
|
1634
|
-
console.log(` Release notes: https://
|
|
1739
|
+
console.log(` Release notes: https://github.com/FerroxLabs/ijfw/releases/tag/v${r.version}`);
|
|
1635
1740
|
console.log(` Run: ijfw update`);
|
|
1636
1741
|
process.exit(0);
|
|
1637
1742
|
}
|
|
@@ -1683,17 +1788,24 @@ function cmdUpdateChangelog() {
|
|
|
1683
1788
|
console.error(`could not fetch latest version: ${r.message}`);
|
|
1684
1789
|
process.exit(1);
|
|
1685
1790
|
}
|
|
1686
|
-
|
|
1687
|
-
|
|
1791
|
+
// V155 rebrand: ported from GitLab API (releases/v<ver>, data.description)
|
|
1792
|
+
// to GitHub API (releases/tags/v<ver>, data.body). GitHub's repo path is
|
|
1793
|
+
// NOT URL-encoded, unlike GitLab's %2F-separated project id.
|
|
1794
|
+
const url = `https://api.github.com/repos/FerroxLabs/ijfw/releases/tags/v${r.version}`;
|
|
1795
|
+
const fetchRes = spawnSync(
|
|
1796
|
+
'curl',
|
|
1797
|
+
['-fsSL', '-H', 'User-Agent: ijfw', '-H', 'Accept: application/vnd.github+json', url],
|
|
1798
|
+
{ encoding: 'utf8', timeout: 10_000 },
|
|
1799
|
+
);
|
|
1688
1800
|
if (fetchRes.status !== 0) {
|
|
1689
1801
|
console.log(`No release notes available for v${r.version}.`);
|
|
1690
|
-
console.log(`Visit: https://
|
|
1802
|
+
console.log(`Visit: https://github.com/FerroxLabs/ijfw/releases/tag/v${r.version}`);
|
|
1691
1803
|
process.exit(0);
|
|
1692
1804
|
}
|
|
1693
1805
|
let body = '';
|
|
1694
1806
|
try {
|
|
1695
1807
|
const data = JSON.parse(fetchRes.stdout || '{}');
|
|
1696
|
-
body = data.
|
|
1808
|
+
body = data.body || '(no body)';
|
|
1697
1809
|
} catch { body = '(could not parse release JSON)'; }
|
|
1698
1810
|
// ANSI strip + cap 4KB. Control-char regex is intentional -- defangs
|
|
1699
1811
|
// CHANGELOG bytes fetched over HTTPS so paste into the terminal can't
|
|
@@ -1707,7 +1819,7 @@ function cmdUpdateChangelog() {
|
|
|
1707
1819
|
console.log(`Changelog for v${r.version}`);
|
|
1708
1820
|
console.log('');
|
|
1709
1821
|
console.log(stripped);
|
|
1710
|
-
if (body.length > 4096) console.log(`\n... (truncated; full notes at https://
|
|
1822
|
+
if (body.length > 4096) console.log(`\n... (truncated; full notes at https://github.com/FerroxLabs/ijfw/releases/tag/v${r.version})`);
|
|
1711
1823
|
process.exit(0);
|
|
1712
1824
|
}
|
|
1713
1825
|
|
|
@@ -1734,7 +1846,7 @@ function cmdUpdateConfirm(token) {
|
|
|
1734
1846
|
// Locate pending sentinel under any session in ~/.ijfw/run/
|
|
1735
1847
|
const runRoot = join(ijfwHome(), 'run');
|
|
1736
1848
|
if (!existsSync(runRoot)) {
|
|
1737
|
-
console.error('No pending update sentinel found. Run `
|
|
1849
|
+
console.error('No pending update sentinel found. Run `ijfw_update_check` via your AI first to issue a confirm token.');
|
|
1738
1850
|
process.exit(1);
|
|
1739
1851
|
}
|
|
1740
1852
|
let sentinelPath = null;
|
|
@@ -1758,15 +1870,15 @@ function cmdUpdateConfirm(token) {
|
|
|
1758
1870
|
if (!sentinelPath) {
|
|
1759
1871
|
console.error(
|
|
1760
1872
|
sawPending
|
|
1761
|
-
? 'Token mismatch -- run ijfw_update_check
|
|
1762
|
-
: 'No pending update sentinel found.
|
|
1873
|
+
? 'Token mismatch -- run `ijfw_update_check` via your AI to issue a fresh token.'
|
|
1874
|
+
: 'No pending update sentinel found. Run `ijfw_update_check` via your AI to issue one.'
|
|
1763
1875
|
);
|
|
1764
1876
|
process.exit(1);
|
|
1765
1877
|
}
|
|
1766
1878
|
|
|
1767
1879
|
if (!pending || !isVersionStringValid(pending.target_version)) {
|
|
1768
1880
|
try { rmSync(sentinelPath, { force: true }); } catch { /* */ }
|
|
1769
|
-
console.error('Pending update sentinel is malformed -- run ijfw_update_check
|
|
1881
|
+
console.error('Pending update sentinel is malformed -- run `ijfw_update_check` again to issue a fresh one.');
|
|
1770
1882
|
process.exit(1);
|
|
1771
1883
|
}
|
|
1772
1884
|
const tokenCheck = validateToken(sessionId, token);
|
|
@@ -1777,7 +1889,7 @@ function cmdUpdateConfirm(token) {
|
|
|
1777
1889
|
tokenCheck.error === 'already-consumed' ? 'Token already consumed' :
|
|
1778
1890
|
tokenCheck.error === 'mismatch' ? 'Token mismatch' :
|
|
1779
1891
|
'Token validation failed';
|
|
1780
|
-
console.error(`${why} -- run ijfw_update_check
|
|
1892
|
+
console.error(`${why} -- run \`ijfw_update_check\` to issue a fresh token.`);
|
|
1781
1893
|
process.exit(1);
|
|
1782
1894
|
}
|
|
1783
1895
|
|
|
@@ -1849,7 +1961,7 @@ function cmdUpdateInteractive(opts = {}) {
|
|
|
1849
1961
|
}
|
|
1850
1962
|
}
|
|
1851
1963
|
// Shasum cross-verify (F-SEC-7): independent second factor on top of
|
|
1852
|
-
// npm-side signatures. Fetches the
|
|
1964
|
+
// npm-side signatures. Fetches the GitHub release asset shasum and
|
|
1853
1965
|
// compares it against npm's dist.shasum for the same version. Mismatch
|
|
1854
1966
|
// means we refuse to install; advisory (release shasum unavailable)
|
|
1855
1967
|
// requires explicit --yes to proceed.
|
|
@@ -1860,7 +1972,7 @@ function cmdUpdateInteractive(opts = {}) {
|
|
|
1860
1972
|
console.error(' Shasum: MISMATCH -- refusing install.');
|
|
1861
1973
|
console.error(` npm : ${shasum.npmShasum}`);
|
|
1862
1974
|
console.error(` release : ${shasum.releaseShasum}`);
|
|
1863
|
-
console.error(' The npm tarball does NOT match the
|
|
1975
|
+
console.error(' The npm tarball does NOT match the GitHub release asset.');
|
|
1864
1976
|
console.error(' This could indicate a compromised registry or release. Aborting.');
|
|
1865
1977
|
return 1;
|
|
1866
1978
|
} else if (shasum.mode === 'error') {
|
|
@@ -1954,10 +2066,28 @@ function cmdUpdateInteractive(opts = {}) {
|
|
|
1954
2066
|
console.error(`npm install did not complete (exit ${npmInstall.status}). Run \`npm install --omit=dev --ignore-scripts\` in ${repoRoot} manually.`);
|
|
1955
2067
|
return 1;
|
|
1956
2068
|
}
|
|
1957
|
-
|
|
2069
|
+
// V155-001 (H-002): hardcoded `bash` ENOENTs on Windows where the runtime
|
|
2070
|
+
// is `sh.exe` (Git-Bash) or absent. Use shell:true on Windows so the
|
|
2071
|
+
// .sh extension dispatches via the registered shebang handler; POSIX
|
|
2072
|
+
// keeps the explicit `bash` binary for portability across distros.
|
|
2073
|
+
//
|
|
2074
|
+
// TS-001 (v1.5.5 Trident security): with shell:true on Windows, the
|
|
2075
|
+
// first argument is interpreted by cmd.exe and tokenized on whitespace,
|
|
2076
|
+
// `&`, `(`, `)`, `;`, `%`, etc. Paths containing those characters are
|
|
2077
|
+
// common on Windows (e.g. `C:\Users\Bob & Alice\...` or
|
|
2078
|
+
// `C:\Program Files (x86)\ijfw\...`) — without quotes cmd will execute
|
|
2079
|
+
// an unrelated binary or fail confusingly. Same-uid attacker could plant
|
|
2080
|
+
// `C:\Users\Bob.exe` and have it picked up first. Quote the path so
|
|
2081
|
+
// cmd.exe treats it as a single token.
|
|
2082
|
+
installRes = process.platform === 'win32'
|
|
2083
|
+
? spawnSync(`"${installSh}"`, [], { stdio: 'inherit', shell: true })
|
|
2084
|
+
: spawnSync('bash', [installSh], { stdio: 'inherit' });
|
|
1958
2085
|
} else {
|
|
1959
2086
|
console.log(' Manual install detected -- run: npx @ijfw/install');
|
|
1960
|
-
installRes = spawnSync('npx', ['-y', `@ijfw/install@${r.version}`], {
|
|
2087
|
+
installRes = spawnSync('npx', ['-y', `@ijfw/install@${r.version}`], {
|
|
2088
|
+
stdio: 'inherit',
|
|
2089
|
+
shell: process.platform === 'win32',
|
|
2090
|
+
});
|
|
1961
2091
|
}
|
|
1962
2092
|
if (!installRes) {
|
|
1963
2093
|
console.error('Update did not complete (install command could not be spawned)');
|
|
@@ -1971,6 +2101,23 @@ function cmdUpdateInteractive(opts = {}) {
|
|
|
1971
2101
|
console.error(`Update did not complete (exit ${installRes.status}). State not written.`);
|
|
1972
2102
|
return 1;
|
|
1973
2103
|
}
|
|
2104
|
+
// V155-001 (A-001 + C-001 + C-002): subprocess exit 0 is "the command did
|
|
2105
|
+
// not crash" — NOT "@ijfw/install on disk is now version r.version".
|
|
2106
|
+
// Re-verify by reading the installed package.json BEFORE writing state.json.
|
|
2107
|
+
// If the filesystem disagrees with what we asked for, refuse the state
|
|
2108
|
+
// write and surface a clear actionable error.
|
|
2109
|
+
const repoRoot = method === 'git-clone' ? repoRootFromCli() : null;
|
|
2110
|
+
const verify = verifyInstallSucceeded({
|
|
2111
|
+
method,
|
|
2112
|
+
repoRoot: repoRoot || repoRootFromCli(),
|
|
2113
|
+
expectedVersion: r.version,
|
|
2114
|
+
});
|
|
2115
|
+
if (!verify.ok) {
|
|
2116
|
+
console.error(`Update did NOT complete: install subprocess exited 0 but filesystem still reports v${verify.actualVersion || 'unknown'} (expected v${r.version}).`);
|
|
2117
|
+
console.error(` Details: ${verify.reason}`);
|
|
2118
|
+
console.error(' State not written. Inspect with `ijfw status`; rerun `ijfw update` after diagnosing.');
|
|
2119
|
+
return 1;
|
|
2120
|
+
}
|
|
1974
2121
|
// Persist both fields atomically -- single write avoids concurrent-reader inconsistency.
|
|
1975
2122
|
// last_good_shasum records the shasum we just successfully cross-verified +
|
|
1976
2123
|
// installed (per docs/SECURITY.md "last_good_shasum is a one-way 'what did
|
|
@@ -2192,7 +2339,7 @@ function statuslineRecompute() {
|
|
|
2192
2339
|
function cmdConfig(sub) {
|
|
2193
2340
|
if (sub === 'audit') {
|
|
2194
2341
|
console.log('ijfw config --audit -- this feature is queued for a later release.');
|
|
2195
|
-
console.log('Track progress: https://
|
|
2342
|
+
console.log('Track progress: https://github.com/FerroxLabs/ijfw/issues');
|
|
2196
2343
|
return;
|
|
2197
2344
|
}
|
|
2198
2345
|
console.log('Usage: ijfw config --audit');
|
|
@@ -2271,7 +2418,7 @@ async function handleGuide(useBrowser) {
|
|
|
2271
2418
|
];
|
|
2272
2419
|
const guidePath = candidates.find(p => existsSync(p));
|
|
2273
2420
|
if (!guidePath) {
|
|
2274
|
-
console.error('[ijfw] Guide not found. Visit https://
|
|
2421
|
+
console.error('[ijfw] Guide not found. Visit https://github.com/FerroxLabs/ijfw/blob/main/docs/GUIDE.md');
|
|
2275
2422
|
process.exit(1);
|
|
2276
2423
|
}
|
|
2277
2424
|
const md = readFileSync(guidePath, 'utf8');
|
|
@@ -3315,7 +3462,18 @@ function cmdSwarm(sub) {
|
|
|
3315
3462
|
const taskId = args[0];
|
|
3316
3463
|
const owner = optionValue(args.slice(1), ['--owner', '-o']);
|
|
3317
3464
|
const message = optionValue(args.slice(1), ['--message', '-m']) || positionalMessage(args.slice(1), ['--owner', '-o']);
|
|
3318
|
-
const
|
|
3465
|
+
const commitSha = optionValue(args.slice(1), ['--commit', '--sha']);
|
|
3466
|
+
const filesChanged = Number(optionValue(args.slice(1), ['--files-changed']));
|
|
3467
|
+
const skipEvidence = args.includes('--skip-evidence');
|
|
3468
|
+
// V155-006: caller must produce a filesystem witness (sha OR diffStats) OR
|
|
3469
|
+
// explicitly opt out via --skip-evidence (admin overrides, dry-run flows).
|
|
3470
|
+
// The planner records `task.completed-no-evidence` for downstream audit.
|
|
3471
|
+
const evidence = commitSha
|
|
3472
|
+
? { commitSha }
|
|
3473
|
+
: Number.isFinite(filesChanged) && filesChanged >= 1
|
|
3474
|
+
? { diffStats: { filesChanged } }
|
|
3475
|
+
: undefined;
|
|
3476
|
+
const result = completeSwarmTask(process.cwd(), taskId, { owner, message, evidence, skipEvidence });
|
|
3319
3477
|
if (!result.ok) {
|
|
3320
3478
|
console.log(`Swarm complete halted: ${result.error}`);
|
|
3321
3479
|
process.exit(1);
|
package/src/dashboard-server.js
CHANGED
|
@@ -10,7 +10,7 @@ import { createServer } from 'node:http';
|
|
|
10
10
|
import { existsSync, readFileSync, watch, writeFileSync, mkdirSync, readdirSync, statSync, realpathSync, renameSync, unlinkSync } from 'node:fs';
|
|
11
11
|
import { readFile } from 'node:fs/promises';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
|
-
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
|
|
13
|
+
import { join, dirname, resolve, relative, isAbsolute, basename, sep } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
|
|
16
16
|
import { buildCostReport, buildBreakdown, buildDailySeries, buildBlockUsage, getSavingsMethodology } from './cost/aggregator.js';
|
|
@@ -736,10 +736,15 @@ export async function startServer(options = {}) {
|
|
|
736
736
|
try {
|
|
737
737
|
const tierFilter = url.searchParams.get('tier') || null;
|
|
738
738
|
const result = ttlCache(`memory:list:${tierFilter}`, COST_CACHE_TTL, memoryDirsMtimeKey, () => {
|
|
739
|
-
|
|
739
|
+
// V155-026 (v1.5.5): surface unreadable / corrupt-frontmatter files
|
|
740
|
+
// so the dashboard can warn the operator. Previously silently dropped.
|
|
741
|
+
const { files, errors, total, root, tiers } = listMemoryFiles(REPO_ROOT, tierFilter);
|
|
740
742
|
const { counts, weekCounts, totalThisWeek } = buildRecallCounts(ledgerPath);
|
|
741
743
|
const enriched = mergeRecallCounts(files, counts, weekCounts);
|
|
742
|
-
return {
|
|
744
|
+
return {
|
|
745
|
+
files: enriched, errors: errors || [], total, root, tiers,
|
|
746
|
+
totalRecallsThisWeek: totalThisWeek,
|
|
747
|
+
};
|
|
743
748
|
});
|
|
744
749
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
745
750
|
res.end(JSON.stringify(result));
|
|
@@ -1254,7 +1259,35 @@ export async function startServer(options = {}) {
|
|
|
1254
1259
|
const contentDir = join(homedir(), '.ijfw', 'design-companion', 'content');
|
|
1255
1260
|
mkdirSync(contentDir, { recursive: true });
|
|
1256
1261
|
const name = url.pathname.split('/').pop();
|
|
1262
|
+
// V155-037 (v1.5.5): the route regex `[^/]+\.html$` only forbids forward
|
|
1263
|
+
// slashes — Windows `path.join` treats backslashes as separators, so
|
|
1264
|
+
// `GET /design/files/..\..\..\windows\system32\hosts.html` previously
|
|
1265
|
+
// escaped contentDir at join time. Reject any name that contains a
|
|
1266
|
+
// backslash or a dot-segment, or starts with a dot, or doesn't survive
|
|
1267
|
+
// a basename() round-trip. Belt-and-braces: also verify the joined
|
|
1268
|
+
// filePath resolves under contentDir.
|
|
1269
|
+
const isUnsafeName = (
|
|
1270
|
+
name.includes('\\')
|
|
1271
|
+
|| name.includes('/')
|
|
1272
|
+
|| name.startsWith('.')
|
|
1273
|
+
|| name.includes('..')
|
|
1274
|
+
// Null-byte truncates path strings on POSIX syscalls that honour
|
|
1275
|
+
// C-string null-terminators. Reject any path containing one.
|
|
1276
|
+
|| name.includes('\u0000')
|
|
1277
|
+
|| basename(name) !== name
|
|
1278
|
+
);
|
|
1257
1279
|
const filePath = join(contentDir, name);
|
|
1280
|
+
const filePathResolved = resolve(filePath);
|
|
1281
|
+
const contentDirResolved = resolve(contentDir);
|
|
1282
|
+
const isContained = (
|
|
1283
|
+
filePathResolved === contentDirResolved
|
|
1284
|
+
|| filePathResolved.startsWith(contentDirResolved + sep)
|
|
1285
|
+
);
|
|
1286
|
+
if (isUnsafeName || !isContained) {
|
|
1287
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
1288
|
+
res.end('404 Not Found');
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1258
1291
|
let html = null;
|
|
1259
1292
|
try {
|
|
1260
1293
|
if (existsSync(filePath)) html = readFileSync(filePath, 'utf8');
|
package/src/dispatch/override.js
CHANGED
|
@@ -151,14 +151,30 @@ async function cmdAudit({ projectRoot }) {
|
|
|
151
151
|
const paths = resolveOverridePaths(skill, projectRoot);
|
|
152
152
|
let sections = 0;
|
|
153
153
|
let extendsChain = [];
|
|
154
|
+
const errors = [];
|
|
154
155
|
for (const p of paths) {
|
|
155
|
-
|
|
156
|
+
// V155-028 (v1.5.5): previously `.catch(() => null)` silently treated
|
|
157
|
+
// schema-invalid / unreadable overrides as 'not present'. The audit
|
|
158
|
+
// command exists precisely to surface override health, so swallowing
|
|
159
|
+
// these errors hid the exact failure mode operators care about.
|
|
160
|
+
let file;
|
|
161
|
+
try { file = await loadOverrideFile(p); }
|
|
162
|
+
catch (err) {
|
|
163
|
+
errors.push({ path: p, reason: 'load-fail', message: err?.message || String(err) });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
156
166
|
if (!file) continue;
|
|
157
167
|
if (Array.isArray(file.manifest?.extends)) extendsChain = file.manifest.extends;
|
|
158
168
|
const matches = file.body?.match(/<!--\s*ijfw-override:/g);
|
|
159
169
|
sections += matches ? matches.length : 0;
|
|
160
170
|
}
|
|
161
|
-
items.push({
|
|
171
|
+
items.push({
|
|
172
|
+
preset: entry.preset, scope: entry.scope, skill, sections,
|
|
173
|
+
extends: extendsChain,
|
|
174
|
+
// Only include `errors` when there is something to report — avoids
|
|
175
|
+
// every clean item carrying a noisy empty array.
|
|
176
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
177
|
+
});
|
|
162
178
|
}
|
|
163
179
|
}
|
|
164
180
|
return { ok: true, command: 'audit', result: { items } };
|
|
@@ -276,22 +276,27 @@ async function keygenHandler(args, ctx = {}) {
|
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
/**
|
|
279
|
-
* keygen-fido2 handler —
|
|
279
|
+
* keygen-fido2 handler — unimplemented.
|
|
280
280
|
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
281
|
+
* V155-057: previously returned `ok:true, deferred:true` — JSON consumers
|
|
282
|
+
* (CI scripts, automated install flows) reading `r.ok` saw success and
|
|
283
|
+
* proceeded as if a key had been minted. That's truthfulness-of-state
|
|
284
|
+
* violation: nothing was minted, no key exists, but state recorded ok.
|
|
285
285
|
*
|
|
286
|
-
*
|
|
286
|
+
* Now returns `ok:false, error:'unimplemented'` so callers fail closed.
|
|
287
|
+
* The transitive ssh-agent path (modern YubiKey/Solokey speak SSH agent
|
|
288
|
+
* natively) remains the recommended route; the hint stays the same but
|
|
289
|
+
* the verdict is honest.
|
|
290
|
+
*
|
|
291
|
+
* @returns {Promise<{ ok: false, error: 'unimplemented', message: string }>}
|
|
287
292
|
*/
|
|
288
293
|
async function keygenFido2Handler(_args, ctx = {}) {
|
|
289
|
-
const msg = 'FIDO2/libfido2
|
|
294
|
+
const msg = 'FIDO2/libfido2 native backend is unimplemented — use `keygen <author> --backend ssh-agent` (modern YubiKey/Solokey work transitively via ssh-agent)';
|
|
290
295
|
// Write to stderr for CLI visibility without disturbing JSON-stdout
|
|
291
296
|
// consumers. Optionally inject a writer via ctx for tests.
|
|
292
297
|
const stderr = ctx.stderr || process.stderr;
|
|
293
298
|
try { stderr.write(`${msg}\n`); } catch { /* ignore */ }
|
|
294
|
-
return { ok:
|
|
299
|
+
return { ok: false, error: 'unimplemented', message: msg };
|
|
295
300
|
}
|
|
296
301
|
|
|
297
302
|
export const handlers = Object.freeze({
|
|
@@ -301,7 +306,7 @@ export const handlers = Object.freeze({
|
|
|
301
306
|
|
|
302
307
|
export const subcommandHelp = Object.freeze({
|
|
303
308
|
keygen: 'keygen <author> [--backend software|ssh-agent] [--ssh-key-comment <c>] — generate or enrol a publisher signing key',
|
|
304
|
-
'keygen-fido2': 'keygen-fido2 <author> —
|
|
309
|
+
'keygen-fido2': 'keygen-fido2 <author> — UNIMPLEMENTED (no native libfido2 backend); use `keygen <author> --backend ssh-agent` instead',
|
|
305
310
|
});
|
|
306
311
|
|
|
307
312
|
// Test-only exports.
|
|
@@ -27,6 +27,23 @@ export async function runStages(root, stages) {
|
|
|
27
27
|
const extras = (await stage.run()) || {};
|
|
28
28
|
markStageCompleted(root, stage.name, extras);
|
|
29
29
|
completed.push({ name: stage.name, extras });
|
|
30
|
+
// TP-003 (v1.5.5 Trident): V155-005 closed the truthfulness-of-state
|
|
31
|
+
// hole (status:'completed_with_error' / 'skipped' written to
|
|
32
|
+
// .dream-state-v2.json), but operators don't read JSON unless they
|
|
33
|
+
// already suspect something's wrong. Surface the non-clean shapes on
|
|
34
|
+
// stderr at run time so the operator discovers the truth proactively
|
|
35
|
+
// (Sutherland: the system explains itself). The JSON state file
|
|
36
|
+
// remains the canonical record; this is the discoverability rail.
|
|
37
|
+
if (extras && extras.error) {
|
|
38
|
+
process.stderr.write(
|
|
39
|
+
`[dream] stage ${stage.name} completed with error: ${extras.error}. `
|
|
40
|
+
+ 'Full envelope in .ijfw/state/.dream-state-v2.json.\n',
|
|
41
|
+
);
|
|
42
|
+
} else if (extras && extras.skipped) {
|
|
43
|
+
process.stderr.write(
|
|
44
|
+
`[dream] stage ${stage.name} skipped: ${extras.skipped}\n`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
30
47
|
} catch (e) {
|
|
31
48
|
markStageFailed(root, stage.name, e?.message || String(e));
|
|
32
49
|
failed.push({ name: stage.name, reason: e?.message || String(e) });
|
package/src/dream/state-file.js
CHANGED
|
@@ -56,9 +56,23 @@ export function markStageStarted(root, stage) {
|
|
|
56
56
|
|
|
57
57
|
export function markStageCompleted(root, stage, extras = {}) {
|
|
58
58
|
const s = readDreamState(root);
|
|
59
|
+
// V155-005 (HIGH): the previous shape unconditionally wrote
|
|
60
|
+
// `status:'completed'` even when the stage returned a payload like
|
|
61
|
+
// `{ skipped:'db-unavailable' }` or `{ error:'tier-promotion-failed' }`.
|
|
62
|
+
// Operators reading `.dream-state-v2.json` saw "all stages completed"
|
|
63
|
+
// perpetually even when wiki-compile had been skipped for weeks. Inspect
|
|
64
|
+
// the returned envelope and route to a more honest status:
|
|
65
|
+
// - `extras.skipped` → status:'skipped'
|
|
66
|
+
// - `extras.error` → status:'completed_with_error'
|
|
67
|
+
// - otherwise → status:'completed' (back-compat shape).
|
|
68
|
+
let status = 'completed';
|
|
69
|
+
if (extras && typeof extras === 'object') {
|
|
70
|
+
if (extras.skipped) status = 'skipped';
|
|
71
|
+
else if (extras.error) status = 'completed_with_error';
|
|
72
|
+
}
|
|
59
73
|
s.stages[stage] = {
|
|
60
74
|
...s.stages[stage],
|
|
61
|
-
status
|
|
75
|
+
status,
|
|
62
76
|
completed_at: Date.now(),
|
|
63
77
|
...extras,
|
|
64
78
|
};
|
|
@@ -736,6 +736,53 @@ export function extensionAuditBrief(manifest, skillBodies) {
|
|
|
736
736
|
|
|
737
737
|
// --- install ----------------------------------------------------------------
|
|
738
738
|
|
|
739
|
+
/**
|
|
740
|
+
* V155-061 (v1.5.5): byte-for-byte equality check for two directory trees.
|
|
741
|
+
* Returns true only when every file in `a` is present in `b` with identical
|
|
742
|
+
* bytes (and vice versa). Bails on the first divergence for efficiency.
|
|
743
|
+
* Used by the installer's staged-flip path to short-circuit re-installing
|
|
744
|
+
* the same version (avoids rewriting `installed_at` for no behavior change).
|
|
745
|
+
*/
|
|
746
|
+
async function dirsAreByteIdentical(a, b) {
|
|
747
|
+
let entriesA;
|
|
748
|
+
let entriesB;
|
|
749
|
+
try { entriesA = await readdir(a, { withFileTypes: true }); }
|
|
750
|
+
catch { return false; }
|
|
751
|
+
try { entriesB = await readdir(b, { withFileTypes: true }); }
|
|
752
|
+
catch { return false; }
|
|
753
|
+
const namesA = entriesA.map((e) => e.name).sort();
|
|
754
|
+
const namesB = entriesB.map((e) => e.name).sort();
|
|
755
|
+
if (namesA.length !== namesB.length) return false;
|
|
756
|
+
for (let i = 0; i < namesA.length; i += 1) {
|
|
757
|
+
if (namesA[i] !== namesB[i]) return false;
|
|
758
|
+
}
|
|
759
|
+
const byNameA = new Map(entriesA.map((e) => [e.name, e]));
|
|
760
|
+
const byNameB = new Map(entriesB.map((e) => [e.name, e]));
|
|
761
|
+
for (const name of namesA) {
|
|
762
|
+
const ea = byNameA.get(name);
|
|
763
|
+
const eb = byNameB.get(name);
|
|
764
|
+
const aPath = join(a, name);
|
|
765
|
+
const bPath = join(b, name);
|
|
766
|
+
if (ea.isDirectory() !== eb.isDirectory()) return false;
|
|
767
|
+
if (ea.isDirectory()) {
|
|
768
|
+
if (!(await dirsAreByteIdentical(aPath, bPath))) return false;
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
// Treat anything that isn't a regular file as "not identical" — symlinks,
|
|
772
|
+
// sockets etc. don't survive npm-publish round-trips, so this is rare.
|
|
773
|
+
if (!ea.isFile() || !eb.isFile()) return false;
|
|
774
|
+
let bufA;
|
|
775
|
+
let bufB;
|
|
776
|
+
try {
|
|
777
|
+
bufA = await readFile(aPath);
|
|
778
|
+
bufB = await readFile(bPath);
|
|
779
|
+
} catch { return false; }
|
|
780
|
+
if (bufA.length !== bufB.length) return false;
|
|
781
|
+
if (!bufA.equals(bufB)) return false;
|
|
782
|
+
}
|
|
783
|
+
return true;
|
|
784
|
+
}
|
|
785
|
+
|
|
739
786
|
/**
|
|
740
787
|
* Install an extension from npm, local path, or https git URL.
|
|
741
788
|
*
|
|
@@ -747,7 +794,7 @@ export function extensionAuditBrief(manifest, skillBodies) {
|
|
|
747
794
|
* accept_degraded_trident?: boolean,
|
|
748
795
|
* tridentExecutor?: Function, // test seam — forwarded as runTrident({executor})
|
|
749
796
|
* }} opts
|
|
750
|
-
* @returns {Promise<{ok: boolean, name?: string, version?: string, scope?: string, gate_result_block?: string, errors?: string[]}>}
|
|
797
|
+
* @returns {Promise<{ok: boolean, name?: string, version?: string, scope?: string, gate_result_block?: string, errors?: string[], unchanged?: boolean}>}
|
|
751
798
|
*/
|
|
752
799
|
export async function installExtension(source, opts = {}) {
|
|
753
800
|
if (!opts || typeof opts !== 'object') {
|
|
@@ -1015,6 +1062,29 @@ export async function installExtension(source, opts = {}) {
|
|
|
1015
1062
|
await cp(s.absPath, dst, { force: true });
|
|
1016
1063
|
}
|
|
1017
1064
|
|
|
1065
|
+
// V155-061 (v1.5.5): byte-identical short-circuit. Re-installing the
|
|
1066
|
+
// same version was unconditional rm+rename → registry rewrite with a
|
|
1067
|
+
// fresh installed_at timestamp. That defeats staleness audits and
|
|
1068
|
+
// makes downstream watchers + hot-reloaders churn for nothing. If the
|
|
1069
|
+
// staged tmpScopeDir is byte-identical to the existing scopeDir, skip
|
|
1070
|
+
// both the swap AND the registry update so installed_at stays pinned
|
|
1071
|
+
// to the original install. Mirrors `syncCodexAgents`'s `existing ===
|
|
1072
|
+
// rendered` pattern. We still need the staged dir cleaned up if we
|
|
1073
|
+
// bail out.
|
|
1074
|
+
const identical = await dirsAreByteIdentical(tmpScopeDir, scopeDir);
|
|
1075
|
+
if (identical) {
|
|
1076
|
+
await rm(tmpScopeDir, { recursive: true, force: true }).catch(() => {});
|
|
1077
|
+
return {
|
|
1078
|
+
ok: true,
|
|
1079
|
+
name: manifest.name,
|
|
1080
|
+
version: manifest.version,
|
|
1081
|
+
scope: opts.scope,
|
|
1082
|
+
gate_result_block: gateResultBlock,
|
|
1083
|
+
// Diagnostic so callers can tell a no-op install from a fresh one.
|
|
1084
|
+
unchanged: true,
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1018
1088
|
// Atomic flip: drop old scopeDir, rename tmp -> scope. fs.rename is
|
|
1019
1089
|
// atomic on POSIX when source/dest are on the same filesystem (they are
|
|
1020
1090
|
// — same parent directory). On Windows rename to existing dir fails, so
|
|
@@ -1129,18 +1199,37 @@ export async function installExtension(source, opts = {}) {
|
|
|
1129
1199
|
}
|
|
1130
1200
|
}
|
|
1131
1201
|
|
|
1202
|
+
// V155-003 / TP-002 (v1.5.5 Trident): when a project-scope install only
|
|
1203
|
+
// partially deploys to platforms (e.g. some skill writes failed), the
|
|
1204
|
+
// registry has already been updated but the user's editor world is
|
|
1205
|
+
// incomplete. The prior shape returned `ok: 'partial'` — a string —
|
|
1206
|
+
// which is TRUTHY under JS-idiomatic `if (r.ok)` checks, so legacy
|
|
1207
|
+
// callers still saw the partial deploy as success. That's the very bug
|
|
1208
|
+
// class V155-003 was meant to retire.
|
|
1209
|
+
//
|
|
1210
|
+
// TP-002 closes the loop: `ok` is now STRICTLY BOOLEAN (false on
|
|
1211
|
+
// partial), and a new `status` field carries the tri-state for callers
|
|
1212
|
+
// that want to differentiate full success vs partial vs failure.
|
|
1213
|
+
// Legacy `if (r.ok) succeed()` consumers now correctly refuse on
|
|
1214
|
+
// partial; new callers can branch on `r.status === 'partial'`.
|
|
1215
|
+
const partialFailed = opts.scope === 'project' && deployPartial === true;
|
|
1132
1216
|
return {
|
|
1133
|
-
ok:
|
|
1217
|
+
ok: !partialFailed,
|
|
1218
|
+
status: partialFailed ? 'partial' : 'success',
|
|
1134
1219
|
name: manifest.name,
|
|
1135
1220
|
version: manifest.version,
|
|
1136
1221
|
scope: opts.scope,
|
|
1137
1222
|
gate_result_block: gateResultBlock,
|
|
1138
1223
|
deploy: deployInfo,
|
|
1139
1224
|
deploy_partial: deployPartial,
|
|
1225
|
+
...(partialFailed
|
|
1226
|
+
? { error: 'deploy-partial', partial_failures: partialDeployFailures }
|
|
1227
|
+
: {}),
|
|
1140
1228
|
};
|
|
1141
1229
|
} catch (err) {
|
|
1142
1230
|
return {
|
|
1143
1231
|
ok: false,
|
|
1232
|
+
status: 'failed',
|
|
1144
1233
|
errors: [err && err.message ? err.message : String(err)],
|
|
1145
1234
|
gate_result_block: gateResultBlock,
|
|
1146
1235
|
};
|