johns-harness 2026.9.17 → 2026.9.18

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/README.md CHANGED
@@ -73,8 +73,8 @@ node johnness.mjs --help
73
73
  ### Verify
74
74
 
75
75
  ```bash
76
- npm test # 827 tests against the installed dependency tree
77
- bash scripts/verify-patches.sh # 405 checks across every patch family
76
+ npm test # 850 tests against the installed dependency tree
77
+ bash scripts/verify-patches.sh # 417 checks across every patch family
78
78
  ```
79
79
 
80
80
  ## Launch a swarm with one command
@@ -187,7 +187,7 @@ The log is a [`RULES.md`](RULES.md) workspace convention enforced through the ac
187
187
 
188
188
  ## Patch families
189
189
 
190
- The runtime carries 65 patch families. Each is a production fix applied at the source with a regression test, a durable patch marker, and a rollback path. A verifier runs 405 checks against the package tree and installed dependencies, and 827 tests run against the installed dependency tree.
190
+ The runtime carries 65 patch families plus the 65.1 safe-update amendment. Each is a production fix applied at the source with a regression test, a durable patch marker, and a rollback path. A verifier runs 417 checks against the package tree and installed dependencies, and 850 tests run against the installed dependency tree.
191
191
 
192
192
  Every family follows the same six steps: reproduce the failure, trace the exact runtime path, make the smallest source-level change that restores the invariant, add a regression test and a patch marker, run the verifier, and retain rollback artifacts. The full index: [`docs/PATCHES.md`](docs/PATCHES.md).
193
193
 
@@ -1,3 +1,5 @@
1
+ // JOHNNESS_PATCH_SAFE_VOLUME_UPDATE_65_1
2
+ import { readInstalledVersion as readUpdatedRuntimeVersion } from "./update-command.js";
1
3
  // JOHNNESS_PATCH_CATALOG_REFRESH_65
2
4
  import { refreshJohnnessCatalog65 } from "./catalog-refresh.js";
3
5
  import { g as resolveStateDir, i as isNixMode, l as resolveGatewayLockDir, o as resolveConfigPath, r as STATE_DIR, t as CONFIG_PATH, u as resolveGatewayPort } from "./paths-BJV7vkaX.js";
@@ -21754,6 +21756,10 @@ async function scheduleRestartSentinelWake(_params) {
21754
21756
  const sentinel = await consumeRestartSentinel();
21755
21757
  if (!sentinel) return;
21756
21758
  const payload = sentinel.payload;
21759
+ if (payload.stats?.mode === "chat.update") {
21760
+ const running = readUpdatedRuntimeVersion();
21761
+ payload.message = running === payload.stats.targetVersion ? `johns-harness is now on ${running}. The new runtime has booted.` : `Update restart complete. Running johns-harness ${running}; requested ${payload.stats.targetVersion ?? "unknown"}.`;
21762
+ }
21757
21763
  const sessionKey = payload.sessionKey?.trim();
21758
21764
  const message = formatRestartSentinelMessage(payload);
21759
21765
  const summary = summarizeRestartSentinel(payload);
@@ -1,3 +1,5 @@
1
+ // JOHNNESS_PATCH_SAFE_VOLUME_UPDATE_65_1
2
+ import { readInstalledVersion as readUpdatedRuntimeVersion } from "./update-command.js";
1
3
  // JOHNNESS_PATCH_CATALOG_REFRESH_65
2
4
  import { refreshJohnnessCatalog65 } from "./catalog-refresh.js";
3
5
  import { a as logVerbose, d as colorize, f as isRich, g as getResolvedLoggerSettings, h as getLogger, m as getChildLogger, p as theme, s as setVerbose } from "./globals-Bv4ZcVWM.js";
@@ -21756,6 +21758,10 @@ async function scheduleRestartSentinelWake(_params) {
21756
21758
  const sentinel = await consumeRestartSentinel();
21757
21759
  if (!sentinel) return;
21758
21760
  const payload = sentinel.payload;
21761
+ if (payload.stats?.mode === "chat.update") {
21762
+ const running = readUpdatedRuntimeVersion();
21763
+ payload.message = running === payload.stats.targetVersion ? `johns-harness is now on ${running}. The new runtime has booted.` : `Update restart complete. Running johns-harness ${running}; requested ${payload.stats.targetVersion ?? "unknown"}.`;
21764
+ }
21759
21765
  const sessionKey = payload.sessionKey?.trim();
21760
21766
  const message = formatRestartSentinelMessage(payload);
21761
21767
  const summary = summarizeRestartSentinel(payload);
@@ -0,0 +1,53 @@
1
+ // JOHNNESS_PATCH_SAFE_VOLUME_UPDATE_65_1
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ export const RECEIPT = '.johnness-runtime-integrity.json';
6
+ export const packageRootForPrefix = (prefix, platform = process.platform) => path.join(prefix, platform === 'win32' ? '' : 'lib', 'node_modules', 'johns-harness');
7
+ const hash = bytes => createHash('sha256').update(bytes).digest('hex');
8
+ const safeRelative = name => typeof name === 'string' && name.length > 0 && !path.isAbsolute(name) && !name.startsWith('node_modules/') && name !== 'node_modules' && !name.split(/[\\/]/).some(p => p === '..' || p === '');
9
+ export function inventoryPackage(root) {
10
+ const files = [];
11
+ function walk(dir) {
12
+ for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a,b) => a.name.localeCompare(b.name))) {
13
+ if ((e.name === 'node_modules' && dir === root) || e.name === '.git') continue;
14
+ const file = path.join(dir, e.name);
15
+ if (e.isSymbolicLink()) throw new Error('Package artifact contains a symlink');
16
+ if (e.isDirectory()) walk(file);
17
+ else if (e.isFile()) { const bytes = fs.readFileSync(file); files.push([path.relative(root, file).split(path.sep).join('/'), bytes.length, hash(bytes)]); }
18
+ }
19
+ }
20
+ walk(root);
21
+ return files;
22
+ }
23
+ export function validateRuntimeTree(root, { receipt, expectedVersion, legacy = true } = {}) {
24
+ try {
25
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
26
+ if (pkg.name !== 'johns-harness' || !pkg.version || (expectedVersion && pkg.version !== expectedVersion)) throw new Error('package version mismatch');
27
+ for (const rel of ['johnness.mjs', 'dist/entry.js', 'extensions/telegram/index.ts']) {
28
+ const stat = fs.lstatSync(path.join(root, rel));
29
+ if (!stat.isFile() || stat.size === 0) throw new Error(`missing or empty entry: ${rel}`);
30
+ }
31
+ if (!receipt && !legacy) throw new Error('integrity receipt missing');
32
+ if (receipt) {
33
+ if (receipt.version !== pkg.version || !Array.isArray(receipt.files) || !receipt.files.length) throw new Error('invalid integrity receipt');
34
+ const seen = new Set();
35
+ for (const [rel, size, digest] of receipt.files) {
36
+ if (!safeRelative(rel) || seen.has(rel) || !Number.isSafeInteger(size) || size < 0 || !/^[a-f0-9]{64}$/.test(digest)) throw new Error('unsafe integrity entry');
37
+ seen.add(rel);
38
+ const stat = fs.lstatSync(path.join(root, rel));
39
+ if (!stat.isFile() || stat.size !== size) throw new Error(`incomplete artifact: ${rel}`);
40
+ if (hash(fs.readFileSync(path.join(root, rel))) !== digest) throw new Error(`artifact hash mismatch: ${rel}`);
41
+ }
42
+ for (const rel of ['package.json','johnness.mjs','dist/entry.js','extensions/telegram/index.ts']) if (!seen.has(rel)) throw new Error('incomplete receipt coverage');
43
+ }
44
+ return { ok: true, version: pkg.version };
45
+ } catch (error) { return { ok: false, reason: error.code ?? error.message }; }
46
+ }
47
+ export function inspectPrefix(prefix, platform = process.platform) {
48
+ let receipt;
49
+ const receiptFile = path.join(prefix, RECEIPT);
50
+ try { if (fs.existsSync(receiptFile)) receipt = JSON.parse(fs.readFileSync(receiptFile, 'utf8')); }
51
+ catch { return { ok: false, reason: 'invalid integrity receipt' }; }
52
+ return validateRuntimeTree(packageRootForPrefix(prefix, platform), { receipt });
53
+ }
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ // JOHNNESS_PATCH_SAFE_VOLUME_UPDATE_65_1
3
+ import { inspectPrefix } from "./runtime-integrity.js";
2
4
  // JOHNNESS_PATCH_UPDATE_COMMAND_62
3
5
  // `johnness-runtime-path`: print the johnness executable an entrypoint should exec.
4
6
  // Prefers the volume runtime installed by `/update` at
@@ -45,9 +47,13 @@ export function resolveRuntimePath({ env = process.env, imageRoot = path.resolve
45
47
  const volumeRoot = path.join(prefix, platform === "win32" ? "" : "lib", "node_modules", PACKAGE);
46
48
  const volumeBin = path.join(volumeRoot, "johnness.mjs");
47
49
  const volumeVersion = readVersion(volumeRoot);
48
- if (!volumeVersion || !fs.existsSync(volumeBin)) return { bin: imageBin, source: "image", version: imageVersion };
49
- if (imageVersion && compare(volumeVersion, imageVersion) < 0) return { bin: imageBin, source: "image", version: imageVersion, skipped: volumeVersion };
50
- return { bin: volumeBin, source: "volume", version: volumeVersion };
50
+ const candidates = [[prefix, "volume"], [prefix + ".previous", "previous"]];
51
+ for (const [candidate, source] of candidates) {
52
+ const checked = inspectPrefix(candidate, platform);
53
+ if (!checked.ok || (imageVersion && compare(checked.version, imageVersion) < 0)) continue;
54
+ return { bin: path.join(candidate, platform === "win32" ? "" : "lib", "node_modules", PACKAGE, "johnness.mjs"), source, version: checked.version };
55
+ }
56
+ return { bin: imageBin, source: "image", version: imageVersion, ...(volumeVersion ? { skipped: volumeVersion } : {}) };
51
57
  }
52
58
 
53
59
  const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
@@ -1,3 +1,4 @@
1
+ // JOHNNESS_PATCH_SAFE_VOLUME_UPDATE_65_1
1
2
  import { p as theme } from "./globals-Bv4ZcVWM.js";
2
3
  import { g as resolveStateDir, n as DEFAULT_GATEWAY_PORT, u as resolveGatewayPort } from "./paths-BfR2LXbA.js";
3
4
  import { f as defaultRuntime } from "./subsystem-Cf9yS0UI.js";
@@ -1049,6 +1050,7 @@ async function maybeRestartService(params) {
1049
1050
  }
1050
1051
  }
1051
1052
  async function updateCommand(opts) {
1053
+ if (await (await import("./update-command.js")).maybeHandleVolumeCliUpdate(opts, { exit: code => { process.exitCode = code; } })) return;
1052
1054
  suppressDeprecations();
1053
1055
  const timeoutMs = parseTimeoutMsOrExit(opts.timeout);
1054
1056
  const shouldRestart = opts.restart !== false;
@@ -1,3 +1,5 @@
1
+ // JOHNNESS_PATCH_SAFE_VOLUME_UPDATE_65_1
2
+ import { installVolumeRuntime } from "./volume-update.js";
1
3
  // JOHNNESS_PATCH_UPDATE_COMMAND_62
2
4
  // Owner-only `/update` chat command: install a newer johns-harness on the host the
3
5
  // gateway runs on, then restart into it. Two install targets, chosen automatically:
@@ -102,6 +104,7 @@ export function installArgv(target, spec) {
102
104
  }
103
105
 
104
106
  export function manualCommand(target, spec) {
107
+ if (target.mode === "volume") return `node ${JSON.stringify(path.join(path.dirname(installedPackageJson(target)), "johnness.mjs"))} update --tag ${spec.slice(spec.lastIndexOf("@") + 1)} --no-restart`;
105
108
  return installArgv(target, spec).join(" ");
106
109
  }
107
110
 
@@ -143,7 +146,10 @@ export async function runChatInstall({ tag, env = process.env, packageRoot, time
143
146
  const manual = manualCommand(target, spec);
144
147
  if (!target.prefix) return { ok: false, target, spec, manual, tail: "Could not detect an npm prefix for the running install." };
145
148
  const runUpdateStep = deps.runUpdateStep ?? (await loadUpdater()).runUpdateStep;
146
- if (target.mode === "volume") await fsp.mkdir(target.prefix, { recursive: true });
149
+ if (target.mode === "volume") {
150
+ const result = await (deps.installVolumeRuntime ?? installVolumeRuntime)({ target, tag, packageRoot, timeoutMs, runUpdateStep, env, deps });
151
+ return { ...result, target, spec, manual };
152
+ }
147
153
  const step = await runUpdateStep({ name: "npm install", argv: installArgv(target, spec), timeoutMs });
148
154
  if (step.exitCode !== 0) return { ok: false, target, spec, manual, tail: redactTail(`${step.stdoutTail ?? ""}\n${step.stderrTail ?? ""}`) };
149
155
  let installedVersion = null;
@@ -154,6 +160,24 @@ export async function runChatInstall({ tag, env = process.env, packageRoot, time
154
160
  return { ok: true, target, spec, manual, installedVersion };
155
161
  }
156
162
 
163
+ export async function maybeHandleVolumeCliUpdate(opts, deps = {}) {
164
+ const env = deps.env ?? process.env;
165
+ const target = resolveUpdatePrefix({ env, packageRoot: deps.packageRoot });
166
+ if (target.mode !== "volume") return false;
167
+ const log = deps.log ?? console.log, error = deps.error ?? console.error;
168
+ const tag = opts.tag ?? "latest";
169
+ if ((opts.channel && opts.channel !== "stable") || (tag !== "latest" && !VERSION_RE.test(tag))) {
170
+ error("Volume updates require --tag latest or an exact release version; the serving runtime was not changed.");
171
+ (deps.exit ?? (code => { process.exitCode = code; }))(1); return true;
172
+ }
173
+ if (opts.dryRun) { log(JSON.stringify({ dryRun: true, mode: "volume", prefix: target.prefix, stagesAndVerifies: true, ephemeralCache: true, keepsPreviousTree: false })); return true; }
174
+ const result = await runChatInstall({ tag, env, packageRoot: deps.packageRoot, deps, timeoutMs: deps.timeoutMs });
175
+ if (!result.ok) { error(redactTail(result.tail)); (deps.exit ?? (code => { process.exitCode = code; }))(1); return true; }
176
+ if (opts.json) log(JSON.stringify({ ok: true, version: result.installedVersion, prefix: target.prefix, restartRequired: true }));
177
+ else log(`Installed johns-harness ${result.installedVersion} safely. Restart the gateway supervisor to use it; no configuration was changed.`);
178
+ return true;
179
+ }
180
+
157
181
  function buildRoute(params) {
158
182
  return {
159
183
  channel: params.ctx?.OriginatingChannel || params.command?.channel,
@@ -175,7 +199,7 @@ function buildRoute(params) {
175
199
  export async function restartIntoUpdate({ target, deps, log = console.info, exit = (code) => process.exit(code) }) {
176
200
  if (target.mode === "volume") {
177
201
  log("[update] volume runtime installed; exiting 75 for supervisor restart into the new runtime");
178
- setTimeout(() => exit(75), 1500).unref?.();
202
+ setTimeout(() => (deps.exit ?? exit)(75), 1500).unref?.();
179
203
  return "supervisor";
180
204
  }
181
205
  try {
@@ -249,10 +273,10 @@ export async function handleUpdateCommand(params, allowTextCommands, { routeRepl
249
273
  status: "ok",
250
274
  ts: Date.now(),
251
275
  sessionKey: params.sessionKey,
252
- deliveryContext: delivery?.deliveryContext,
253
- threadId: delivery?.threadId,
276
+ deliveryContext: delivery?.deliveryContext ?? { channel: route.channel, to: route.to, accountId: route.accountId },
277
+ threadId: delivery?.threadId ?? route.threadId,
254
278
  message: `Updated johns-harness ${current} -> ${result.installedVersion} (${result.target.mode} prefix ${result.target.prefix}).`,
255
- stats: { mode: "chat.update", reason: "/update" },
279
+ stats: { mode: "chat.update", reason: "/update", targetVersion: result.installedVersion },
256
280
  });
257
281
  } catch {}
258
282
  }
@@ -260,7 +284,7 @@ export async function handleUpdateCommand(params, allowTextCommands, { routeRepl
260
284
  const restartText = how === "supervisor"
261
285
  ? "Restarting through the supervisor; the entrypoint will start the new runtime."
262
286
  : how === "service" ? "Restarting the gateway service." : how === "gateway" ? "Restarting the gateway." : "Restart the gateway to finish (johnness gateway restart).";
263
- await deliver(`Installed johns-harness ${result.installedVersion} into ${result.target.prefix}. ${restartText} I will confirm the running version when I am back.`);
287
+ if (result.target.mode !== "volume") await deliver(`Installed johns-harness ${result.installedVersion} into ${result.target.prefix}. ${restartText} I will confirm the running version when I am back.`);
264
288
  } catch (error) {
265
289
  await deliver(`Update failed: ${redactTail(error?.message ?? String(error), 5)}\nFix by hand: ${manualCommand(target, `${PACKAGE}@${targetVersion}`)}`);
266
290
  } finally {
@@ -0,0 +1,208 @@
1
+ // JOHNNESS_PATCH_SAFE_VOLUME_UPDATE_65_1
2
+ // Stage and verify first. Never run npm against the serving prefix.
3
+ import fs from 'node:fs';
4
+ import fsp from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import { createHash, randomUUID } from 'node:crypto';
8
+ import { Readable, Transform } from 'node:stream';
9
+ import { pipeline } from 'node:stream/promises';
10
+ import { x as extract, t as listTar } from 'tar';
11
+ import { RECEIPT, inventoryPackage, packageRootForPrefix, validateRuntimeTree } from './runtime-integrity.js';
12
+
13
+ const MB = 1024 * 1024;
14
+ const sha = value => createHash('sha256').update(value).digest('hex');
15
+ const exists = file => fs.existsSync(file);
16
+ const within = (parent, child) => { const r = path.relative(path.resolve(parent), path.resolve(child)); return r === '' || (!r.startsWith('..'+path.sep) && r !== '..' && !path.isAbsolute(r)); };
17
+ const readJson = file => JSON.parse(fs.readFileSync(file, 'utf8'));
18
+
19
+ export async function scanDependencies(root) {
20
+ const result = { bytes: 0, files: 0, dirs: 0, empty: new Set() };
21
+ if (!exists(root)) return result;
22
+ async function walk(dir) {
23
+ for (const e of await fsp.readdir(dir, { withFileTypes: true })) {
24
+ const file = path.join(dir, e.name);
25
+ if (e.isDirectory()) { result.dirs++; await walk(file); }
26
+ else if (e.isFile()) { const st = await fsp.stat(file); result.files++; result.bytes += st.size; if (!st.size) result.empty.add(path.relative(root, file)); }
27
+ }
28
+ }
29
+ await walk(root);
30
+ return result;
31
+ }
32
+ export async function verifyEmptyDependencies(root, empty, { fetchImpl = fetch, scratch, signal } = {}) {
33
+ const manifests = new Map();
34
+ for (const rel of empty) {
35
+ let dir = path.dirname(path.join(root, rel));
36
+ while (within(root, dir) && !exists(path.join(dir, 'package.json'))) dir = path.dirname(dir);
37
+ if (!within(root, dir)) throw new Error(`Unexpected empty dependency file: ${rel}`);
38
+ const pkg = readJson(path.join(dir, 'package.json')), member = 'package/' + path.relative(dir, path.join(root, rel)).split(path.sep).join('/');
39
+ const key = pkg.name + '@' + pkg.version;
40
+ if (!manifests.has(key)) {
41
+ const metaRes = await fetchImpl(`https://registry.npmjs.org/${encodeURIComponent(pkg.name)}/${encodeURIComponent(pkg.version)}`, { signal });
42
+ if (!metaRes.ok) throw new Error('Cannot authenticate empty dependency fixtures');
43
+ const meta = await metaRes.json(), url = new URL(meta.dist?.tarball);
44
+ if (meta.name !== pkg.name || meta.version !== pkg.version || url.origin !== 'https://registry.npmjs.org' || !/^sha512-[A-Za-z0-9+/]+=*$/.test(meta.dist?.integrity ?? '')) throw new Error('Invalid dependency fixture metadata');
45
+ const res = await fetchImpl(url.href, { signal });
46
+ if (!res.ok) throw new Error('Cannot fetch dependency fixture manifest');
47
+ const archive = path.join(scratch, 'dependency-' + sha(key) + '.tgz');
48
+ const digest = createHash('sha512'); let received = 0;
49
+ await pipeline(Readable.fromWeb(res.body), new Transform({ transform(chunk, _enc, cb) { received += chunk.length; if (received > 512*MB) return cb(new Error('Dependency archive too large')); digest.update(chunk); cb(null, chunk); } }), fs.createWriteStream(archive, { flags: 'wx', mode: 0o600 }));
50
+ if ('sha512-' + digest.digest('base64') !== meta.dist.integrity) throw new Error('Dependency fixture integrity mismatch');
51
+ const allowed = new Set();
52
+ await listTar({ file: archive, strict: true, onReadEntry: entry => { if (entry.type === 'File' && entry.size === 0) allowed.add(entry.path); } });
53
+ await fsp.unlink(archive); manifests.set(key, allowed);
54
+ }
55
+ if (!manifests.get(key).has(member)) throw new Error(`Unexpected empty dependency file: ${rel}`);
56
+ }
57
+ return [...empty].sort();
58
+ }
59
+ export function requiredVolumeBytes({ tarballBytes, unpackedBytes, packageFiles = 0, dependencyBytes = 0, dependencyFiles = 0, dependencyDirs = 0, reuse = false }) {
60
+ return Math.ceil(Math.max(tarballBytes * 3, unpackedBytes + packageFiles * 4096 + 16 * MB + (reuse ? dependencyFiles * 512 + dependencyDirs * 4096 : dependencyBytes * 1.25)));
61
+ }
62
+ function matchingContract(oldRoot, nextRoot) {
63
+ try {
64
+ const a = readJson(path.join(oldRoot, 'package.json')), b = readJson(path.join(nextRoot, 'package.json'));
65
+ for (const key of ['dependencies','optionalDependencies','peerDependencies','engines']) if (JSON.stringify(a[key]) !== JSON.stringify(b[key])) return false;
66
+ // Only the known, identical postinstall may run against a link-cloned dependency tree.
67
+ for (const rel of ['scripts/postinstall.mjs','dist/migrate-plugin-adapters.js','dist/migrate-plan.js']) if (sha(fs.readFileSync(path.join(oldRoot, rel))) !== sha(fs.readFileSync(path.join(nextRoot, rel)))) return false;
68
+ return true;
69
+ } catch { return false; }
70
+ }
71
+ function mutableDependencyFiles(artifact) {
72
+ const mutable = new Set();
73
+ const vendor = path.join(artifact, 'vendor/patched-deps');
74
+ if (!exists(vendor)) return mutable;
75
+ function walk(dir) {
76
+ const manifest = path.join(dir, 'package.json');
77
+ if (exists(manifest)) {
78
+ const pkg = readJson(manifest);
79
+ for (const [rel] of inventoryPackage(dir)) if (rel !== 'package.json') mutable.add(path.join(pkg.name, rel));
80
+ return;
81
+ }
82
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) if (e.isDirectory()) walk(path.join(dir, e.name));
83
+ }
84
+ walk(vendor);
85
+ return mutable;
86
+ }
87
+ export async function cloneDependencies(source, target, mutable = new Set()) {
88
+ async function walk(dir, dest) {
89
+ await fsp.mkdir(dest, { recursive: true });
90
+ for (const e of await fsp.readdir(dir, { withFileTypes: true })) {
91
+ const from = path.join(dir, e.name), to = path.join(dest, e.name), rel = path.relative(source, from);
92
+ if (e.isDirectory()) await walk(from, to);
93
+ else if (e.isSymbolicLink()) {
94
+ if (rel === 'mr-memory' || [...mutable].some(p => p === rel || p.startsWith(rel+path.sep))) throw new Error('Mutable dependency is a symlink; fresh dependency install required');
95
+ await fsp.symlink(await fsp.readlink(from), to);
96
+ } else if (e.isFile()) {
97
+ const memoryAdapterFile = rel.startsWith('mr-memory'+path.sep) && !rel.startsWith(path.join('mr-memory','node_modules')+path.sep);
98
+ if (mutable.has(rel) || memoryAdapterFile) await fsp.copyFile(from, to);
99
+ else await fsp.link(from, to);
100
+ }
101
+ }
102
+ }
103
+ await walk(source, target);
104
+ }
105
+ function assertHealthy(root, receipt, version) {
106
+ const result = validateRuntimeTree(root, { receipt, expectedVersion: version, legacy: false });
107
+ if (!result.ok) throw new Error(`Incomplete staged runtime: ${result.reason}`);
108
+ }
109
+ function assertStep(step) {
110
+ if (step.exitCode !== 0 || /ENOSPC|TAR_ENTRY_ERROR|no space left on device/i.test(`${step.stderrTail??''}\n${step.stdoutTail??''}`)) throw new Error('Install step failed or reported incomplete extraction; serving runtime preserved');
111
+ }
112
+
113
+ export async function installVolumeRuntime({ target, tag, packageRoot, timeoutMs = 20*60000, runUpdateStep, env = process.env, deps = {} }) {
114
+ const prefix = path.resolve(target.prefix), parent = path.dirname(prefix);
115
+ const current = packageRootForPrefix(prefix), sourceRoot = exists(current) ? current : packageRoot;
116
+ const stage = prefix + '.stage-' + randomUUID(), previous = prefix + '.previous';
117
+ const lockPath = prefix + '.update.lock';
118
+ const rename = deps.rename ?? fsp.rename, remove = deps.remove ?? fsp.rm;
119
+ const fetchImpl = deps.fetch ?? fetch;
120
+ let lock, scratch, cache, response, promoted = false, previousMoved = false, recordWritten = false, cleanupStarted = false;
121
+ const recordPath = prefix + ".previous-version.json";
122
+ const previousRecord = exists(recordPath) ? await fsp.readFile(recordPath) : null;
123
+ try {
124
+ await fsp.mkdir(parent, { recursive: true });
125
+ lock = await fsp.open(lockPath, 'wx', 0o600);
126
+ await lock.writeFile(JSON.stringify({ pid: process.pid, startedAt: Date.now(), version: tag }));
127
+ if (exists(previous)) throw new Error('A previous interrupted update needs recovery before another install');
128
+ const signal = AbortSignal.timeout(timeoutMs);
129
+ const metadataResponse = await fetchImpl(`https://registry.npmjs.org/johns-harness/${encodeURIComponent(tag)}`, { signal });
130
+ if (!metadataResponse.ok) throw new Error(`Registry metadata HTTP ${metadataResponse.status} (E${metadataResponse.status})`);
131
+ const meta = await metadataResponse.json();
132
+ if (meta.name !== 'johns-harness' || !/^\d{4}\.\d{1,2}\.\d{1,2}(?:-[\w.-]+)?$/.test(meta.version) || (tag !== 'latest' && meta.version !== tag)) throw new Error('Registry package/version mismatch');
133
+ const url = new URL(meta.dist?.tarball);
134
+ if (url.origin !== 'https://registry.npmjs.org' || !/^sha512-[A-Za-z0-9+/]+=*$/.test(meta.dist?.integrity??'')) throw new Error('Registry artifact integrity metadata invalid');
135
+ response = await fetchImpl(url.href, { signal, headers: { 'accept-encoding': 'identity' } });
136
+ if (!response.ok) throw new Error(`Registry artifact HTTP ${response.status}`);
137
+ const tarballBytes = Number(response.headers.get('content-length'));
138
+ if (!Number.isSafeInteger(tarballBytes) || tarballBytes <= 0 || tarballBytes > 512*MB) throw new Error('Cannot establish a safe tarball size');
139
+ const available = async () => { const s = await (deps.statfs ?? fsp.statfs)(parent); return Number(s.bavail)*Number(s.bsize); };
140
+ const insistSpace = async required => { const free = await available(); if (free < required) throw new Error(`Insufficient volume space: need ${Math.ceil(required/MB)} MiB free, have ${Math.floor(free/MB)} MiB. Free at least ${Math.ceil((required-free)/MB)} MiB; live runtime untouched.`); };
141
+ await insistSpace(tarballBytes * 3);
142
+ scratch = await fsp.mkdtemp(path.join(deps.tempRoot ?? os.tmpdir(), 'johnness-update-'));
143
+ if (within(parent, scratch) || (env.JOHNNESS_STATE_DIR && within(env.JOHNNESS_STATE_DIR, scratch))) throw new Error('Update scratch/cache must be outside the persistent state volume');
144
+ cache = path.join(scratch, 'npm-cache');
145
+ await fsp.mkdir(cache);
146
+ const archive = path.join(scratch, 'package.tgz'), artifact = path.join(scratch, 'artifact');
147
+ const digest = createHash('sha512'); let received = 0;
148
+ await pipeline(Readable.fromWeb(response.body), new Transform({ transform(chunk, _enc, cb) { received += chunk.length; if (received > tarballBytes) return cb(new Error('Artifact exceeded declared size')); digest.update(chunk); cb(null, chunk); } }), fs.createWriteStream(archive, { flags: 'wx', mode: 0o600 }));
149
+ if (received !== tarballBytes || 'sha512-'+digest.digest('base64') !== meta.dist.integrity) throw new Error('Downloaded artifact integrity mismatch');
150
+ await fsp.mkdir(artifact);
151
+ await extract({ file: archive, cwd: artifact, strip: 1, strict: true, preservePaths: false, filter: (name, entry) => {
152
+ if (!name.startsWith('package/') || name.split('/').includes('..') || ['SymbolicLink','Link'].includes(entry.type)) throw new Error('Unsafe package archive member');
153
+ return true;
154
+ } });
155
+ const files = inventoryPackage(artifact), receipt = { managedBy: 'johnness-65.1', version: meta.version, integrity: meta.dist.integrity, files };
156
+ assertHealthy(artifact, receipt, meta.version);
157
+ const sourceDeps = sourceRoot && path.join(sourceRoot, 'node_modules');
158
+ const scanned = await scanDependencies(sourceDeps ?? path.join(scratch, 'none'));
159
+ let reuse = process.platform !== 'win32' && !!sourceDeps && exists(sourceDeps) && matchingContract(sourceRoot, artifact) && (await fsp.stat(sourceDeps)).dev === (await fsp.stat(parent)).dev;
160
+ const mutable = mutableDependencyFiles(artifact);
161
+ if (reuse) for (const name of ['mr-memory', ...new Set([...mutable].map(p => p.startsWith('@') ? p.split(path.sep).slice(0,2).join(path.sep) : p.split(path.sep)[0]))]) if (exists(path.join(sourceDeps,name)) && (await fsp.lstat(path.join(sourceDeps,name))).isSymbolicLink()) reuse = false;
162
+ await insistSpace(requiredVolumeBytes({ tarballBytes, unpackedBytes: files.reduce((n,f)=>n+f[1],0), packageFiles: files.length, dependencyBytes: scanned.bytes, dependencyFiles: scanned.files, dependencyDirs: scanned.dirs, reuse }));
163
+ const stagedRoot = packageRootForPrefix(stage);
164
+ await fsp.mkdir(path.dirname(stagedRoot), { recursive: true });
165
+ if (reuse) {
166
+ await fsp.cp(artifact, stagedRoot, { recursive: true, errorOnExist: true, force: false });
167
+ await cloneDependencies(sourceDeps, path.join(stagedRoot,'node_modules'), mutable);
168
+ assertHealthy(stagedRoot, receipt, meta.version);
169
+ assertStep(await runUpdateStep({ name: 'staged dependency patches', argv: [process.execPath, path.join(stagedRoot,'scripts/postinstall.mjs')], cwd: stagedRoot, timeoutMs }));
170
+ await fsp.mkdir(path.join(stage,'bin'), { recursive: true });
171
+ await fsp.symlink('../lib/node_modules/johns-harness/johnness.mjs', path.join(stage,'bin/johnness'));
172
+ } else {
173
+ assertStep(await runUpdateStep({ name: 'staged npm install', argv: ['env',`npm_config_cache=${cache}`,'npm','i','-g','--prefix',stage,'--cache',cache,'--prefer-offline','--no-audit','--no-fund',archive], timeoutMs }));
174
+ }
175
+ assertHealthy(stagedRoot, receipt, meta.version);
176
+ const afterDeps = await scanDependencies(path.join(stagedRoot,'node_modules'));
177
+ receipt.emptyDependencies = await (deps.verifyEmptyDependencies ?? verifyEmptyDependencies)(path.join(stagedRoot,'node_modules'), afterDeps.empty, { fetchImpl, scratch, signal });
178
+ await fsp.writeFile(path.join(stage, RECEIPT), JSON.stringify(receipt)+'\n', { mode: 0o600 });
179
+ const oldVersion = exists(current) ? readJson(path.join(current,'package.json')).version : null;
180
+ if (exists(prefix)) { await rename(prefix, previous); previousMoved = true; }
181
+ await rename(stage, prefix); promoted = true;
182
+ await deps.afterPromotion?.(prefix);
183
+ assertHealthy(packageRootForPrefix(prefix), receipt, meta.version);
184
+ // Commit is complete only after the old tree is gone. Keep version metadata, not code.
185
+ await fsp.writeFile(recordPath, JSON.stringify({ previousVersion: oldVersion, currentVersion: meta.version, rollback: oldVersion ? `johnness update --tag ${oldVersion}` : null })+'\n', { mode: 0o600 });
186
+ recordWritten = true;
187
+ if (previousMoved) { cleanupStarted = true; await remove(previous, { recursive: true, force: true }); }
188
+ return { ok: true, installedVersion: meta.version, reusedDependencies: reuse, verifiedFiles: files.length };
189
+ } catch (error) {
190
+ if (cleanupStarted) return { ok: false, installedVersion: tag, committed: true, tail: "New verified runtime committed, but old-tree cleanup failed. Serving the new runtime; remove the .previous directory before retrying. Never restore its partially deleted contents." };
191
+ if (previousMoved && exists(previous)) {
192
+ if (promoted && exists(prefix)) await rename(prefix, stage);
193
+ await rename(previous, prefix);
194
+ promoted = false;
195
+ }
196
+ if (promoted && !previousMoved && exists(prefix)) { await rename(prefix, stage); promoted = false; }
197
+ if (recordWritten && !promoted) {
198
+ if (previousRecord) await fsp.writeFile(recordPath, previousRecord);
199
+ else await fsp.unlink(recordPath).catch(()=>{});
200
+ }
201
+ return { ok: false, tail: String(error.code ?? error.message).replace(/(?:npm_[A-Za-z0-9]+|Bearer\s+\S+)/g, '<redacted>') };
202
+ } finally {
203
+ try { await response?.body?.cancel(); } catch {}
204
+ if (!promoted) await fsp.rm(stage, { recursive: true, force: true }).catch(()=>{});
205
+ if (scratch) await fsp.rm(scratch, { recursive: true, force: true }).catch(()=>{});
206
+ if (lock) { await lock.close(); await fsp.unlink(lockPath).catch(()=>{}); }
207
+ }
208
+ }
package/docs/PATCHES.md CHANGED
@@ -17,7 +17,7 @@ they predate ~2026-06-06).
17
17
 
18
18
  ## Index
19
19
 
20
- Family 01 is retired. Families 02 through 41 and 43 through 65 are live on `main`. Family 42 is omitted; native Codex OAuth ownership is Family 58.
20
+ Family 01 is retired. Families 02 through 41 and 43 through 65, including the 65.1 amendment, are live on `main`. Family 42 is omitted; native Codex OAuth ownership is Family 58.
21
21
 
22
22
  1. **01, announce queue TTL, retired:** Removed the five-minute age filter that could discard delayed sub-agent completion announcements. [Details](#01--announce-ttl-retired-2026-07-30)
23
23
  2. **02, failover rotation:** Returns model selection to the highest-priority profile after its cooldown ends. [Details](#02--failover-rotation-undocumented)
@@ -85,6 +85,8 @@ Family 01 is retired. Families 02 through 41 and 43 through 65 are live on `main
85
85
  64. **64, Codex to API sibling fallback:** Any `openai-codex/<model>` failover tries `openai/<model>` first, before configured fallbacks, when an OpenAI API key is present. [Details](#64-codex-to-api-sibling-fallback)
86
86
  65. **65, catalog refresh and selection policy:** API cache defaults cannot create a model allowlist; shipped models remain authoritative and every boot refreshes version-stamped per-agent catalogs while preserving custom entries. [Details](#65-catalog-refresh-and-selection-policy)
87
87
 
88
+ 65.1. **65.1, safe staged volume updater:** Same-volume verified promotion, ephemeral npm cache, one final runtime tree and actual-version confirmation after boot. [Details](#651-safe-staged-in-place-volume-updater)
89
+
88
90
  ---
89
91
 
90
92
  ## 01 - announce-ttl (RETIRED 2026-07-30)
@@ -2464,3 +2466,59 @@ The durable choice is both a merged in-memory catalog and a refreshed on-disk sn
2464
2466
  Baseline: 812 tests and 395 verifier checks. Release: 827 tests and 405 verifier checks, plus the alias contract. Earlier families retain exact byte-identical replay; overlapping historical reconstructions apply Family 65 chronologically, not a relaxed comparison. The Family 59 README counters and pinned CLI artifact are synchronized.
2465
2467
 
2466
2468
  Rex-only deployment procedure: back up the existing state, install 2026.9.17 into the existing volume prefix, restart, inspect the boot/provider/stamp evidence, and use gateway `sessions.patch` on an isolated probe session for both Astra references. Preserve Rex's current OpenAI primary and sub-agent model. Rollback: `npm i -g --prefix /home/rex/.clawdbot/runtime/johnness johns-harness@2026.9.16`, then restart his machine. No other fleet agent is part of this release deployment.
2469
+
2470
+
2471
+ ## 65.1 Safe staged in-place volume updater
2472
+
2473
+ Release: **johns-harness 2026.9.18**. Marker: `JOHNNESS_PATCH_SAFE_VOLUME_UPDATE_65_1`.
2474
+ Patcher: `scripts/patch-safe-volume-update.py [root] [--check]`, seven runtime files,
2475
+ idempotent and fail-closed across every anchor. Verifier: **65.1.1 through 65.1.12**.
2476
+
2477
+ - Chat `/update` and volume-mode CLI `johnness update --tag VERSION` use the same staged
2478
+ installer. A cross-process lock guards the prefix. Download/cache live in an ephemeral
2479
+ OS temp directory, never under persistent state. npm receives both `npm_config_cache`
2480
+ and `--cache`. Network, npm, extraction and preflight failures preserve the live tree.
2481
+ - Preflight requires at least **3x compressed archive bytes**, then the greater of that
2482
+ minimum and unpacked files, directory metadata and dependency staging costs. Refusals
2483
+ report required/free MiB and exactly how much more to free.
2484
+ - The registry tarball is SHA-512 authenticated. An exact path/size/SHA-256 inventory
2485
+ verifies every package file before and after installation and promotion, plus package
2486
+ version and required entrypoints, including Telegram. One intentional package JSONL
2487
+ and legitimate dependency fixtures may be empty only when publisher manifests say so.
2488
+ Dependency empty fixtures are authenticated against their own integrity-checked tarballs.
2489
+ - Identical dependency and postinstall contracts can hardlink unchanged dependencies on
2490
+ the same device. Vendored patch targets and mr-memory JavaScript receive private copies
2491
+ before postinstall, so staging cannot mutate live shared inodes. Changed contracts use
2492
+ a fresh npm install and the larger space requirement. No full dependency copy is retained.
2493
+ - Promotion uses same-volume renames, verifies the promoted tree, writes a small previous
2494
+ version/reinstall record, then deletes the old tree. Success leaves exactly one runtime
2495
+ tree. Chat does not try to send its final volume completion before exit; the existing
2496
+ restart sentinel is consumed after channels start, and both gateway copies report the
2497
+ actual booted package version rather than blindly claiming the requested version.
2498
+
2499
+ ### Failure boundaries and recovery
2500
+
2501
+ Before old-tree deletion begins, caught failures restore the original live prefix. Renames
2502
+ are individually atomic, not a portable two-directory exchange transaction. The selector
2503
+ recognizes a valid `.previous` tree if interrupted between renames; a stale lock or previous
2504
+ path deliberately blocks another updater until the operator inspects/reconciles it. This
2505
+ release does not claim automatic recovery from every power-loss or filesystem failure.
2506
+ Once deletion starts, its contents cannot safely be rolled back: an exceptional cleanup
2507
+ failure reports `committed: true`, retains the verified NEW runtime, and requests removal
2508
+ of the partial `.previous` directory. It never restores a partially deleted old tree.
2509
+ Final confirmation uses the existing startup sentinel delivery lifecycle; transport failure
2510
+ is recorded as a system event, not guaranteed exactly-once delivery to an offline service.
2511
+ CLI volume installation reports the supervisor restart command; it does not kill another
2512
+ process. `--dry-run` displays the in-place plan without staging or modifying the prefix.
2513
+
2514
+ Model defaults and fleet entrypoint config rewriting are unchanged. A 9.17 runtime cannot
2515
+ provide this new installer until bootstrapped from the 9.18 artifact. Once installed, the
2516
+ next `/update` uses this path natively.
2517
+
2518
+ ### Verification
2519
+
2520
+ Full suite **850/850**, source and isolated npm-install verifier **417/417**, 23 focused
2521
+ safety tests. A fake volume exercises private staging, artifact verification, atomic rename
2522
+ promotion, old-tree removal and corrupt-stage rollback. Historical families 51/54/60/61/62/
2523
+ 63/64/65 and alias contracts remain green; Family 65 replay layers the new amendment before
2524
+ byte-exact comparisons. Family 59's README snapshot keeps its exact counts synchronized.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "johns-harness",
3
- "version": "2026.9.17",
3
+ "version": "2026.9.18",
4
4
  "description": "John's Harness: a production agent harness that runs autonomous coding agents as one cooperative swarm.",
5
5
  "keywords": [
6
6
  "ai-agents",