quoxcli 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/lib/api/endpoints/governance.js +7 -0
  3. package/lib/api/endpoints/inboxq.js +39 -0
  4. package/lib/api/endpoints/index.js +2 -0
  5. package/lib/api/endpoints/lucid.js +17 -0
  6. package/lib/api/endpoints/mcp.js +35 -0
  7. package/lib/api/endpoints/notesq.js +2 -0
  8. package/lib/api/endpoints/quoxplan.js +51 -0
  9. package/lib/commands/actions.js +51 -13
  10. package/lib/commands/contacts.js +2 -0
  11. package/lib/commands/lucid.js +6 -0
  12. package/lib/commands/lucidGoals.js +389 -0
  13. package/lib/commands/mail.js +266 -5
  14. package/lib/commands/mcp.js +190 -0
  15. package/lib/commands/mon.js +14 -0
  16. package/lib/commands/monitoring.js +68 -0
  17. package/lib/commands/notesSeal.js +380 -22
  18. package/lib/commands/offload.js +51 -10
  19. package/lib/commands/plan.js +471 -53
  20. package/lib/commands/volt.js +6 -1
  21. package/lib/commands/wazuh.js +65 -9
  22. package/lib/commands/wazuhIntake.js +46 -5
  23. package/lib/core/commandRegistrars.js +1 -0
  24. package/lib/core/config.js +14 -1
  25. package/lib/core/offload.js +83 -0
  26. package/lib/core/output.js +29 -4
  27. package/lib/core/selfUpdate.js +2 -0
  28. package/lib/core/version.js +1 -1
  29. package/lib/quoxon/api.js +4 -0
  30. package/lib/quoxon/app.js +257 -48
  31. package/lib/quoxon/approvalsView.js +3 -1
  32. package/lib/quoxon/ask.js +4 -2
  33. package/lib/quoxon/bang.js +7 -1
  34. package/lib/quoxon/commandShort.js +1 -1
  35. package/lib/quoxon/commands.js +4 -4
  36. package/lib/quoxon/delegationDragon.js +49 -0
  37. package/lib/quoxon/dnd.js +47 -0
  38. package/lib/quoxon/events.js +12 -1
  39. package/lib/quoxon/extensions.js +2 -0
  40. package/lib/quoxon/foreignText.js +49 -0
  41. package/lib/quoxon/inboxView.js +5 -5
  42. package/lib/quoxon/launcher.js +2 -1
  43. package/lib/quoxon/loopCard.js +2 -1
  44. package/lib/quoxon/main.js +8 -2
  45. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  46. package/lib/quoxon/onboarding/view.js +1 -1
  47. package/lib/quoxon/render.js +5 -2
  48. package/lib/quoxon/resend.js +14 -0
  49. package/lib/quoxon/rows.js +3 -1
  50. package/lib/quoxon/secretShape.js +2 -0
  51. package/lib/quoxon/slash/broadcast.js +30 -6
  52. package/lib/quoxon/slash/broadcastRun.js +12 -6
  53. package/lib/quoxon/slash/cliLeaves.json +35 -0
  54. package/lib/quoxon/slash/dashboard.js +2 -0
  55. package/lib/quoxon/slash/listView.js +2 -1
  56. package/lib/quoxon/slash/palette.js +41 -0
  57. package/lib/quoxon/slash/paletteController.js +6 -2
  58. package/lib/quoxon/slash/restart.js +2 -0
  59. package/lib/quoxon/slotChat.js +7 -3
  60. package/lib/quoxon/slotRun.js +2 -0
  61. package/lib/quoxon/webPty.js +15 -0
  62. package/lib/quoxon/whatsnew.jsonl +6 -0
  63. package/lib/render/planBrief.js +614 -0
  64. package/lib/toolOutputShape.js +6 -3
  65. package/package.json +1 -1
@@ -6,12 +6,12 @@
6
6
  * agents cannot read a sealed note. Titles, collections and tags are NOT sealed (they stay searchable).
7
7
  * ENQRYPT is an adapter dependency, loaded on demand; core Quox does not need it.
8
8
  */
9
- import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs';
9
+ import { readFileSync, existsSync, mkdirSync, statSync, lstatSync, openSync, writeSync, closeSync, fsyncSync, renameSync, unlinkSync } from 'fs';
10
10
  import { homedir } from 'os';
11
- import { join, resolve } from 'path';
12
- import { randomUUID } from 'crypto';
11
+ import { join, resolve, dirname } from 'path';
12
+ import { randomUUID, randomBytes, createHash } from 'crypto';
13
13
  import { pathToFileURL } from 'url';
14
- import { QuoxError } from '../core/errors.js';
14
+ import { QuoxError, QuoxApiError } from '../core/errors.js';
15
15
  import { resolveEndpoint } from '../api/endpoints.js';
16
16
 
17
17
  export const SEAL_PURPOSE = 'notesq.note';
@@ -56,17 +56,206 @@ export function readSealProperties(props) {
56
56
  return { fingerprint: p.enqrypt_fp, objectId: p.enqrypt_object, version };
57
57
  }
58
58
 
59
- // Rollback floor: the highest version this client has opened per sealed object. Older envelopes are refused.
59
+ // Rollback floor: the highest version this client has opened or written per sealed object. Older envelopes are
60
+ // refused. ENQRYPT-NOTES F11: the floor fails CLOSED. A floor file that is damaged, unreadable or not a floor map is
61
+ // refused, never read as 0; a floor file that has been used before (marker file, or a floor already seen in this
62
+ // process) and is now missing is refused too. Only a keystore that has never recorded a floor starts at 0. Writes
63
+ // are a fresh read, merge and atomic replace (temp file, fsync, rename) under a cross-process lock file, modelled on
64
+ // the ENQRYPT CLI's state files, so concurrent quox processes cannot lose each other's floors.
60
65
  const floorFile = (dir) => join(dir, 'notes-floor.json');
66
+ const floorMarker = (dir) => join(dir, 'notes-floor.used');
67
+ const FLOOR_LOCK_STALE_MS = 30_000;
68
+ const FLOOR_LOCK_TIMEOUT_MS = 10_000;
69
+ const seenFloors = new Map(); // resolved dir -> Map(objectId -> highest floor seen in this process)
70
+
71
+ function floorRefusal(dir, why) {
72
+ return new QuoxError(
73
+ `The ENQRYPT rollback floor file ${floorFile(dir)} is ${why}, so this device cannot tell whether the server is serving an older version of a sealed note. Nothing was opened or written. `
74
+ + `Restore it from a backup of ${dir}. Only if you accept that an older version could be accepted once, delete ${floorFile(dir)} and ${floorMarker(dir)} to start the floors again.`, 1);
75
+ }
76
+
77
+ function memFloors(dir) {
78
+ const k = resolve(dir);
79
+ if (!seenFloors.has(k)) seenFloors.set(k, new Map());
80
+ return seenFloors.get(k);
81
+ }
82
+
83
+ // RT-13: the floor, its marker, the binding record and the lock are files this CLI writes itself (atomic rename,
84
+ // never through a link). A symbolic link in their place was planted, so it is refused, never followed.
85
+ const isLink = (p) => { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } };
86
+ function markerPresent(dir) {
87
+ if (isLink(floorMarker(dir))) throw floorRefusal(dir, 'unverifiable (its marker file is a symbolic link)');
88
+ return existsSync(floorMarker(dir));
89
+ }
90
+
91
+ function loadFloors(dir) {
92
+ let text;
93
+ if (isLink(floorFile(dir))) throw floorRefusal(dir, 'a symbolic link (not a file this device wrote)');
94
+ try { text = readFileSync(floorFile(dir), 'utf8'); } catch (e) {
95
+ if (e && e.code === 'ENOENT') {
96
+ if (markerPresent(dir) || memFloors(dir).size > 0) throw floorRefusal(dir, 'missing');
97
+ return {};
98
+ }
99
+ throw floorRefusal(dir, `unreadable (${e && e.code ? e.code : 'read error'})`);
100
+ }
101
+ let all;
102
+ try { all = JSON.parse(text); } catch { throw floorRefusal(dir, 'damaged (not valid JSON)'); }
103
+ if (!all || typeof all !== 'object' || Array.isArray(all)) throw floorRefusal(dir, 'damaged (not a floor map)');
104
+ for (const v of Object.values(all)) {
105
+ if (!Number.isSafeInteger(v) || v < 1) throw floorRefusal(dir, 'damaged (a floor is not a positive whole number)');
106
+ }
107
+ return all;
108
+ }
109
+
61
110
  export function readFloor(dir, objectId) {
62
- try { return Number(JSON.parse(readFileSync(floorFile(dir), 'utf8'))[objectId]) || 0; } catch { return 0; }
111
+ const fromFile = Number(loadFloors(dir)[objectId]) || 0;
112
+ return Math.max(fromFile, memFloors(dir).get(objectId) || 0);
113
+ }
114
+
115
+ const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
116
+ function pidAlive(pid) {
117
+ if (!Number.isInteger(pid) || pid <= 0) return false;
118
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
119
+ }
120
+ function readLockFile(lock) {
121
+ if (isLink(lock)) return { pid: 0, t: 0, nonce: '', link: true }; // RT-13/14: never follow a planted link
122
+ try { return JSON.parse(readFileSync(lock, 'utf8')); } catch (e) { return e.code === 'ENOENT' ? null : { pid: 0, t: 0, nonce: '' }; }
123
+ }
124
+ function lockAge(lock) {
125
+ try { return Date.now() - lstatSync(lock).mtimeMs; } catch { return 0; }
126
+ }
127
+ // RT-14: a lock whose time is further in the future than the stale bound (clock skew, or planted) is stale too,
128
+ // otherwise it would block every seal command forever.
129
+ const lockAged = (p) => { const age = lockAge(p); return age > FLOOR_LOCK_STALE_MS || age < -FLOOR_LOCK_STALE_MS; };
130
+ function breakStaleLock(lock, seen) {
131
+ const brk = `${lock}.break`;
132
+ let fd;
133
+ try { fd = openSync(brk, 'wx', 0o600); } catch (e) {
134
+ if (e.code !== 'EEXIST') throw e;
135
+ if (lockAged(brk)) { try { unlinkSync(brk); } catch { /* raced */ } }
136
+ return;
137
+ }
138
+ try {
139
+ const now = readLockFile(lock);
140
+ if (now && now.nonce === seen.nonce) { try { unlinkSync(lock); } catch { /* raced */ } }
141
+ } finally { closeSync(fd); try { unlinkSync(brk); } catch { /* raced */ } }
142
+ }
143
+ function withFloorLock(dir, fn) {
144
+ const lock = `${floorFile(dir)}.lock`;
145
+ const nonce = randomUUID();
146
+ const deadline = Date.now() + FLOOR_LOCK_TIMEOUT_MS;
147
+ for (;;) {
148
+ let fd = null;
149
+ try { fd = openSync(lock, 'wx', 0o600); } catch (e) { if (e.code !== 'EEXIST') throw e; }
150
+ if (fd !== null) {
151
+ try { writeSync(fd, JSON.stringify({ pid: process.pid, t: Date.now(), nonce })); } finally { closeSync(fd); }
152
+ break;
153
+ }
154
+ const held = readLockFile(lock);
155
+ if (held) {
156
+ // A lock with no nonce yet (holder between create and write) is only judged stale once it is old.
157
+ const stale = held.link || (held.nonce ? (!pidAlive(held.pid) || lockAged(lock)) : lockAged(lock));
158
+ if (stale) { breakStaleLock(lock, held); continue; }
159
+ }
160
+ if (Date.now() > deadline) throw new QuoxError(`The ENQRYPT rollback floor file is locked by another quox process (${lock}). Nothing was written; try again.`, 1);
161
+ sleepSync(5 + Math.floor(Math.random() * 20));
162
+ }
163
+ try { return fn(); } finally {
164
+ const h = readLockFile(lock);
165
+ if (h && h.nonce === nonce) { try { unlinkSync(lock); } catch { /* already gone */ } }
166
+ }
63
167
  }
168
+ function atomicWrite(file, data) {
169
+ const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
170
+ try {
171
+ const fd = openSync(tmp, 'wx', 0o600);
172
+ try { writeSync(fd, data); fsyncSync(fd); } finally { closeSync(fd); }
173
+ renameSync(tmp, file);
174
+ } catch (e) { try { unlinkSync(tmp); } catch { /* not created */ } throw e; }
175
+ try { const d = openSync(dirname(file), 'r'); try { fsyncSync(d); } finally { closeSync(d); } } catch { /* directory fsync is best effort */ }
176
+ }
177
+
64
178
  export function bumpFloor(dir, objectId, version) {
65
- let all = {};
66
- try { all = JSON.parse(readFileSync(floorFile(dir), 'utf8')); } catch { /* first use */ }
67
- if (!(Number(all[objectId]) >= version)) all[objectId] = version;
68
179
  mkdirSync(dir, { recursive: true, mode: 0o700 });
69
- writeFileSync(floorFile(dir), JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
180
+ withFloorLock(dir, () => {
181
+ const all = loadFloors(dir); // fresh read inside the lock; a damaged file refuses rather than being overwritten
182
+ const mem = memFloors(dir);
183
+ const next = Math.max(Number(all[objectId]) || 0, mem.get(objectId) || 0, version);
184
+ if (all[objectId] !== next) {
185
+ all[objectId] = next;
186
+ atomicWrite(floorFile(dir), JSON.stringify(all, null, 2) + '\n');
187
+ }
188
+ if (!markerPresent(dir)) atomicWrite(floorMarker(dir), 'This keystore has recorded ENQRYPT rollback floors in notes-floor.json. Do not delete either file.\n');
189
+ mem.set(objectId, next);
190
+ });
191
+ }
192
+
193
+ // NOTE BINDING (red team RT-15). ENQRYPT authenticates (op, purpose, objectId) but not which NotesQ note an
194
+ // envelope belongs to, so a server could serve note A's envelope (and properties) under note B. A BOUND object id
195
+ // commits to the note id: `<nonce8>-<h4>-0b1d-<h4>-<h12>`, the fixed third group marking the format (never a
196
+ // random v4 UUID, whose third group starts with 4) and h = sha256("notesq.note\0" + noteId + "\0" + nonce). The
197
+ // object id is inside the authenticated envelope, so an envelope bound to note A cannot verify as note B. Notes
198
+ // sealed before binding (random UUID object ids) still open, with a warning; this device also records which object
199
+ // it last saw for each note (notes-bind.json, next to the floor) and refuses a mismatch; `seal edit` re-seals an
200
+ // unbound note under a bound id. `seal new` cannot bind at creation (the server assigns the id after the envelope is
201
+ // sent), so a new note is bound by this device's record until its first `seal edit`.
202
+ const BIND_MARK = '0b1d';
203
+ const bindHash = (noteId, nonce) => createHash('sha256').update(`notesq.note\0${noteId}\0${nonce}`).digest('hex');
204
+ export function bindObjectId(noteId, nonce = randomBytes(4).toString('hex')) {
205
+ const h = bindHash(noteId, nonce);
206
+ return `${nonce}-${h.slice(0, 4)}-${BIND_MARK}-${h.slice(4, 8)}-${h.slice(8, 20)}`;
207
+ }
208
+ export const isBoundObjectId = (objectId) => /^[0-9a-f]{8}-[0-9a-f]{4}-0b1d-[0-9a-f]{4}-[0-9a-f]{12}$/.test(String(objectId));
209
+ export function objectBindsNote(objectId, noteId) {
210
+ if (!isBoundObjectId(objectId) || !noteId) return false;
211
+ return bindObjectId(String(noteId), String(objectId).slice(0, 8)) === objectId;
212
+ }
213
+
214
+ const bindFile = (dir) => join(dir, 'notes-bind.json');
215
+ function loadBinds(dir) {
216
+ let text;
217
+ if (isLink(bindFile(dir))) throw new QuoxError(`The note binding record ${bindFile(dir)} is a symbolic link (not a file this device wrote). Nothing was opened or written.`, 1);
218
+ try { text = readFileSync(bindFile(dir), 'utf8'); } catch (e) {
219
+ if (e && e.code === 'ENOENT') return {};
220
+ throw new QuoxError(`The note binding record ${bindFile(dir)} is unreadable (${e && e.code ? e.code : 'read error'}). Nothing was opened or written.`, 1);
221
+ }
222
+ let all;
223
+ try { all = JSON.parse(text); } catch { all = null; }
224
+ if (!all || typeof all !== 'object' || Array.isArray(all) || Object.values(all).some((v) => typeof v !== 'string')) {
225
+ throw new QuoxError(`The note binding record ${bindFile(dir)} is damaged. Nothing was opened or written. Restore it from a backup of ${dir}.`, 1);
226
+ }
227
+ return all;
228
+ }
229
+ export function recordBinding(dir, noteId, objectId) {
230
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
231
+ withFloorLock(dir, () => {
232
+ const all = loadBinds(dir);
233
+ if (all[noteId] === objectId) return;
234
+ all[noteId] = objectId;
235
+ atomicWrite(bindFile(dir), JSON.stringify(all, null, 2) + '\n');
236
+ });
237
+ }
238
+
239
+ /**
240
+ * After an envelope OPENED under objectId, check it belongs to noteId. Refuses a swap; warns for an unbound note
241
+ * this device cannot vouch for. Records the binding. Returns 'bound' | 'recorded' | 'unverified'.
242
+ */
243
+ export function checkNoteBinding(dir, noteId, objectId, { warn = (m) => console.error(m) } = {}) {
244
+ const swapped = (why) => new QuoxError(`Refusing note ${noteId}: ${why} The server may be serving another note's sealed envelope under this id. Nothing was shown or written.`, 1);
245
+ if (isBoundObjectId(objectId)) {
246
+ if (!objectBindsNote(objectId, noteId)) throw swapped(`its sealed envelope (object ${objectId}) is bound to a different note.`);
247
+ recordBinding(dir, noteId, objectId);
248
+ return 'bound';
249
+ }
250
+ const all = loadBinds(dir);
251
+ if (all[noteId] && all[noteId] !== objectId && !objectBindsNote(all[noteId], noteId)) throw swapped(`this device last saw it as sealed object ${all[noteId]}, but the server sent object ${objectId}.`);
252
+ if (all[noteId] && objectBindsNote(all[noteId], noteId)) throw swapped(`this device last saw it bound to object ${all[noteId]}, but the server sent an unbound envelope (object ${objectId}).`);
253
+ const other = Object.keys(all).find((k) => k !== noteId && all[k] === objectId);
254
+ if (other) throw swapped(`its sealed envelope (object ${objectId}) belongs to note ${other} on this device.`);
255
+ const known = all[noteId] === objectId;
256
+ if (!known) warn(`Warning: note ${noteId} was sealed before notes were bound to their ids, and this device has no record of it, so it cannot prove the server sent the right note. Re-seal it to bind it: quox notes seal edit ${noteId}`);
257
+ recordBinding(dir, noteId, objectId);
258
+ return known ? 'recorded' : 'unverified';
70
259
  }
71
260
 
72
261
  /** A passphrase file must be private to its owner; anything looser is refused rather than trusted. */
@@ -76,15 +265,36 @@ export function readPassphraseFile(path) {
76
265
  return readFileSync(path, 'utf8').replace(/\r?\n$/, '');
77
266
  }
78
267
 
268
+ // quox#1675: ENQRYPT is not on npm yet. The CLI looks in QUOX_ENQRYPT_PATH, then its own install location
269
+ // ~/.quox/enqrypt-src (a clone of quoxai/enqrypt main with its dependencies), then the package name. A directory
270
+ // counts only if it holds src/seal.mjs, and what loads must export every function the CLI calls (an old checkout
271
+ // is refused, not half used). A developer's working checkout is never picked up implicitly.
272
+ export const ENQRYPT_PACKAGE = '@quoxai/enqrypt-script';
273
+ export const ENQRYPT_NEEDS = ['createKeystore', 'unlockKeystore', 'readPublicIdentity', 'fingerprint', 'seal', 'open', 'parseEnvelope', 'toAsciiArmor', 'fromAsciiArmor'];
274
+ export const enqryptInstallDir = (home = homedir()) => join(home, '.quox', 'enqrypt-src');
275
+ export function enqryptSources({ env = process.env, home = homedir() } = {}) {
276
+ const dirs = [env.QUOX_ENQRYPT_PATH, enqryptInstallDir(home)].filter((d, i) => d && (i === 0 || existsSync(join(d, 'src', 'seal.mjs'))));
277
+ return [...dirs, ENQRYPT_PACKAGE];
278
+ }
279
+
280
+ export async function loadEnqryptFrom(src, importer = (u) => import(u)) {
281
+ const at = (sub) => (src === ENQRYPT_PACKAGE ? `${src}/${sub}` : pathToFileURL(join(src, 'src', `${sub}.mjs`)).href);
282
+ const [seal, keystore, armor] = await Promise.all([importer(at('seal')), importer(at('keystore')), importer(at('armor'))]);
283
+ const E = { ...seal, ...keystore, ...armor };
284
+ const missing = ENQRYPT_NEEDS.filter((f) => typeof E[f] !== 'function');
285
+ if (missing.length) throw new Error(`${src} is too old for quox notes seal (missing ${missing.join(', ')})`);
286
+ return E;
287
+ }
288
+
79
289
  async function defaultLoadEnqrypt() {
80
- const base = process.env.QUOX_ENQRYPT_PATH;
81
- const at = (sub) => (base ? pathToFileURL(join(base, 'src', `${sub}.mjs`)).href : `@quoxai/enqrypt-script/${sub}`);
82
- try {
83
- const [seal, keystore, armor] = await Promise.all([import(at('seal')), import(at('keystore')), import(at('armor'))]);
84
- return { ...seal, ...keystore, ...armor };
85
- } catch {
86
- throw new QuoxError('ENQRYPT is not available on this machine. It is not published yet: set QUOX_ENQRYPT_PATH to a checkout of quoxai/enqrypt (never `npm install enqrypt`, that name is an unrelated malicious package).', 1);
290
+ const reasons = [];
291
+ for (const src of enqryptSources()) {
292
+ try { return await loadEnqryptFrom(src); } catch (e) {
293
+ if (src !== ENQRYPT_PACKAGE) reasons.push(/too old/.test(e.message) ? e.message : `${src} did not load`);
294
+ }
87
295
  }
296
+ const tried = reasons.length ? ` Tried: ${reasons.join('; ')}.` : '';
297
+ throw new QuoxError(`ENQRYPT is not available on this machine. It is not on npm yet: clone quoxai/enqrypt into ${enqryptInstallDir()} and run \`npm ci\` there, or set QUOX_ENQRYPT_PATH to a checkout.${tried} Never \`npm install enqrypt\`: that name is an unrelated malicious package.`, 1);
88
298
  }
89
299
 
90
300
  async function askPassphrase(message, confirm = false) {
@@ -98,6 +308,47 @@ async function askPassphrase(message, confirm = false) {
98
308
  return p;
99
309
  }
100
310
 
311
+ // Properties a sealed note keeps visible (organisation only). Every other property of a converted note moves
312
+ // inside the seal, because a property can hold a secret as easily as the body can.
313
+ const VISIBLE_PROPS = ['collection', 'tags', 'pinned', 'archived'];
314
+ export const NEUTRAL_TITLE = 'Sealed note';
315
+
316
+ // RT-05: sealed means the seal mark, or a well-formed envelope block (same rule as quoxbrain lib/sealedNote.js), not
317
+ // a note that merely mentions the fence or carries some other enqrypt_* property.
318
+ const ENVELOPE_BLOCK_RE = new RegExp('```' + FENCE + '[ \\t]*\\r?\\n([\\s\\S]*?)\\r?\\n[ \\t]*```', 'g');
319
+ export function isAlreadySealed(note) {
320
+ const p = (note && note.properties) || {};
321
+ if (p.enqrypt === SEAL_MARK) return true;
322
+ const text = String((note && (note.body ?? note.markdown)) || '');
323
+ return [...text.matchAll(ENVELOPE_BLOCK_RE)].some((m) => { const b = m[1].replace(/\s+/g, ''); return b.length >= 16 && B64U_RE.test(b); });
324
+ }
325
+
326
+ // RT-17: files attached with `quox notes attach` live in the org files store, linked from the body. Sealing the body
327
+ // moves the link inside the seal but leaves the FILE readable, so convert names them and refuses unless told to
328
+ // go ahead. Same pattern as ATTACH_REF in notes.js (a test pins the two equal).
329
+ export const NOTE_ATTACH_REF = /!?\[([^\]]*)\]\((\/api\/orgs\/[^/)\s]+\/files\/([^/?#)\s]+))\)/g;
330
+ export function noteAttachments(text) {
331
+ const seen = new Map();
332
+ for (const m of String(text || '').matchAll(NOTE_ATTACH_REF)) {
333
+ const fileId = decodeURIComponent(m[3]);
334
+ if (!seen.has(fileId)) seen.set(fileId, { name: m[1] || fileId, file_id: fileId });
335
+ }
336
+ return [...seen.values()];
337
+ }
338
+ const listAttachments = (a) => a.map((x) => `${x.name} (file ${x.file_id})`).join(', ');
339
+
340
+ /** Plain lines for what the server's seal purge removed, and what it cannot reach. */
341
+ export function purgeReport(id, r) {
342
+ const p = (r && r.purged) || {};
343
+ const n = (v) => (Number.isFinite(v) ? v : 0);
344
+ const lines = [
345
+ `Purged from the server for note ${id}: the text of ${n(p.revisions)} earlier revisions, ${n(p.embeddings)} embeddings (${n(p.ann_points_queued)} vector index points queued for deletion), ${n(p.beliefs_retracted)} derived belief(s) retracted. The search index now holds only the sealed envelope.`,
346
+ ];
347
+ if (p.belief_retract_error) lines.push(`Some derived beliefs could not be retracted yet (${p.belief_retract_error}); the retraction is queued and retried by the server.`);
348
+ lines.push((r && r.backups) || 'Backups taken before this purge still hold the old plaintext until they expire.');
349
+ return lines;
350
+ }
351
+
101
352
  export function registerNotesSeal(notes, ctx) {
102
353
  const { program, client, auth, call, getNote, putNote, readContent, isJson, printJson, announce } = ctx;
103
354
  const loadEnqrypt = ctx.loadEnqrypt || defaultLoadEnqrypt;
@@ -175,6 +426,7 @@ export function registerNotesSeal(notes, ctx) {
175
426
  const body = { scope: options.scope, markdown: sealedNoteMarkdown({ title: options.title, envelope, fingerprint }), properties: sealProperties({ fingerprint, objectId, version: 1, collection: options.collection, tags: options.tag }) };
176
427
  const data = await call(() => client.post(ep.path, { service: ep.service, body }));
177
428
  bumpFloor(keystoreDir(options.keystore), objectId, 1);
429
+ if (data && data.note && data.note.id) recordBinding(keystoreDir(options.keystore), String(data.note.id), objectId);
178
430
  if (isJson(program)) return printJson({ id: data.note?.id, object: objectId, version: 1, fingerprint });
179
431
  console.log(`Sealed note ${data.note?.id} created (${text.length} characters sealed; the title "${options.title}" is visible).`);
180
432
  announce('seal new', `sealed note ${data.note?.id}`);
@@ -224,6 +476,8 @@ export function registerNotesSeal(notes, ctx) {
224
476
  let plain;
225
477
  try { plain = await E.open({ recipient: me, senderPublicKey: me.publicKey, senderFingerprint: fingerprint, envelope, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId, minVersion: readFloor(dir, objectId) }); }
226
478
  catch (e) { throw mapErr(e); }
479
+ if (!options.armorFile) checkNoteBinding(dir, String(id), objectId);
480
+ else if (isBoundObjectId(objectId) && label !== 'armour' && !objectBindsNote(objectId, label)) throw new QuoxError(`Refusing that armour block: its sealed object ${objectId} is not bound to note ${label}. Nothing was opened.`, 1);
227
481
  const v = E.parseEnvelope(envelope).seq;
228
482
  bumpFloor(dir, objectId, v);
229
483
  const text = new TextDecoder().decode(plain);
@@ -245,15 +499,119 @@ export function registerNotesSeal(notes, ctx) {
245
499
  const me = await unlock(E, options);
246
500
  const fingerprint = await E.fingerprint(me.publicKey);
247
501
  if (fingerprint !== props.fingerprint) throw new QuoxError('This note was sealed to a different key; refusing to re-seal it.', 1);
248
- const version = props.version + 1;
502
+ // ENQRYPT-NOTES F10: the next version comes from the AUTHENTICATED envelope, never from the server's
503
+ // enqrypt_version property. Opening the current envelope proves its seq and object binding, and refuses it
504
+ // (ROLLBACK) when it is older than this device's floor, so a server cannot bury the owner's newest text.
505
+ const dir = keystoreDir(options.keystore);
506
+ const current = extractEnvelope(cur.markdown ?? cur.body);
507
+ const floor = readFloor(dir, props.objectId);
508
+ try { await E.open({ recipient: me, senderPublicKey: me.publicKey, senderFingerprint: fingerprint, envelope: current, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId: props.objectId, minVersion: floor }); }
509
+ catch (e) { throw mapErr(e); }
510
+ checkNoteBinding(dir, String(id), props.objectId); // RT-15: refuse another note's envelope before writing
511
+ const version = Math.max(E.parseEnvelope(current).seq, floor) + 1;
512
+ // RT-15: an unbound note is re-sealed under a fresh object id bound to this note; the old object's floor is
513
+ // raised too, so its older envelopes stay refused.
514
+ const rebind = !isBoundObjectId(props.objectId);
515
+ const objectId = rebind ? bindObjectId(String(id)) : props.objectId;
249
516
  let envelope;
250
- try { envelope = await E.seal({ sender: me, recipientPublicKey: me.publicKey, recipientFingerprint: fingerprint, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId: props.objectId, seq: version, plaintext: new TextEncoder().encode(text) }); }
517
+ try { envelope = await E.seal({ sender: me, recipientPublicKey: me.publicKey, recipientFingerprint: fingerprint, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId, seq: version, plaintext: new TextEncoder().encode(text) }); }
251
518
  catch (e) { throw mapErr(e); }
252
519
  const title = String(cur.title || 'Sealed note');
253
- const data = await putNote(id, { markdown: sealedNoteMarkdown({ title, envelope, fingerprint }), properties: { ...cur.properties, enqrypt_version: version }, base_hash: cur.content_hash });
254
- bumpFloor(keystoreDir(options.keystore), props.objectId, version);
255
- if (isJson(program)) return printJson({ id, version, revision: data.revision?.rev });
520
+ const data = await putNote(id, { markdown: sealedNoteMarkdown({ title, envelope, fingerprint }), properties: { ...cur.properties, enqrypt_object: objectId, enqrypt_version: version }, base_hash: cur.content_hash });
521
+ bumpFloor(dir, props.objectId, version);
522
+ if (rebind) { bumpFloor(dir, objectId, version); recordBinding(dir, String(id), objectId); }
523
+ if (isJson(program)) return printJson({ id, version, revision: data.revision?.rev, ...(rebind ? { bound: true, object: objectId } : {}) });
256
524
  console.log(`Sealed note ${id} updated to sealed version ${version}. Earlier revisions hold earlier envelopes, never plaintext.`);
525
+ if (rebind) console.log(`It is now bound to its note id (sealed object ${objectId}), so a server cannot pass another note's envelope off as this one.`);
257
526
  announce('seal edit', `sealed note ${id} v${version}`);
258
527
  });
528
+
529
+ // RT-04: the purge is bound to the exact sealed body this device checked (expected_hash), so a note that changes
530
+ // between the check and the purge is refused (409) instead of purged.
531
+ const runPurge = async (id, expectedHash) => {
532
+ const ep = resolveEndpoint('notesq.notes.sealPurge', { id });
533
+ try { return await call(() => client.post(ep.path, { service: ep.service, body: expectedHash ? { expected_hash: expectedHash } : {} })); } catch (e) {
534
+ if (e instanceof QuoxApiError && e.status === 403) throw new QuoxError(`Purge refused (${e.code}): only the author, the owner or an org admin can purge a sealed note, and only from a human session.`, 1);
535
+ if (e instanceof QuoxApiError && e.status === 409) throw new QuoxError(`Purge refused (${e.code}): the note is not sealed (or changed); nothing was purged.`, 1);
536
+ throw e;
537
+ }
538
+ };
539
+
540
+ common(seal.command('convert').description('Seal an EXISTING note on this device, then purge its earlier plaintext from the server (human only, irreversible, requires --yes)'))
541
+ .argument('<id>', 'note id')
542
+ .option('--keep-title', 'keep the current title visible (by default it is replaced with a neutral one)')
543
+ .option('--attachments-stay-plaintext', 'seal the text even though the note links attached files, which are NOT sealed and stay readable in the org files store')
544
+ .option('--yes', 'confirm: the note is sealed and its earlier plaintext revisions, embeddings and derived beliefs are purged')
545
+ .action(async (id, options) => {
546
+ auth.requireAuth();
547
+ if (!options.yes) throw new QuoxError(`Refusing to seal ${id} without --yes: sealing an existing note purges its earlier plaintext from the server (revisions, embeddings, derived beliefs) and cannot be undone.`, 2);
548
+ const cur = await getNote(id);
549
+ if (isAlreadySealed(cur)) throw new QuoxError(`Note ${id} is already sealed. Use \`quox notes seal edit ${id}\` to change it.`, 1);
550
+ const text = String(cur.body ?? cur.markdown ?? '');
551
+ const attachments = noteAttachments(`${text}\n${cur.markdown ?? ''}`);
552
+ if (attachments.length && !options.attachmentsStayPlaintext) {
553
+ throw new QuoxError(`Refusing to seal ${id}: it links ${attachments.length} attached file(s) that seal convert cannot seal: ${listAttachments(attachments)}. `
554
+ + 'Sealing would hide the links inside the seal while the files stay readable in the org files store. Nothing was changed. '
555
+ + `Download and delete them first (\`quox notes download <fileId>\`), or pass --attachments-stay-plaintext to seal only the text.`, 1);
556
+ }
557
+ const E = await loadEnqrypt();
558
+ const me = await unlock(E, options);
559
+ const fingerprint = await E.fingerprint(me.publicKey);
560
+ const objectId = bindObjectId(String(id)); // RT-15: the note id is known, so the sealed object is bound to it
561
+ let envelope;
562
+ try { envelope = await E.seal({ sender: me, recipientPublicKey: me.publicKey, recipientFingerprint: fingerprint, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId, seq: 1, plaintext: new TextEncoder().encode(text) }); }
563
+ catch (e) { throw mapErr(e); }
564
+ const oldTitle = String(cur.title || '');
565
+ const title = options.keepTitle && oldTitle ? oldTitle : NEUTRAL_TITLE;
566
+ const props = cur.properties || {};
567
+ const visible = Object.fromEntries(VISIBLE_PROPS.filter((k) => props[k] !== undefined && props[k] !== null).map((k) => [k, props[k]]));
568
+ const movedInside = Object.keys(props).filter((k) => !VISIBLE_PROPS.includes(k) && k !== 'title');
569
+ const properties = { ...sealProperties({ fingerprint, objectId, version: 1 }), ...visible };
570
+ const sealedPut = await putNote(id, { markdown: sealedNoteMarkdown({ title, envelope, fingerprint }), properties, base_hash: cur.content_hash });
571
+ bumpFloor(keystoreDir(options.keystore), objectId, 1);
572
+ recordBinding(keystoreDir(options.keystore), String(id), objectId);
573
+ let purged;
574
+ try { purged = await runPurge(id, sealedPut && sealedPut.note && sealedPut.note.content_hash); } catch (e) {
575
+ const why = (e && e.message) || 'unknown error';
576
+ throw new QuoxError(`Note ${id} is sealed now, but the purge was refused (${why}): its earlier revisions still hold the plaintext on the server. Finish from a human session: quox notes seal purge ${id} --yes`, 1);
577
+ }
578
+ if (isJson(program)) return printJson({ id, object: objectId, version: 1, fingerprint, title, kept_title: title === oldTitle, moved_inside_seal: movedInside, attachments_not_sealed: attachments, ...purged });
579
+ console.log(attachments.length
580
+ ? `Sealed note ${id} (${text.length} characters sealed on this device; the server now stores only the envelope for the note's text).`
581
+ : `Sealed note ${id} (${text.length} characters sealed on this device; the server now stores only the envelope).`);
582
+ if (attachments.length) console.log(`NOT sealed: ${attachments.length} attached file(s) stay readable in the org files store: ${listAttachments(attachments)}. Their links are now inside the seal.`);
583
+ console.log(title === oldTitle && options.keepTitle
584
+ ? `The title "${oldTitle}" stays visible: titles are NOT sealed.`
585
+ : `The title "${oldTitle}" was replaced with "${NEUTRAL_TITLE}" (titles are not sealed; use --keep-title to keep one visible).`);
586
+ if (movedInside.length) console.log(`These properties are now inside the seal, not visible: ${movedInside.join(', ')}. Still visible: ${Object.keys(visible).join(', ') || 'none'}.`);
587
+ for (const line of purgeReport(id, purged)) console.log(line);
588
+ announce('seal convert', `sealed note ${id}, history purged`);
589
+ });
590
+
591
+ common(seal.command('purge').description('Purge a sealed note\'s earlier plaintext from the server: revisions, embeddings, derived beliefs (human only, irreversible, requires --yes; first opens the note with your key)'))
592
+ .argument('<id>', 'note id')
593
+ .option('--yes', 'confirm the purge')
594
+ .action(async (id, options) => {
595
+ auth.requireAuth();
596
+ if (!options.yes) throw new QuoxError(`Refusing to purge ${id} without --yes: the earlier text of a sealed note is removed from the server and cannot be recovered.`, 2);
597
+ // RT-04: anyone who could write the note could dress plaintext up as sealed, and a purge would then erase its
598
+ // real history. So the purge runs only after THIS device opens the live envelope with the owner's key.
599
+ const cur = await getNote(id);
600
+ const props = readSealProperties(cur.properties);
601
+ const envelope = extractEnvelope(cur.markdown ?? cur.body);
602
+ const E = await loadEnqrypt();
603
+ const me = await unlock(E, options);
604
+ const fingerprint = await E.fingerprint(me.publicKey);
605
+ if (fingerprint !== props.fingerprint) throw new QuoxError(`Purge refused: note ${id} is not sealed to this device's key (${fingerprint.slice(0, 16)}…). Nothing was purged.`, 1);
606
+ try { await E.open({ recipient: me, senderPublicKey: me.publicKey, senderFingerprint: fingerprint, envelope, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId: props.objectId, minVersion: readFloor(keystoreDir(options.keystore), props.objectId) }); }
607
+ catch (e) {
608
+ const why = e && e.code ? e.code : (e && e.message) || 'open failed';
609
+ throw new QuoxError(`Purge refused: the envelope in note ${id} does not open with your key (${why}), so it may not be a genuine sealed note and its earlier revisions could be the only copy of the real text. Nothing was purged.`, 1);
610
+ }
611
+ checkNoteBinding(keystoreDir(options.keystore), String(id), props.objectId); // RT-15: another note's envelope is refused
612
+ const purged = await runPurge(id, cur.content_hash);
613
+ if (isJson(program)) return printJson({ id, ...purged });
614
+ for (const line of purgeReport(id, purged)) console.log(line);
615
+ announce('seal purge', `sealed note ${id} history purged`);
616
+ });
259
617
  }
@@ -2,7 +2,8 @@
2
2
  * `quox offload` : run a heavy command on a pool worker VM and behave like running it locally.
3
3
  *
4
4
  * quox offload [--dir <path>] [--fresh] [--keep] [--cores N --memory-gb N --disk-gb N] -- <command...>
5
- * quox offload status
5
+ * quox offload status every configured worker's lease (EXPIRED / EXPIRING / OK) + the selected worker's dirs
6
+ * quox offload leases [--json] the same lease list, machine-readable for a timer
6
7
  * quox offload release
7
8
  *
8
9
  * Engine: lib/core/offload.js. The worker is leased through the human pool REST API
@@ -11,7 +12,10 @@
11
12
 
12
13
  import { printInfo } from '../core/output.js';
13
14
  import { makePoolRest } from '../core/vm.js';
14
- import { OffloadError, realDeps, runOffload, releaseWorker, workerStatus, resolveSyncDir, WORKER_NAME_RE } from '../core/offload.js';
15
+ import {
16
+ OffloadError, realDeps, runOffload, releaseWorker, workerStatus, resolveSyncDir, WORKER_NAME_RE,
17
+ allLeases, leaseWarnings, describeLease, LEASE_WARN_H, listWorkerNames,
18
+ } from '../core/offload.js';
15
19
 
16
20
  export function registerOffloadCommands(program, ctx, { deps: injected } = {}) {
17
21
  const { client, auth } = ctx;
@@ -26,6 +30,19 @@ export function registerOffloadCommands(program, ctx, { deps: injected } = {}) {
26
30
  }
27
31
  return name ? Object.assign(Object.create(Object.getPrototypeOf(d)), d, { worker: String(name) }) : d;
28
32
  };
33
+ // Leasing needs a human profile, so an agent can only warn: one stderr line per lease ending within LEASE_WARN_H.
34
+ const warn = (report) => { for (const l of leaseWarnings(report)) process.stderr.write(`offload: ${l}\n`); };
35
+ const leaseLines = (report) => {
36
+ const width = Math.max(7, ...report.leases.map((l) => l.worker.length));
37
+ const lines = [`offload workers (warn when a lease ends within ${LEASE_WARN_H}h; source: ${report.source})`];
38
+ for (const l of report.leases) {
39
+ const head = ` ${l.worker.padEnd(width)} `;
40
+ if (l.lease === 'NONE') { lines.push(`${head}no worker leased`); continue; }
41
+ lines.push(`${head}vm ${l.vmid} ${l.ip || '?'} pool ${l.pool_state} lease ${l.lease_expires_at || '?'} ${describeLease(l)}`);
42
+ }
43
+ if (report.pool_error) lines.push(` (pool status failed, expiry from the local state: ${report.pool_error})`);
44
+ return lines;
45
+ };
29
46
  const WORKER_HELP = 'use your own named worker (its own lease and size) instead of the shared one; or set QUOX_OFFLOAD_WORKER';
30
47
 
31
48
  const off = program
@@ -46,6 +63,7 @@ export function registerOffloadCommands(program, ctx, { deps: injected } = {}) {
46
63
  const cwd = process.cwd();
47
64
  try {
48
65
  const dir = resolveSyncDir(cwd, options.dir);
66
+ try { warn(await allLeases(base(), { live: false })); } catch { /* the warning never blocks a run */ }
49
67
  const code = await runOffload(mk(options), {
50
68
  dir, cwd, command, fresh: !!options.fresh, docker: !!options.docker,
51
69
  cores: options.cores, memoryGb: options.memoryGb, diskGb: options.diskGb,
@@ -59,16 +77,32 @@ export function registerOffloadCommands(program, ctx, { deps: injected } = {}) {
59
77
  });
60
78
 
61
79
  off.command('status')
62
- .description('Show your offload worker: vmid, ip, lease expiry, synced dirs')
80
+ .description('Show every offload worker lease (EXPIRED / EXPIRING / OK) and your worker: vmid, ip, synced dirs')
63
81
  .option('--worker <name>', WORKER_HELP)
64
82
  .action(async (options) => {
65
83
  auth.requireAuth();
66
- const s = await workerStatus(mk(options));
67
- if (json()) { console.log(JSON.stringify(s, null, 2)); return; }
68
- if (!s.worker) { printInfo('no offload worker leased (it is leased on the next `quox offload -- <command>`)'); return; }
69
- const w = s.worker;
70
- console.log(`worker ${w.vmid} ${w.ip || '?'} ${w.state} lessee ${w.lessee} lease expires ${w.lease_expires_at || '?'}`);
71
- for (const d of s.dirs) console.log(` synced ${d.local} -> ${d.remote} ${d.synced_at || ''}`);
84
+ // commander hands `status --worker x` to the parent offload command's --worker, so read both.
85
+ const s = await workerStatus(mk({ worker: options.worker ?? off.opts().worker }));
86
+ const report = await allLeases(base());
87
+ warn(report);
88
+ if (json()) { console.log(JSON.stringify({ ...s, leases: report.leases }, null, 2)); return; }
89
+ if (!s.worker) printInfo('this worker has no lease (a human profile leases it on the next `quox offload -- <command>`)');
90
+ else {
91
+ const w = s.worker;
92
+ console.log(`worker ${w.vmid} ${w.ip || '?'} ${w.state} lessee ${w.lessee} lease expires ${w.lease_expires_at || '?'}`);
93
+ for (const d of s.dirs) console.log(` synced ${d.local} -> ${d.remote} ${d.synced_at || ''}`);
94
+ }
95
+ for (const line of leaseLines(report)) console.log(line);
96
+ });
97
+
98
+ off.command('leases')
99
+ .description(`List every configured offload worker lease with EXPIRED / EXPIRING (within ${LEASE_WARN_H}h) / OK and the human renew command (read-only)`)
100
+ .action(async () => {
101
+ auth.requireAuth();
102
+ const report = await allLeases(base());
103
+ warn(report);
104
+ if (json()) { console.log(JSON.stringify(report, null, 2)); return; }
105
+ for (const line of leaseLines(report)) console.log(line);
72
106
  });
73
107
 
74
108
  off.command('release')
@@ -77,7 +111,14 @@ export function registerOffloadCommands(program, ctx, { deps: injected } = {}) {
77
111
  .action(async (options) => {
78
112
  auth.requireAuth();
79
113
  try {
80
- const vmid = await releaseWorker(mk(options));
114
+ // commander hands `release --worker x` to the parent offload command; reading only the subcommand's
115
+ // options released (destroyed) the SHARED default worker instead of x.
116
+ const d = mk({ worker: options.worker ?? off.opts().worker });
117
+ if (d.worker && !listWorkerNames(d).includes(d.worker)) {
118
+ const known = listWorkerNames(d).map((n) => n || 'default').join(', ') || 'none';
119
+ throw new OffloadError(`no offload worker named ${JSON.stringify(d.worker)} (configured: ${known}); nothing released`);
120
+ }
121
+ const vmid = await releaseWorker(d);
81
122
  if (json()) console.log(JSON.stringify({ released: vmid }));
82
123
  else printInfo(vmid ? `released worker ${vmid}` : 'no offload worker to release');
83
124
  } catch (e) {