opensocrates 1.2.0 → 1.3.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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { spawnSync } from "node:child_process";
3
+ import { spawn, spawnSync } from "node:child_process";
4
4
  import { createHash, randomInt, randomUUID } from "node:crypto";
5
5
  import {
6
6
  constants as fsConstants,
@@ -13,6 +13,7 @@ import {
13
13
  import {
14
14
  access,
15
15
  chmod,
16
+ chown,
16
17
  cp,
17
18
  lstat,
18
19
  mkdir,
@@ -20,8 +21,10 @@ import {
20
21
  open,
21
22
  readFile,
22
23
  readdir,
24
+ realpath,
23
25
  rename,
24
26
  rm,
27
+ rmdir,
25
28
  stat,
26
29
  writeFile,
27
30
  } from "node:fs/promises";
@@ -31,7 +34,7 @@ import { Readable } from "node:stream";
31
34
  import { pipeline } from "node:stream/promises";
32
35
  import { fileURLToPath } from "node:url";
33
36
 
34
- export const PRODUCT_VERSION = "1.2.0";
37
+ export const PRODUCT_VERSION = "1.3.0";
35
38
  export const REPOSITORY = "ParkerHwang/OpenSocrates";
36
39
  export const MARKETPLACE_NAME = "opensocrates";
37
40
  export const PLUGIN_NAME = "opensocrates";
@@ -149,6 +152,1057 @@ const AUTO_UPDATE_MAX_INTERVAL_HOURS = 24 * 7;
149
152
  const AUTO_UPDATE_DEFAULT_INTERVAL_HOURS = 24;
150
153
  const AUTO_UPDATE_POLL_SECONDS = 60 * 60;
151
154
  const LOCK_STALE_MILLISECONDS = 2 * 60 * 60 * 1000;
155
+ export const PURGE_RESULT_SCHEMA = "opensocrates.purge-result/1.0.0";
156
+ export const CODEX_TRUST_EVENTS = Object.freeze([
157
+ "pre_tool_use",
158
+ "post_tool_use",
159
+ "pre_compact",
160
+ "session_start",
161
+ "session_end",
162
+ "user_prompt_submit",
163
+ "stop",
164
+ ]);
165
+ const CODEX_TRUST_KEY_PREFIX = `${PLUGIN_ID}:hooks/hooks.json:`;
166
+ const MAX_CODEX_CONFIG_BYTES = 16 * 1024 * 1024;
167
+ const CODEX_APP_SERVER_ARGS = Object.freeze(["app-server", "--stdio"]);
168
+ const CODEX_APP_SERVER_TIMEOUT_MILLISECONDS = 15_000;
169
+ const CODEX_APP_SERVER_TERMINATION_MILLISECONDS = 1_000;
170
+ const MAX_CODEX_APP_SERVER_LINE_BYTES = 256 * 1024;
171
+ const MAX_CODEX_APP_SERVER_OUTPUT_BYTES = 1024 * 1024;
172
+ const MAX_CODEX_APP_SERVER_NOTIFICATIONS = 128;
173
+
174
+ // Keep host security state separate from installer-owned payload cleanup so an
175
+ // explicit trust reset can fail without weakening purge path-ownership checks.
176
+ export function purgeExtensionResult(host, resetTrust = false) {
177
+ return host === "codex"
178
+ ? {
179
+ component: "host-security-trust",
180
+ status: resetTrust ? "pending" : "preserved",
181
+ nextAction: resetTrust ? null : "rerun-purge-with-reset-trust",
182
+ }
183
+ : {
184
+ component: "host-security-trust",
185
+ status: "not-applicable",
186
+ nextAction: null,
187
+ };
188
+ }
189
+
190
+ export function createPurgeResult(hosts, { resetTrust = false } = {}) {
191
+ return {
192
+ schema: PURGE_RESULT_SCHEMA,
193
+ status: "pending",
194
+ hosts: [...hosts].sort().map((host) => ({
195
+ host,
196
+ status: "pending",
197
+ registration: "not-checked",
198
+ registrationDetail: null,
199
+ components: [],
200
+ extension: purgeExtensionResult(host, resetTrust),
201
+ errors: [],
202
+ })),
203
+ finalization: {
204
+ status: "pending",
205
+ components: [],
206
+ errors: [],
207
+ },
208
+ };
209
+ }
210
+
211
+ function trustResetFailure(message) {
212
+ fail(`Codex OpenSocrates hook trust was not reset: ${message}`);
213
+ }
214
+
215
+ function decodeTomlBasicString(source, start) {
216
+ let value = "";
217
+ let index = start + 1;
218
+ while (index < source.length) {
219
+ const character = source[index];
220
+ if (character === '"') {
221
+ return { value, next: index + 1, kind: "basic", raw: source.slice(start, index + 1) };
222
+ }
223
+ if (character === "\\") {
224
+ const escaped = source[index + 1];
225
+ const simple = new Map([
226
+ ['"', '"'],
227
+ ["\\", "\\"],
228
+ ["b", "\b"],
229
+ ["t", "\t"],
230
+ ["n", "\n"],
231
+ ["f", "\f"],
232
+ ["r", "\r"],
233
+ ]);
234
+ if (simple.has(escaped)) {
235
+ value += simple.get(escaped);
236
+ index += 2;
237
+ continue;
238
+ }
239
+ const digits = escaped === "u" ? 4 : escaped === "U" ? 8 : 0;
240
+ if (digits > 0) {
241
+ const encoded = source.slice(index + 2, index + 2 + digits);
242
+ if (!new RegExp(`^[0-9A-Fa-f]{${digits}}$`, "u").test(encoded)) return null;
243
+ const codePoint = Number.parseInt(encoded, 16);
244
+ if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return null;
245
+ value += String.fromCodePoint(codePoint);
246
+ index += 2 + digits;
247
+ continue;
248
+ }
249
+ return null;
250
+ }
251
+ if (character.codePointAt(0) < 0x20 || character.codePointAt(0) === 0x7f) return null;
252
+ value += character;
253
+ index += 1;
254
+ }
255
+ return null;
256
+ }
257
+
258
+ function decodeTomlLiteralString(source, start) {
259
+ const end = source.indexOf("'", start + 1);
260
+ if (end < 0) return null;
261
+ const value = source.slice(start + 1, end);
262
+ if ([...value].some((character) => character.codePointAt(0) < 0x20 || character.codePointAt(0) === 0x7f)) {
263
+ return null;
264
+ }
265
+ return { value, next: end + 1, kind: "literal", raw: source.slice(start, end + 1) };
266
+ }
267
+
268
+ function parseTomlDottedKey(source) {
269
+ const segments = [];
270
+ let index = 0;
271
+ const skipWhitespace = () => {
272
+ while (source[index] === " " || source[index] === "\t") index += 1;
273
+ };
274
+ skipWhitespace();
275
+ while (index < source.length) {
276
+ let segment;
277
+ if (source[index] === '"') {
278
+ segment = decodeTomlBasicString(source, index);
279
+ } else if (source[index] === "'") {
280
+ segment = decodeTomlLiteralString(source, index);
281
+ } else {
282
+ const match = source.slice(index).match(/^[A-Za-z0-9_-]+/u);
283
+ if (!match) return null;
284
+ segment = {
285
+ value: match[0],
286
+ next: index + match[0].length,
287
+ kind: "bare",
288
+ raw: match[0],
289
+ };
290
+ }
291
+ if (segment === null) return null;
292
+ segments.push(segment);
293
+ index = segment.next;
294
+ skipWhitespace();
295
+ if (index === source.length) return segments;
296
+ if (source[index] !== ".") return null;
297
+ index += 1;
298
+ skipWhitespace();
299
+ if (index === source.length) return null;
300
+ }
301
+ return null;
302
+ }
303
+
304
+ function scanTomlLine(line, carriedContext) {
305
+ let state = carriedContext.stringState;
306
+ let arrayDepth = carriedContext.arrayDepth;
307
+ let inlineTableDepth = carriedContext.inlineTableDepth;
308
+ let commentIndex = -1;
309
+ for (let index = 0; index < line.length; index += 1) {
310
+ if (state === "multiline-basic") {
311
+ if (line[index] === "\\") {
312
+ index += 1;
313
+ } else if (line.startsWith('"""', index)) {
314
+ state = null;
315
+ index += 2;
316
+ }
317
+ continue;
318
+ }
319
+ if (state === "multiline-literal") {
320
+ if (line.startsWith("'''", index)) {
321
+ state = null;
322
+ index += 2;
323
+ }
324
+ continue;
325
+ }
326
+ if (state === "basic") {
327
+ if (line[index] === "\\") index += 1;
328
+ else if (line[index] === '"') state = null;
329
+ continue;
330
+ }
331
+ if (state === "literal") {
332
+ if (line[index] === "'") state = null;
333
+ continue;
334
+ }
335
+ if (line[index] === "#") {
336
+ commentIndex = index;
337
+ break;
338
+ }
339
+ if (line.startsWith('"""', index)) {
340
+ state = "multiline-basic";
341
+ index += 2;
342
+ } else if (line.startsWith("'''", index)) {
343
+ state = "multiline-literal";
344
+ index += 2;
345
+ } else if (line[index] === '"') {
346
+ state = "basic";
347
+ } else if (line[index] === "'") {
348
+ state = "literal";
349
+ } else if (line[index] === "[") {
350
+ arrayDepth += 1;
351
+ } else if (line[index] === "]") {
352
+ arrayDepth -= 1;
353
+ if (arrayDepth < 0) return { invalid: true, commentIndex, context: carriedContext };
354
+ } else if (line[index] === "{") {
355
+ inlineTableDepth += 1;
356
+ } else if (line[index] === "}") {
357
+ inlineTableDepth -= 1;
358
+ if (inlineTableDepth < 0) return { invalid: true, commentIndex, context: carriedContext };
359
+ }
360
+ }
361
+ if (state === "basic" || state === "literal") {
362
+ return { invalid: true, commentIndex, context: carriedContext };
363
+ }
364
+ return {
365
+ invalid: false,
366
+ commentIndex,
367
+ context: { stringState: state, arrayDepth, inlineTableDepth },
368
+ };
369
+ }
370
+
371
+ function tomlLines(source) {
372
+ const lines = [];
373
+ let start = 0;
374
+ while (start < source.length) {
375
+ const newline = source.indexOf("\n", start);
376
+ const end = newline < 0 ? source.length : newline + 1;
377
+ const contentEnd = newline < 0 ? source.length : newline > start && source[newline - 1] === "\r" ? newline - 1 : newline;
378
+ lines.push({ start, end, contentEnd, body: source.slice(start, contentEnd) });
379
+ start = end;
380
+ }
381
+ return lines;
382
+ }
383
+
384
+ function parseTomlTableHeader(code) {
385
+ const leading = code.match(/^[\t ]*/u)?.[0].length ?? 0;
386
+ const trimmedEnd = code.trimEnd().length;
387
+ const candidate = code.slice(leading, trimmedEnd);
388
+ const array = candidate.startsWith("[[");
389
+ if (!candidate.startsWith("[") || (array ? !candidate.endsWith("]]") : !candidate.endsWith("]"))) {
390
+ return null;
391
+ }
392
+ const openWidth = array ? 2 : 1;
393
+ const closeWidth = array ? 2 : 1;
394
+ const keySource = candidate.slice(openWidth, candidate.length - closeWidth);
395
+ const segments = parseTomlDottedKey(keySource);
396
+ if (segments === null) return null;
397
+ return {
398
+ array,
399
+ raw: candidate,
400
+ segments,
401
+ syntaxStart: leading,
402
+ syntaxEnd: leading + candidate.length,
403
+ };
404
+ }
405
+
406
+ function matchingTrustKey(segments) {
407
+ if (
408
+ segments.length < 3 ||
409
+ segments[0].value !== "hooks" ||
410
+ segments[1].value !== "state" ||
411
+ !segments[2].value.startsWith(CODEX_TRUST_KEY_PREFIX)
412
+ ) {
413
+ return null;
414
+ }
415
+ return segments[2].value;
416
+ }
417
+
418
+ function isCanonicalTrustHeader(header, key) {
419
+ return !header.array && header.raw === `[hooks.state.${JSON.stringify(key)}]`;
420
+ }
421
+
422
+ function assignmentKeySegments(code) {
423
+ let state = null;
424
+ for (let index = 0; index < code.length; index += 1) {
425
+ if (state === "basic") {
426
+ if (code[index] === "\\") index += 1;
427
+ else if (code[index] === '"') state = null;
428
+ continue;
429
+ }
430
+ if (state === "literal") {
431
+ if (code[index] === "'") state = null;
432
+ continue;
433
+ }
434
+ if (code[index] === '"') state = "basic";
435
+ else if (code[index] === "'") state = "literal";
436
+ else if (code[index] === "=") return parseTomlDottedKey(code.slice(0, index));
437
+ }
438
+ return null;
439
+ }
440
+
441
+ function lineContainsTrustNamespaceString(code) {
442
+ for (let index = 0; index < code.length; index += 1) {
443
+ let decoded = null;
444
+ if (code[index] === '"') decoded = decodeTomlBasicString(code, index);
445
+ else if (code[index] === "'") decoded = decodeTomlLiteralString(code, index);
446
+ if (decoded === null) continue;
447
+ if (decoded.value.startsWith(CODEX_TRUST_KEY_PREFIX)) return true;
448
+ index = decoded.next - 1;
449
+ }
450
+ return false;
451
+ }
452
+
453
+ function pathContainsTrustNamespace(segments) {
454
+ return matchingTrustKey(segments) !== null;
455
+ }
456
+
457
+ function trustEventFromKey(key) {
458
+ const suffix = key.slice(CODEX_TRUST_KEY_PREFIX.length);
459
+ const match = suffix.match(/^([a-z_]+):(\d+):(\d+)$/u);
460
+ if (!match || !CODEX_TRUST_EVENTS.includes(match[1]) || match[2] !== "0" || match[3] !== "0") {
461
+ trustResetFailure("the matching trust namespace has a noncanonical event or index");
462
+ }
463
+ return match[1];
464
+ }
465
+
466
+ function addRemovalSpan(spans, line, start, end) {
467
+ if (start >= end) trustResetFailure("the matching trust section has an unsupported shape");
468
+ spans.push({ start: line.start + start, end: line.start + end });
469
+ }
470
+
471
+ /**
472
+ * Remove only the syntax bytes owned by canonical OpenSocrates hook trust
473
+ * sections. Comments, whitespace, newline style, unrelated keys, and the
474
+ * parent hooks.state table are retained byte-for-byte.
475
+ */
476
+ export function stripCodexOpenSocratesTrustSections(contents) {
477
+ const original = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
478
+ const utf8Bom = original.subarray(0, 3).equals(Buffer.from([0xef, 0xbb, 0xbf]));
479
+ const encodedSource = utf8Bom ? original.subarray(3) : original;
480
+ let source;
481
+ try {
482
+ source = new TextDecoder("utf-8", { fatal: true }).decode(encodedSource);
483
+ } catch {
484
+ trustResetFailure("the configuration is not valid UTF-8");
485
+ }
486
+ const spans = [];
487
+ const found = new Map();
488
+ let currentTarget = null;
489
+ let currentTableSegments = [];
490
+ let context = { stringState: null, arrayDepth: 0, inlineTableDepth: 0 };
491
+ for (const line of tomlLines(source)) {
492
+ const startContext = { ...context };
493
+ const scanned = scanTomlLine(line.body, context);
494
+ if (scanned.invalid) trustResetFailure("the configuration has an unsupported string shape");
495
+ context = scanned.context;
496
+ const code = scanned.commentIndex < 0 ? line.body : line.body.slice(0, scanned.commentIndex);
497
+ const firstNonWhitespace = code.search(/[^\t ]/u);
498
+ let header = null;
499
+ if (
500
+ startContext.stringState === null &&
501
+ startContext.arrayDepth === 0 &&
502
+ startContext.inlineTableDepth === 0 &&
503
+ firstNonWhitespace >= 0 &&
504
+ code[firstNonWhitespace] === "["
505
+ ) {
506
+ header = parseTomlTableHeader(code);
507
+ if (header === null) trustResetFailure("the configuration has an unsupported table header");
508
+ }
509
+ if (header !== null) {
510
+ if (currentTarget !== null && !currentTarget.hashSeen) {
511
+ trustResetFailure("a matching trust section is missing its canonical trusted_hash key");
512
+ }
513
+ currentTarget = null;
514
+ currentTableSegments = header.segments;
515
+ const key = matchingTrustKey(header.segments);
516
+ if (key !== null) {
517
+ if (!isCanonicalTrustHeader(header, key)) {
518
+ trustResetFailure("the matching trust namespace uses a noncanonical table shape");
519
+ }
520
+ const event = trustEventFromKey(key);
521
+ if (found.has(event)) trustResetFailure("a matching trust event appears more than once");
522
+ currentTarget = { event, hashSeen: false };
523
+ found.set(event, currentTarget);
524
+ addRemovalSpan(spans, line, header.syntaxStart, header.syntaxEnd);
525
+ }
526
+ continue;
527
+ }
528
+ if (currentTarget === null) {
529
+ if (code.trim().length === 0 || startContext.stringState !== null) continue;
530
+ const assignment = assignmentKeySegments(code);
531
+ const semanticPath = assignment === null ? [] : [...currentTableSegments, ...assignment];
532
+ if (
533
+ pathContainsTrustNamespace(semanticPath) ||
534
+ ((semanticPath[0]?.value === "hooks" ||
535
+ (currentTableSegments[0]?.value === "hooks" && currentTableSegments[1]?.value === "state")) &&
536
+ lineContainsTrustNamespaceString(code))
537
+ ) {
538
+ trustResetFailure("the matching trust namespace uses a noncanonical assignment shape");
539
+ }
540
+ continue;
541
+ }
542
+ if (code.trim().length === 0) continue;
543
+ if (
544
+ startContext.stringState !== null ||
545
+ context.stringState !== null ||
546
+ startContext.arrayDepth !== 0 ||
547
+ startContext.inlineTableDepth !== 0 ||
548
+ context.arrayDepth !== 0 ||
549
+ context.inlineTableDepth !== 0
550
+ ) {
551
+ trustResetFailure("a matching trust section contains a multiline value");
552
+ }
553
+ const assignment = code.match(/^(\s*)trusted_hash\s*=\s*"(?:[^"\\\r\n]|\\.)*"(\s*)$/u);
554
+ if (assignment === null || currentTarget.hashSeen) {
555
+ trustResetFailure("a matching trust section contains an unexpected or duplicate key");
556
+ }
557
+ currentTarget.hashSeen = true;
558
+ const syntaxStart = assignment[1].length;
559
+ const syntaxEnd = code.length - assignment[2].length;
560
+ addRemovalSpan(spans, line, syntaxStart, syntaxEnd);
561
+ }
562
+ if (context.stringState !== null || context.arrayDepth !== 0 || context.inlineTableDepth !== 0) {
563
+ trustResetFailure("the configuration has an unterminated multiline value");
564
+ }
565
+ if (currentTarget !== null && !currentTarget.hashSeen) {
566
+ trustResetFailure("a matching trust section is missing its canonical trusted_hash key");
567
+ }
568
+ spans.sort((left, right) => left.start - right.start);
569
+ let cursor = 0;
570
+ let updated = "";
571
+ for (const span of spans) {
572
+ if (span.start < cursor) trustResetFailure("matching trust syntax overlaps unexpectedly");
573
+ updated += source.slice(cursor, span.start);
574
+ cursor = span.end;
575
+ }
576
+ updated += source.slice(cursor);
577
+ const updatedBytes = Buffer.from(updated, "utf8");
578
+ return {
579
+ contents: utf8Bom ? Buffer.concat([original.subarray(0, 3), updatedBytes]) : updatedBytes,
580
+ removedEvents: CODEX_TRUST_EVENTS.filter((event) => found.has(event)),
581
+ };
582
+ }
583
+
584
+ function currentUid() {
585
+ if (typeof process.getuid !== "function") {
586
+ trustResetFailure("file ownership cannot be verified on this platform");
587
+ }
588
+ return process.getuid();
589
+ }
590
+
591
+ function assertOwnedDirectory(info) {
592
+ if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== currentUid() || (info.mode & 0o022) !== 0) {
593
+ trustResetFailure("the Codex configuration directory is not a safe owner-controlled directory");
594
+ }
595
+ }
596
+
597
+ function assertOwnedConfigFile(info) {
598
+ if (!info.isFile() || info.isSymbolicLink() || info.uid !== currentUid() || info.nlink !== 1) {
599
+ trustResetFailure("the Codex configuration is not a safe owner-controlled regular file");
600
+ }
601
+ if (info.size > MAX_CODEX_CONFIG_BYTES) {
602
+ trustResetFailure("the Codex configuration is too large for a bounded trust reset");
603
+ }
604
+ }
605
+
606
+ function sameSnapshot(left, right) {
607
+ return (
608
+ left.dev === right.dev &&
609
+ left.ino === right.ino &&
610
+ left.size === right.size &&
611
+ left.mtimeNs === right.mtimeNs &&
612
+ left.ctimeNs === right.ctimeNs &&
613
+ left.mode === right.mode &&
614
+ left.uid === right.uid &&
615
+ left.gid === right.gid &&
616
+ left.nlink === right.nlink
617
+ );
618
+ }
619
+
620
+ async function readConfigSnapshot(target) {
621
+ const noFollow = fsConstants.O_NOFOLLOW ?? 0;
622
+ let handle;
623
+ let operationFailed = false;
624
+ try {
625
+ handle = await open(target, fsConstants.O_RDONLY | noFollow);
626
+ const before = await handle.stat({ bigint: true });
627
+ if (
628
+ !before.isFile() ||
629
+ before.isSymbolicLink() ||
630
+ before.uid !== BigInt(currentUid()) ||
631
+ before.nlink !== 1n
632
+ ) {
633
+ trustResetFailure("the Codex configuration changed to an unsafe file");
634
+ }
635
+ if (before.size > BigInt(MAX_CODEX_CONFIG_BYTES)) {
636
+ trustResetFailure("the Codex configuration is too large for a bounded trust reset");
637
+ }
638
+ const contents = await handle.readFile();
639
+ const after = await handle.stat({ bigint: true });
640
+ if (!sameSnapshot(before, after) || BigInt(contents.length) !== after.size) {
641
+ trustResetFailure("the Codex configuration changed while it was being inspected");
642
+ }
643
+ return { info: after, contents };
644
+ } catch (error) {
645
+ operationFailed = true;
646
+ if (error instanceof InstallerError) throw error;
647
+ trustResetFailure("the Codex configuration could not be inspected safely");
648
+ } finally {
649
+ try {
650
+ await handle?.close();
651
+ } catch {
652
+ if (!operationFailed) {
653
+ trustResetFailure("the Codex configuration handle could not be closed safely");
654
+ }
655
+ }
656
+ }
657
+ }
658
+
659
+ async function writeSyncedOwnerOnlyFile(target, contents) {
660
+ let handle;
661
+ let operationFailed = false;
662
+ try {
663
+ handle = await open(target, "wx", 0o600);
664
+ await handle.writeFile(contents);
665
+ await handle.chmod(0o600);
666
+ await handle.sync();
667
+ } catch (error) {
668
+ operationFailed = true;
669
+ if (error instanceof InstallerError) throw error;
670
+ trustResetFailure("an owner-only Codex transaction file could not be written safely");
671
+ } finally {
672
+ try {
673
+ await handle?.close();
674
+ } catch {
675
+ if (!operationFailed) {
676
+ trustResetFailure("an owner-only Codex transaction file could not be closed safely");
677
+ }
678
+ }
679
+ }
680
+ }
681
+
682
+ async function fsyncFile(target) {
683
+ let handle;
684
+ let operationFailed = false;
685
+ try {
686
+ handle = await open(target, "r");
687
+ await handle.sync();
688
+ } catch (error) {
689
+ operationFailed = true;
690
+ if (error instanceof InstallerError) throw error;
691
+ trustResetFailure("the Codex configuration could not be synchronized safely");
692
+ } finally {
693
+ try {
694
+ await handle?.close();
695
+ } catch {
696
+ if (!operationFailed) {
697
+ trustResetFailure("the Codex configuration handle could not be closed safely");
698
+ }
699
+ }
700
+ }
701
+ }
702
+
703
+ async function fsyncDirectory(target) {
704
+ let handle;
705
+ let operationFailed = false;
706
+ try {
707
+ handle = await open(target, "r");
708
+ await handle.sync();
709
+ } catch (error) {
710
+ operationFailed = true;
711
+ if (error instanceof InstallerError) throw error;
712
+ trustResetFailure("the Codex configuration directory could not be synchronized safely");
713
+ } finally {
714
+ try {
715
+ await handle?.close();
716
+ } catch {
717
+ if (!operationFailed) {
718
+ trustResetFailure("the Codex configuration directory handle could not be closed safely");
719
+ }
720
+ }
721
+ }
722
+ }
723
+
724
+ function exactCodexInitializeResponse(message, requestId, codexHome) {
725
+ if (message === null || typeof message !== "object" || Array.isArray(message)) return false;
726
+ const responseKeys = Object.keys(message).sort();
727
+ if (responseKeys.length !== 2 || responseKeys[0] !== "id" || responseKeys[1] !== "result") {
728
+ return false;
729
+ }
730
+ if (message.id !== requestId) return false;
731
+ const result = message.result;
732
+ if (result === null || typeof result !== "object" || Array.isArray(result)) return false;
733
+ const resultKeys = Object.keys(result).sort();
734
+ if (
735
+ resultKeys.length !== 4 ||
736
+ resultKeys[0] !== "codexHome" ||
737
+ resultKeys[1] !== "platformFamily" ||
738
+ resultKeys[2] !== "platformOs" ||
739
+ resultKeys[3] !== "userAgent"
740
+ ) {
741
+ return false;
742
+ }
743
+ return (
744
+ result.codexHome === codexHome &&
745
+ typeof result.platformFamily === "string" &&
746
+ result.platformFamily.length > 0 &&
747
+ typeof result.platformOs === "string" &&
748
+ result.platformOs.length > 0 &&
749
+ typeof result.userAgent === "string" &&
750
+ result.userAgent.length > 0
751
+ );
752
+ }
753
+
754
+ function exactCodexAppServerNotification(message) {
755
+ if (message === null || typeof message !== "object" || Array.isArray(message)) return false;
756
+ const keys = Object.keys(message).sort();
757
+ const withoutTimestamp = keys.length === 2 && keys[0] === "method" && keys[1] === "params";
758
+ const withTimestamp =
759
+ keys.length === 3 &&
760
+ keys[0] === "emittedAtMs" &&
761
+ keys[1] === "method" &&
762
+ keys[2] === "params";
763
+ if (!withoutTimestamp && !withTimestamp) return false;
764
+ if (typeof message.method !== "string" || message.method.length === 0) return false;
765
+ if (message.params === null || typeof message.params !== "object" || Array.isArray(message.params)) {
766
+ return false;
767
+ }
768
+ return !withTimestamp || (typeof message.emittedAtMs === "number" && Number.isFinite(message.emittedAtMs));
769
+ }
770
+
771
+ function codexAppServerValidationError() {
772
+ return new InstallerError(
773
+ "Codex OpenSocrates hook trust was not reset: the installed Codex app server did not accept the isolated configuration",
774
+ );
775
+ }
776
+
777
+ function isolatedCodexValidationEnvironment(codexHome) {
778
+ const environment = {
779
+ PATH: process.env.PATH ?? "",
780
+ HOME: codexHome,
781
+ CODEX_HOME: codexHome,
782
+ XDG_CACHE_HOME: join(codexHome, "xdg-cache"),
783
+ XDG_CONFIG_HOME: join(codexHome, "xdg-config"),
784
+ XDG_DATA_HOME: join(codexHome, "xdg-data"),
785
+ XDG_STATE_HOME: join(codexHome, "xdg-state"),
786
+ TMPDIR: join(codexHome, "tmp"),
787
+ NO_COLOR: "1",
788
+ };
789
+ if (process.platform === "win32") {
790
+ for (const key of ["ComSpec", "PATHEXT", "SystemRoot", "WINDIR"]) {
791
+ if (typeof process.env[key] === "string") environment[key] = process.env[key];
792
+ }
793
+ }
794
+ return environment;
795
+ }
796
+
797
+ async function runCodexAppServerConfigCheck(
798
+ codexBin,
799
+ codexHome,
800
+ {
801
+ spawnAppServer = spawn,
802
+ timeoutMilliseconds = CODEX_APP_SERVER_TIMEOUT_MILLISECONDS,
803
+ terminationMilliseconds = CODEX_APP_SERVER_TERMINATION_MILLISECONDS,
804
+ } = {},
805
+ ) {
806
+ const requestId = 1;
807
+ const request = Buffer.from(
808
+ `${JSON.stringify({
809
+ id: requestId,
810
+ method: "initialize",
811
+ params: {
812
+ clientInfo: {
813
+ name: "opensocrates_installer",
814
+ title: "OpenSocrates Installer",
815
+ version: PRODUCT_VERSION,
816
+ },
817
+ capabilities: { experimentalApi: false },
818
+ },
819
+ })}\n`,
820
+ "utf8",
821
+ );
822
+ let child;
823
+ try {
824
+ child = spawnAppServer(codexBin, CODEX_APP_SERVER_ARGS, {
825
+ cwd: codexHome,
826
+ env: isolatedCodexValidationEnvironment(codexHome),
827
+ stdio: ["pipe", "pipe", "pipe"],
828
+ windowsHide: true,
829
+ });
830
+ } catch {
831
+ throw codexAppServerValidationError();
832
+ }
833
+
834
+ await new Promise((resolvePromise, rejectPromise) => {
835
+ const timeoutDelay =
836
+ Number.isFinite(timeoutMilliseconds) && timeoutMilliseconds > 0
837
+ ? timeoutMilliseconds
838
+ : CODEX_APP_SERVER_TIMEOUT_MILLISECONDS;
839
+ const terminationDelay =
840
+ Number.isFinite(terminationMilliseconds) && terminationMilliseconds > 0
841
+ ? terminationMilliseconds
842
+ : CODEX_APP_SERVER_TERMINATION_MILLISECONDS;
843
+ let closed = false;
844
+ let failed = false;
845
+ let settled = false;
846
+ let responseCount = 0;
847
+ let notificationCount = 0;
848
+ let responseAccepted = false;
849
+ let requestWriteCompleted = false;
850
+ let outputBytes = 0;
851
+ let lineBuffer = Buffer.alloc(0);
852
+ let inputEndRequested = false;
853
+ let terminationTimer;
854
+ let forcedFinishTimer;
855
+
856
+ const clearTimers = () => {
857
+ clearTimeout(deadlineTimer);
858
+ if (terminationTimer !== undefined) clearTimeout(terminationTimer);
859
+ if (forcedFinishTimer !== undefined) clearTimeout(forcedFinishTimer);
860
+ };
861
+ const finish = (accepted) => {
862
+ if (settled) return;
863
+ settled = true;
864
+ clearTimers();
865
+ if (accepted) resolvePromise();
866
+ else rejectPromise(codexAppServerValidationError());
867
+ };
868
+ const endInput = () => {
869
+ if (inputEndRequested) return true;
870
+ inputEndRequested = true;
871
+ try {
872
+ child.stdin.end();
873
+ return true;
874
+ } catch {
875
+ return false;
876
+ }
877
+ };
878
+ const terminate = () => {
879
+ if (failed || settled) return;
880
+ failed = true;
881
+ // The stream may already be closed; process termination below remains bounded.
882
+ endInput();
883
+ if (closed) {
884
+ finish(false);
885
+ return;
886
+ }
887
+ try {
888
+ child.kill("SIGTERM");
889
+ } catch {
890
+ // A failed signal is followed by the bounded SIGKILL/final failure timers.
891
+ }
892
+ terminationTimer = setTimeout(() => {
893
+ if (closed || settled) return;
894
+ try {
895
+ child.kill("SIGKILL");
896
+ } catch {
897
+ // The final timer still prevents an unbounded validator wait.
898
+ }
899
+ forcedFinishTimer = setTimeout(() => finish(false), terminationDelay);
900
+ }, terminationDelay);
901
+ };
902
+ const consumeLine = (line) => {
903
+ if (failed || settled) return;
904
+ if (line.length > MAX_CODEX_APP_SERVER_LINE_BYTES || line.length === 0) {
905
+ terminate();
906
+ return;
907
+ }
908
+ const normalized = line.at(-1) === 0x0d ? line.subarray(0, -1) : line;
909
+ let message;
910
+ try {
911
+ message = JSON.parse(normalized.toString("utf8"));
912
+ } catch {
913
+ terminate();
914
+ return;
915
+ }
916
+ if (exactCodexInitializeResponse(message, requestId, codexHome)) {
917
+ responseCount += 1;
918
+ if (responseCount !== 1) {
919
+ terminate();
920
+ return;
921
+ }
922
+ responseAccepted = true;
923
+ if (!endInput()) terminate();
924
+ return;
925
+ }
926
+ if (!exactCodexAppServerNotification(message)) {
927
+ terminate();
928
+ return;
929
+ }
930
+ notificationCount += 1;
931
+ if (notificationCount > MAX_CODEX_APP_SERVER_NOTIFICATIONS) terminate();
932
+ };
933
+ const deadlineTimer = setTimeout(terminate, timeoutDelay);
934
+
935
+ child.stdout.on("data", (chunk) => {
936
+ if (failed || settled) return;
937
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
938
+ outputBytes += bytes.length;
939
+ if (outputBytes > MAX_CODEX_APP_SERVER_OUTPUT_BYTES) {
940
+ terminate();
941
+ return;
942
+ }
943
+ lineBuffer = Buffer.concat([lineBuffer, bytes]);
944
+ for (;;) {
945
+ const newline = lineBuffer.indexOf(0x0a);
946
+ if (newline < 0) break;
947
+ const line = lineBuffer.subarray(0, newline);
948
+ lineBuffer = lineBuffer.subarray(newline + 1);
949
+ consumeLine(line);
950
+ if (failed || settled) return;
951
+ }
952
+ if (lineBuffer.length > MAX_CODEX_APP_SERVER_LINE_BYTES) terminate();
953
+ });
954
+ child.stderr.on("data", (chunk) => {
955
+ if (failed || settled) return;
956
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
957
+ outputBytes += bytes.length;
958
+ if (outputBytes > MAX_CODEX_APP_SERVER_OUTPUT_BYTES) terminate();
959
+ });
960
+ child.once("error", terminate);
961
+ child.stdin.once("error", terminate);
962
+ child.once("close", (code, signal) => {
963
+ closed = true;
964
+ finish(
965
+ !failed &&
966
+ code === 0 &&
967
+ signal === null &&
968
+ responseCount === 1 &&
969
+ responseAccepted &&
970
+ requestWriteCompleted &&
971
+ lineBuffer.length === 0,
972
+ );
973
+ });
974
+
975
+ try {
976
+ const accepted = child.stdin.write(request, (error) => {
977
+ if (error !== undefined && error !== null) {
978
+ terminate();
979
+ return;
980
+ }
981
+ requestWriteCompleted = true;
982
+ });
983
+ if (!accepted) {
984
+ // This is the only bounded write. A false return means it is queued;
985
+ // the response/deadline path bounds a child that never drains it.
986
+ child.stdin.once("drain", () => {});
987
+ }
988
+ } catch {
989
+ terminate();
990
+ }
991
+ });
992
+ }
993
+
994
+ async function validateCodexConfigBytes(
995
+ codexBin,
996
+ contents,
997
+ {
998
+ removeValidationHome = rm,
999
+ spawnAppServer = spawn,
1000
+ validationTimeoutMilliseconds = CODEX_APP_SERVER_TIMEOUT_MILLISECONDS,
1001
+ validationTerminationMilliseconds = CODEX_APP_SERVER_TERMINATION_MILLISECONDS,
1002
+ } = {},
1003
+ ) {
1004
+ let validationHome;
1005
+ let operationFailed = false;
1006
+ try {
1007
+ validationHome = await mkdtemp(join(tmpdir(), "opensocrates-codex-config-check-"));
1008
+ await chmod(validationHome, 0o700);
1009
+ // macOS commonly exposes the temporary directory through /var while
1010
+ // Codex reports its canonical /private/var spelling. Use one canonical
1011
+ // identity for the environment, cwd, and initialize-response comparison.
1012
+ validationHome = await realpath(validationHome);
1013
+ for (const directory of ["tmp", "xdg-cache", "xdg-config", "xdg-data", "xdg-state"]) {
1014
+ await mkdir(join(validationHome, directory), { mode: 0o700 });
1015
+ }
1016
+ const target = join(validationHome, "config.toml");
1017
+ await writeSyncedOwnerOnlyFile(target, contents);
1018
+ await runCodexAppServerConfigCheck(codexBin, validationHome, {
1019
+ spawnAppServer,
1020
+ timeoutMilliseconds: validationTimeoutMilliseconds,
1021
+ terminationMilliseconds: validationTerminationMilliseconds,
1022
+ });
1023
+ } catch (error) {
1024
+ operationFailed = true;
1025
+ if (error instanceof InstallerError) throw error;
1026
+ trustResetFailure("the Codex configuration could not be validated in isolation");
1027
+ } finally {
1028
+ if (validationHome !== undefined) {
1029
+ try {
1030
+ await removeValidationHome(validationHome, { recursive: true, force: true });
1031
+ } catch {
1032
+ if (!operationFailed) {
1033
+ trustResetFailure("the isolated Codex validation environment could not be removed safely");
1034
+ }
1035
+ }
1036
+ }
1037
+ }
1038
+ }
1039
+
1040
+ async function restoreTrustConfig({
1041
+ rollback,
1042
+ target,
1043
+ parent,
1044
+ original,
1045
+ originalMode,
1046
+ originalUid,
1047
+ originalGid,
1048
+ }) {
1049
+ try {
1050
+ if (!(await entryExists(rollback))) trustResetFailure("the rollback copy is unavailable");
1051
+ await rename(rollback, target);
1052
+ await chown(target, originalUid, originalGid);
1053
+ await chmod(target, originalMode);
1054
+ await fsyncFile(target);
1055
+ await fsyncDirectory(parent);
1056
+ const restored = await readConfigSnapshot(target);
1057
+ if (
1058
+ !restored.contents.equals(original) ||
1059
+ Number(restored.info.mode & 0o7777n) !== originalMode ||
1060
+ restored.info.uid !== BigInt(originalUid) ||
1061
+ restored.info.gid !== BigInt(originalGid)
1062
+ ) {
1063
+ trustResetFailure("the original configuration was not restored");
1064
+ }
1065
+ } catch (error) {
1066
+ if (error instanceof InstallerError) throw error;
1067
+ trustResetFailure("the original configuration could not be restored automatically");
1068
+ }
1069
+ }
1070
+
1071
+ /**
1072
+ * Reset only OpenSocrates Codex hook trust. Optional hooks are dependency
1073
+ * injection seams for isolated failure/race tests; production callers pass no
1074
+ * hooks and always use the installed Codex CLI for validation.
1075
+ */
1076
+ export async function resetCodexOpenSocratesHookTrust({
1077
+ codexHome = process.env.CODEX_HOME || join(homedir(), ".codex"),
1078
+ codexBin = codexBinary(),
1079
+ hooks = {},
1080
+ } = {}) {
1081
+ const lexicalHome = resolve(codexHome);
1082
+ if (!(await entryExists(lexicalHome))) return { status: "absent", removedEvents: [] };
1083
+ const lexicalInfo = await lstat(lexicalHome);
1084
+ assertOwnedDirectory(lexicalInfo);
1085
+ let parent;
1086
+ try {
1087
+ parent = await realpath(lexicalHome);
1088
+ } catch {
1089
+ trustResetFailure("the Codex configuration directory could not be canonicalized");
1090
+ }
1091
+ const parentInfo = await lstat(parent);
1092
+ assertOwnedDirectory(parentInfo);
1093
+ const target = join(parent, "config.toml");
1094
+ if (!(await entryExists(target))) return { status: "absent", removedEvents: [] };
1095
+ const targetInfo = await lstat(target);
1096
+ assertOwnedConfigFile(targetInfo);
1097
+ const snapshot = await readConfigSnapshot(target);
1098
+ const originalMode = Number(snapshot.info.mode & 0o7777n);
1099
+ const originalUid = Number(snapshot.info.uid);
1100
+ const originalGid = Number(snapshot.info.gid);
1101
+ const validationOptions = {
1102
+ removeValidationHome: hooks.removeValidationHome,
1103
+ spawnAppServer: hooks.spawnAppServer,
1104
+ validationTimeoutMilliseconds: hooks.validationTimeoutMilliseconds,
1105
+ validationTerminationMilliseconds: hooks.validationTerminationMilliseconds,
1106
+ };
1107
+ await validateCodexConfigBytes(codexBin, snapshot.contents, validationOptions);
1108
+ const stripped = stripCodexOpenSocratesTrustSections(snapshot.contents);
1109
+ if (stripped.removedEvents.length === 0) {
1110
+ const current = await readConfigSnapshot(target);
1111
+ if (!sameSnapshot(snapshot.info, current.info) || !current.contents.equals(snapshot.contents)) {
1112
+ trustResetFailure("the Codex configuration changed during isolated validation");
1113
+ }
1114
+ return { status: "absent", removedEvents: [] };
1115
+ }
1116
+ await validateCodexConfigBytes(codexBin, stripped.contents, validationOptions);
1117
+
1118
+ const token = randomUUID();
1119
+ const temporary = join(parent, `.config.toml.opensocrates-trust-reset-${token}.tmp`);
1120
+ const rollback = join(parent, `.config.toml.opensocrates-trust-reset-${token}.rollback`);
1121
+ let replaced = false;
1122
+ let preserveRollback = false;
1123
+ let transactionFailed = false;
1124
+ let transactionCommitted = false;
1125
+ let cleanupRequired = true;
1126
+ try {
1127
+ await hooks.beforeWrite?.({ target });
1128
+ await writeSyncedOwnerOnlyFile(temporary, stripped.contents);
1129
+ await writeSyncedOwnerOnlyFile(rollback, snapshot.contents);
1130
+ await fsyncDirectory(parent);
1131
+ await hooks.beforeRename?.({ target, temporary, rollback });
1132
+ const current = await readConfigSnapshot(target);
1133
+ if (!sameSnapshot(snapshot.info, current.info) || !current.contents.equals(snapshot.contents)) {
1134
+ trustResetFailure("the Codex configuration changed before the atomic update");
1135
+ }
1136
+ await rename(temporary, target);
1137
+ replaced = true;
1138
+ await chown(target, originalUid, originalGid);
1139
+ await chmod(target, originalMode);
1140
+ await fsyncFile(target);
1141
+ await fsyncDirectory(parent);
1142
+ await hooks.afterRename?.({ target, rollback });
1143
+ const committed = await readConfigSnapshot(target);
1144
+ if (
1145
+ !committed.contents.equals(stripped.contents) ||
1146
+ Number(committed.info.mode & 0o7777n) !== originalMode ||
1147
+ committed.info.uid !== BigInt(originalUid) ||
1148
+ committed.info.gid !== BigInt(originalGid)
1149
+ ) {
1150
+ trustResetFailure("the committed Codex configuration did not match the validated candidate");
1151
+ }
1152
+ await validateCodexConfigBytes(codexBin, committed.contents, validationOptions);
1153
+ await (hooks.removeRollback ?? rm)(rollback);
1154
+ // The candidate and parent rename are already fsynced and post-validated.
1155
+ // Keep rollback unlink as the final fallible commit step; another directory
1156
+ // fsync would only create a failure point after the recovery source is gone.
1157
+ transactionCommitted = true;
1158
+ cleanupRequired = false;
1159
+ return { status: "reset", removedEvents: stripped.removedEvents };
1160
+ } catch (error) {
1161
+ transactionFailed = true;
1162
+ if (replaced) {
1163
+ let canRollback = false;
1164
+ try {
1165
+ const current = await readConfigSnapshot(target);
1166
+ canRollback = current.contents.equals(stripped.contents);
1167
+ } catch {
1168
+ canRollback = false;
1169
+ }
1170
+ if (!canRollback) {
1171
+ preserveRollback = true;
1172
+ trustResetFailure(
1173
+ "the Codex configuration changed after replacement; automatic rollback was withheld and the owner-only recovery copy was preserved",
1174
+ );
1175
+ }
1176
+ await restoreTrustConfig({
1177
+ rollback,
1178
+ target,
1179
+ parent,
1180
+ original: snapshot.contents,
1181
+ originalMode,
1182
+ originalUid,
1183
+ originalGid,
1184
+ });
1185
+ }
1186
+ if (error instanceof InstallerError) throw error;
1187
+ trustResetFailure("the atomic configuration update failed; the original configuration was preserved");
1188
+ } finally {
1189
+ let cleanupFailed = false;
1190
+ if (cleanupRequired) {
1191
+ for (const residue of [temporary, rollback]) {
1192
+ try {
1193
+ if (residue === rollback && preserveRollback) continue;
1194
+ await hooks.beforeResidueCleanup?.({ residue });
1195
+ if (await entryExists(residue)) await rm(residue);
1196
+ } catch {
1197
+ cleanupFailed = true;
1198
+ }
1199
+ }
1200
+ }
1201
+ if (cleanupFailed && !transactionFailed && !transactionCommitted) {
1202
+ trustResetFailure("owner-only Codex transaction residue could not be removed safely");
1203
+ }
1204
+ }
1205
+ }
152
1206
 
153
1207
  export class InstallerError extends Error {}
154
1208
 
@@ -169,6 +1223,27 @@ async function exists(target) {
169
1223
  }
170
1224
  }
171
1225
 
1226
+ // Destructive paths must distinguish a missing entry from a dangling symlink.
1227
+ // access() follows links, so it is intentionally insufficient at deletion
1228
+ // boundaries where an unresolvable link must be refused rather than reported
1229
+ // as already absent.
1230
+ async function entryExists(target) {
1231
+ try {
1232
+ await lstat(target);
1233
+ return true;
1234
+ } catch (error) {
1235
+ if (error?.code === "ENOENT") return false;
1236
+ throw error;
1237
+ }
1238
+ }
1239
+
1240
+ async function requireRegularFileEntry(target, label) {
1241
+ const info = await lstat(target);
1242
+ if (!info.isFile() || info.isSymbolicLink()) {
1243
+ fail(`refusing an unsafe ${label}: ${target}`);
1244
+ }
1245
+ }
1246
+
172
1247
  export function statePaths() {
173
1248
  const stateDirectory = resolve(process.env.OPENSOCRATES_STATE_DIR || join(homedir(), ".opensocrates"));
174
1249
  const launchAgentsDirectory = resolve(
@@ -260,9 +1335,10 @@ function normalizeDesiredState(value) {
260
1335
 
261
1336
  export async function readDesiredState() {
262
1337
  const { desiredState } = statePaths();
263
- if (!(await exists(desiredState))) {
1338
+ if (!(await entryExists(desiredState))) {
264
1339
  return defaultDesiredState();
265
1340
  }
1341
+ await requireRegularFileEntry(desiredState, "OpenSocrates desired-state file");
266
1342
  let value;
267
1343
  try {
268
1344
  value = JSON.parse(await readFile(desiredState, "utf8"));
@@ -428,6 +1504,34 @@ function jsonEqual(left, right) {
428
1504
  );
429
1505
  }
430
1506
 
1507
+ function jsonDeepEqual(left, right) {
1508
+ if (left === right) return true;
1509
+ if (Array.isArray(left) || Array.isArray(right)) {
1510
+ return (
1511
+ Array.isArray(left) &&
1512
+ Array.isArray(right) &&
1513
+ left.length === right.length &&
1514
+ left.every((item, index) => jsonDeepEqual(item, right[index]))
1515
+ );
1516
+ }
1517
+ if (
1518
+ left === null ||
1519
+ right === null ||
1520
+ typeof left !== "object" ||
1521
+ typeof right !== "object"
1522
+ ) {
1523
+ return false;
1524
+ }
1525
+ const leftKeys = Object.keys(left).sort();
1526
+ const rightKeys = Object.keys(right).sort();
1527
+ return (
1528
+ leftKeys.length === rightKeys.length &&
1529
+ leftKeys.every(
1530
+ (key, index) => key === rightKeys[index] && jsonDeepEqual(left[key], right[key]),
1531
+ )
1532
+ );
1533
+ }
1534
+
431
1535
  function markerFor(host) {
432
1536
  if (host === "cursor") return CURSOR_MARKER;
433
1537
  if (host === "antigravity") return ANTIGRAVITY_MARKER;
@@ -509,6 +1613,8 @@ export function parseCli(argv) {
509
1613
  intervalHours: AUTO_UPDATE_DEFAULT_INTERVAL_HOURS,
510
1614
  allowMajor: false,
511
1615
  force: false,
1616
+ purge: false,
1617
+ resetTrust: false,
512
1618
  };
513
1619
  const seenOptions = new Set();
514
1620
  while (args.length > 0) {
@@ -536,6 +1642,16 @@ export function parseCli(argv) {
536
1642
  options.force = true;
537
1643
  continue;
538
1644
  }
1645
+ if (flag === "--purge") {
1646
+ seenOptions.add("purge");
1647
+ options.purge = true;
1648
+ continue;
1649
+ }
1650
+ if (flag === "--reset-trust") {
1651
+ seenOptions.add("reset-trust");
1652
+ options.resetTrust = true;
1653
+ continue;
1654
+ }
539
1655
  if (flag === "--channel") {
540
1656
  seenOptions.add("channel");
541
1657
  const channel = args.shift();
@@ -623,6 +1739,15 @@ export function parseCli(argv) {
623
1739
  if (seenOptions.has("force") && !autoUpdateRun) {
624
1740
  fail("--force is only valid with auto-update run");
625
1741
  }
1742
+ if (seenOptions.has("purge") && options.action !== "remove") {
1743
+ fail("--purge is only valid with remove");
1744
+ }
1745
+ if (seenOptions.has("reset-trust") && (options.action !== "remove" || !options.purge)) {
1746
+ fail("--reset-trust requires remove --purge");
1747
+ }
1748
+ if (seenOptions.has("reset-trust") && !new Set([ALL_HOST, "codex"]).has(options.host)) {
1749
+ fail("--reset-trust is only valid with --host codex or --host all");
1750
+ }
626
1751
  if (
627
1752
  seenOptions.has("host") &&
628
1753
  options.action === "auto-update" &&
@@ -640,11 +1765,11 @@ function showHelp() {
640
1765
  console.log(`OpenSocrates ${PRODUCT_VERSION}
641
1766
 
642
1767
  Usage:
643
- opensocrates install [--host all|antigravity|claude|codex|cursor|grok|opencode] [--asset ZIP --checksum SHA256]
1768
+ opensocrates install [--host all|antigravity|claude|codex|cursor|grok|opencode] [--asset ZIP --checksum FILE]
644
1769
  opensocrates status [--host all|antigravity|claude|codex|cursor|grok|opencode]
645
- opensocrates update [--host all|antigravity|claude|codex|cursor|grok|opencode] [--asset ZIP --checksum SHA256]
646
- opensocrates remove [--host all|antigravity|claude|codex|cursor|grok|opencode]
647
- opensocrates verify [--host all|antigravity|claude|codex|cursor|grok|opencode] [--asset ZIP --checksum SHA256]
1770
+ opensocrates update [--host all|antigravity|claude|codex|cursor|grok|opencode] [--asset ZIP --checksum FILE]
1771
+ opensocrates remove [--host all|antigravity|claude|codex|cursor|grok|opencode] [--purge [--reset-trust]]
1772
+ opensocrates verify [--host all|antigravity|claude|codex|cursor|grok|opencode] [--asset ZIP --checksum FILE]
648
1773
  opensocrates auto-update enable [--host all|antigravity|claude|codex|cursor|grok|opencode]
649
1774
  [--channel stable|next] [--interval-hours ${AUTO_UPDATE_DEFAULT_INTERVAL_HOURS}]
650
1775
  [--allow-major]
@@ -656,6 +1781,15 @@ package and checksum from GitHub Releases. The default lifecycle host is codex.
656
1781
  With --host all, supplied host-qualified asset/checksum pairs define the exact
657
1782
  transaction set and are never mixed with downloads for other hosts. Without
658
1783
  qualified assets, every ready host participates. Automatic updates are opt-in.
1784
+
1785
+ Ordinary remove unregisters the selected host and removes installer-managed
1786
+ files, but may leave host-owned caches, installer state, and Codex hook trust.
1787
+ Add --purge for an explicit payload/data cleanup attempt. Purge preserves Codex
1788
+ hook trust unless --reset-trust is also supplied for codex or all; that explicit
1789
+ option removes only the seven canonical OpenSocrates trust entries. Registration,
1790
+ payload cleanup, host security trust, and user history are reported separately.
1791
+ User history is always preserved. Any unverified, unsafe, or in-use component is
1792
+ reported as pending.
659
1793
  `);
660
1794
  }
661
1795
 
@@ -715,6 +1849,28 @@ function managedPaths(host) {
715
1849
  };
716
1850
  }
717
1851
 
1852
+ export function purgePathsFor(host) {
1853
+ if (!SUPPORTED_HOSTS.includes(host)) {
1854
+ fail(`unsupported host ${JSON.stringify(host)}`);
1855
+ }
1856
+ const paths = managedPaths(host);
1857
+ const cacheRoot = ["claude", "codex"].includes(host)
1858
+ ? join(paths.hostHome, "plugins", "cache", MARKETPLACE_NAME, PLUGIN_NAME)
1859
+ : null;
1860
+ return {
1861
+ ...paths,
1862
+ cacheRoot,
1863
+ cacheMarketplaceRoot: cacheRoot === null ? null : dirname(cacheRoot),
1864
+ pluginData:
1865
+ host === "claude"
1866
+ ? [
1867
+ join(paths.hostHome, "plugins", "data", "opensocrates-inline"),
1868
+ join(paths.hostHome, "plugins", "data", "opensocrates-opensocrates"),
1869
+ ]
1870
+ : [],
1871
+ };
1872
+ }
1873
+
718
1874
  // Directory that holds staging trees and rollback backups for one host.
719
1875
  //
720
1876
  // Grok scans every directory below ~/.grok/plugins, including dot-prefixed
@@ -1194,7 +2350,9 @@ async function requireOwnedRoot(root, host) {
1194
2350
  if (!info.isDirectory() || info.isSymbolicLink()) {
1195
2351
  fail(`managed marketplace path is not an owned directory: ${root}`);
1196
2352
  }
1197
- const marker = await readJsonObject(join(root, MARKER_NAME));
2353
+ const markerPath = join(root, MARKER_NAME);
2354
+ await requireRegularFileEntry(markerPath, `${host} ownership marker`);
2355
+ const marker = await readJsonObject(markerPath);
1198
2356
  if (!markerMatches(marker, host)) {
1199
2357
  fail(`managed marketplace path has no valid ownership marker: ${root}`);
1200
2358
  }
@@ -1437,46 +2595,514 @@ async function verifyExtractedPackage(pluginRoot, host) {
1437
2595
  `got ${String(manifest.name)} ${String(manifest.version)}`,
1438
2596
  );
1439
2597
  }
1440
- const release = await readJsonObject(join(pluginRoot, "release-manifest.json"));
2598
+ const release = await readJsonObject(join(pluginRoot, "release-manifest.json"));
2599
+ if (
2600
+ release.product_version !== PRODUCT_VERSION ||
2601
+ release.host !== host ||
2602
+ release.schema !== "opensocrates.plugin-release-manifest/1.0.0"
2603
+ ) {
2604
+ fail("package release manifest does not match this installer");
2605
+ }
2606
+ if (HOST_LAYOUTS[host].requiresRuntime) {
2607
+ const runtime = join(pluginRoot, "runtime", "darwin-arm64", "opensocrates-runtime", "opensocrates-runtime");
2608
+ const runtimeInfo = await stat(runtime);
2609
+ if (!runtimeInfo.isFile() || (runtimeInfo.mode & 0o111) === 0) {
2610
+ fail("package is missing the executable darwin-arm64 runtime");
2611
+ }
2612
+ } else if (
2613
+ release.launchers?.length !== 0 ||
2614
+ release.runtime_targets?.length !== 0 ||
2615
+ (await exists(join(pluginRoot, "runtime"))) ||
2616
+ (await exists(join(pluginRoot, "hooks"))) ||
2617
+ (await exists(join(pluginRoot, "bin"))) ||
2618
+ ((host === "cursor" || host === "grok") && (await exists(join(pluginRoot, "mcp.json")))) ||
2619
+ (host === "grok" &&
2620
+ ((await exists(join(pluginRoot, "commands"))) ||
2621
+ (await exists(join(pluginRoot, "agents"))) ||
2622
+ (await exists(join(pluginRoot, ".mcp.json")))))
2623
+ ) {
2624
+ fail(`${host} package contains an unexpected native runtime or launcher surface`);
2625
+ }
2626
+ if (host === "opencode") {
2627
+ if (
2628
+ manifest.minimum_opencode_version !== "1.18.18" ||
2629
+ manifest.stable_plugin_hook !== "chat.message" ||
2630
+ manifest.beta_v2_api !== false ||
2631
+ !(await exists(join(pluginRoot, "plugins", "opensocrates.js"))) ||
2632
+ !(await exists(join(pluginRoot, "skills", "opensocrates", "SKILL.md")))
2633
+ ) {
2634
+ fail("OpenCode package does not match the verified stable bridge contract");
2635
+ }
2636
+ }
2637
+ return verifyPackageChecksums(pluginRoot);
2638
+ }
2639
+
2640
+ async function requireSafePathBelow(root, target, label, { allowRoot = false } = {}) {
2641
+ const resolvedRoot = resolve(root);
2642
+ const resolvedTarget = resolve(target);
2643
+ if (await entryExists(resolvedRoot)) {
2644
+ const rootInfo = await lstat(resolvedRoot);
2645
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
2646
+ fail(`refusing an unsafe ${label} root: ${root}`);
2647
+ }
2648
+ }
2649
+ const suffix = relative(resolvedRoot, resolvedTarget);
2650
+ if (
2651
+ (!allowRoot && !suffix) ||
2652
+ suffix === ".." ||
2653
+ suffix.startsWith(`..${sep}`) ||
2654
+ resolve(resolvedRoot, suffix) !== resolvedTarget
2655
+ ) {
2656
+ fail(`refusing an unsafe ${label} path: ${target}`);
2657
+ }
2658
+ let current = resolvedRoot;
2659
+ for (const component of suffix.split(sep)) {
2660
+ current = join(current, component);
2661
+ if (!(await entryExists(current))) continue;
2662
+ const info = await lstat(current);
2663
+ if (info.isSymbolicLink()) {
2664
+ fail(`refusing a symbolic-link ${label} path: ${current}`);
2665
+ }
2666
+ }
2667
+ if (await entryExists(resolvedTarget)) {
2668
+ let canonical;
2669
+ let canonicalRoot = resolvedRoot;
2670
+ try {
2671
+ canonical = realpathSync(resolvedTarget);
2672
+ if (await entryExists(resolvedRoot)) canonicalRoot = realpathSync(resolvedRoot);
2673
+ } catch (error) {
2674
+ fail(`cannot canonicalize ${label}: ${error.message}`);
2675
+ }
2676
+ if (canonical !== resolve(canonicalRoot, suffix)) {
2677
+ fail(`refusing a non-canonical ${label} path: ${target}`);
2678
+ }
2679
+ }
2680
+ }
2681
+
2682
+ async function packageIdentityForPurge(pluginRoot, host, { allowedExtra = () => false } = {}) {
2683
+ const rootInfo = await lstat(pluginRoot);
2684
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
2685
+ fail(`OpenSocrates ${host} payload is not a real directory: ${pluginRoot}`);
2686
+ }
2687
+ const releasePath = join(pluginRoot, "release-manifest.json");
2688
+ const manifestPath = join(pluginRoot, HOST_LAYOUTS[host].manifestRelative);
2689
+ const checksumPath = join(pluginRoot, "checksums.sha256");
2690
+ for (const [target, label] of [
2691
+ [releasePath, `${host} release manifest`],
2692
+ [manifestPath, `${host} plugin manifest`],
2693
+ [checksumPath, `${host} checksum inventory`],
2694
+ ]) {
2695
+ await requireRegularFileEntry(target, label);
2696
+ }
2697
+ const release = await readJsonObject(releasePath);
2698
+ const version = release.product_version;
2699
+ if (
2700
+ release.schema !== "opensocrates.plugin-release-manifest/1.0.0" ||
2701
+ release.host !== host ||
2702
+ typeof version !== "string" ||
2703
+ version.trim().length === 0 ||
2704
+ !Number.isInteger(release.content_revision) ||
2705
+ release.content_revision < 1
2706
+ ) {
2707
+ fail(`OpenSocrates ${host} payload has an invalid release identity`);
2708
+ }
2709
+ const manifest = await readJsonObject(manifestPath);
2710
+ if (manifest.name !== PLUGIN_NAME || manifest.version !== version) {
2711
+ fail(`OpenSocrates ${host} payload has a mismatched plugin identity`);
2712
+ }
2713
+
2714
+ const lines = (await readFile(checksumPath, "utf8"))
2715
+ .split(/\r?\n/u)
2716
+ .map((line) => line.trim())
2717
+ .filter(Boolean);
2718
+ if (lines.length === 0) fail(`OpenSocrates ${host} payload has an empty checksum inventory`);
2719
+ const declared = new Set();
2720
+ for (const line of lines) {
2721
+ const match = line.match(/^([a-fA-F0-9]{64})\s+[*]?(.+)$/u);
2722
+ if (!match) fail(`OpenSocrates ${host} payload has an invalid checksum inventory`);
2723
+ const item = match[2].trim();
2724
+ if (!isSafeArchivePath(item) || item === "checksums.sha256" || declared.has(item)) {
2725
+ fail(`OpenSocrates ${host} payload has an unsafe checksum path`);
2726
+ }
2727
+ const target = join(pluginRoot, ...item.split("/"));
2728
+ if (!(await entryExists(target))) fail(`OpenSocrates ${host} payload is missing a declared file`);
2729
+ const info = await lstat(target);
2730
+ if (!info.isFile() || info.isSymbolicLink()) {
2731
+ fail(`OpenSocrates ${host} payload contains an unsafe declared file`);
2732
+ }
2733
+ if ((await sha256File(target)) !== match[1].toLowerCase()) {
2734
+ fail(`OpenSocrates ${host} payload checksum verification failed`);
2735
+ }
2736
+ declared.add(item);
2737
+ }
2738
+ const actual = await walkFiles(pluginRoot);
2739
+ for (const item of actual) {
2740
+ if (item === "checksums.sha256" || declared.has(item) || allowedExtra(item)) continue;
2741
+ fail(`OpenSocrates ${host} payload contains an unowned file: ${item}`);
2742
+ }
2743
+ return { version, files: actual };
2744
+ }
2745
+
2746
+ async function cacheHasLiveUser(versionRoot) {
2747
+ const marker = join(versionRoot, ".in_use");
2748
+ if (!(await entryExists(marker))) return false;
2749
+ const markerInfo = await lstat(marker);
2750
+ if (!markerInfo.isDirectory() || markerInfo.isSymbolicLink()) {
2751
+ fail("OpenSocrates cache has an unsafe .in_use marker");
2752
+ }
2753
+ const entries = await readdir(marker, { withFileTypes: true });
2754
+ for (const entry of entries) {
2755
+ if (entry.isSymbolicLink() || !entry.isFile() || !/^[1-9]\d*$/u.test(entry.name)) {
2756
+ fail("OpenSocrates cache has an unrecognized .in_use entry");
2757
+ }
2758
+ const pid = Number(entry.name);
2759
+ if (!Number.isSafeInteger(pid)) fail("OpenSocrates cache has an invalid .in_use process ID");
2760
+ try {
2761
+ process.kill(pid, 0);
2762
+ return true;
2763
+ } catch (error) {
2764
+ if (error?.code === "EPERM") return true;
2765
+ if (error?.code !== "ESRCH") {
2766
+ fail("OpenSocrates cache use could not be determined safely");
2767
+ }
2768
+ }
2769
+ }
2770
+ return false;
2771
+ }
2772
+
2773
+ async function removeDirectoryIfEmpty(target) {
2774
+ if (!(await entryExists(target))) return false;
2775
+ try {
2776
+ await rmdir(target);
2777
+ return true;
2778
+ } catch (error) {
2779
+ if (error?.code === "ENOTEMPTY" || error?.code === "EEXIST") return false;
2780
+ throw error;
2781
+ }
2782
+ }
2783
+
2784
+ async function purgeHostCache(host, paths) {
2785
+ if (paths.cacheRoot === null) {
2786
+ return { component: "plugin-cache", status: "absent", path: paths.cacheRoot };
2787
+ }
2788
+ if (!(await entryExists(paths.cacheRoot))) {
2789
+ let removedEmptyMarketplace = false;
2790
+ if (await entryExists(paths.cacheMarketplaceRoot)) {
2791
+ await requireSafePathBelow(paths.hostHome, paths.cacheMarketplaceRoot, `${host} cache marketplace`);
2792
+ const info = await lstat(paths.cacheMarketplaceRoot);
2793
+ if (!info.isDirectory() || info.isSymbolicLink()) {
2794
+ fail(`refusing an unsafe ${host} OpenSocrates cache marketplace`);
2795
+ }
2796
+ if ((await readdir(paths.cacheMarketplaceRoot)).length !== 0) {
2797
+ fail(`refusing an unrecognized entry in the ${host} OpenSocrates cache marketplace`);
2798
+ }
2799
+ await rmdir(paths.cacheMarketplaceRoot);
2800
+ removedEmptyMarketplace = true;
2801
+ }
2802
+ return {
2803
+ component: "plugin-cache",
2804
+ status: removedEmptyMarketplace ? "removed" : "absent",
2805
+ path: paths.cacheMarketplaceRoot,
2806
+ };
2807
+ }
2808
+ await requireSafePathBelow(paths.hostHome, paths.cacheRoot, `${host} plugin cache`);
2809
+ const rootInfo = await lstat(paths.cacheRoot);
2810
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
2811
+ fail(`refusing an unsafe ${host} OpenSocrates plugin-cache root`);
2812
+ }
2813
+ const versions = await readdir(paths.cacheRoot, { withFileTypes: true });
2814
+ let removed = 0;
2815
+ let pending = 0;
2816
+ for (const version of versions) {
2817
+ if (!version.isDirectory() || version.isSymbolicLink()) {
2818
+ fail(`refusing an unrecognized entry in the ${host} OpenSocrates plugin cache`);
2819
+ }
2820
+ const versionRoot = join(paths.cacheRoot, version.name);
2821
+ await requireSafePathBelow(paths.cacheRoot, versionRoot, `${host} cached plugin version`);
2822
+ const identity = await packageIdentityForPurge(versionRoot, host, {
2823
+ allowedExtra: (item) => item === ".orphaned_at" || item.startsWith(".in_use/"),
2824
+ });
2825
+ if (identity.version !== version.name) {
2826
+ fail(`refusing a ${host} cache directory whose version does not match its manifests`);
2827
+ }
2828
+ if (await cacheHasLiveUser(versionRoot)) {
2829
+ pending += 1;
2830
+ continue;
2831
+ }
2832
+ await rm(versionRoot, { recursive: true });
2833
+ removed += 1;
2834
+ }
2835
+ if (pending === 0) {
2836
+ const cacheRootRemoved = await removeDirectoryIfEmpty(paths.cacheRoot);
2837
+ if (!cacheRootRemoved && (await entryExists(paths.cacheRoot))) {
2838
+ return {
2839
+ component: "plugin-cache",
2840
+ status: "pending",
2841
+ path: paths.cacheRoot,
2842
+ detail: "cache-changed-during-purge",
2843
+ };
2844
+ }
2845
+ const marketplaceRemoved = await removeDirectoryIfEmpty(paths.cacheMarketplaceRoot);
2846
+ if (!marketplaceRemoved && (await entryExists(paths.cacheMarketplaceRoot))) {
2847
+ return {
2848
+ component: "plugin-cache",
2849
+ status: "pending",
2850
+ path: paths.cacheMarketplaceRoot,
2851
+ detail: "unrecognized-cache-marketplace-content",
2852
+ };
2853
+ }
2854
+ }
2855
+ return {
2856
+ component: "plugin-cache",
2857
+ status: pending > 0 ? "pending" : removed > 0 ? "removed" : "absent",
2858
+ path: paths.cacheRoot,
2859
+ detail: pending > 0 ? "host-in-use" : null,
2860
+ };
2861
+ }
2862
+
2863
+ async function purgeClaudePluginData(paths) {
2864
+ const results = [];
2865
+ for (const target of paths.pluginData) {
2866
+ if (!(await entryExists(target))) {
2867
+ results.push({ component: "plugin-data", status: "absent", path: target });
2868
+ continue;
2869
+ }
2870
+ await requireSafePathBelow(paths.hostHome, target, "Claude OpenSocrates plugin data");
2871
+ const info = await lstat(target);
2872
+ if (!info.isDirectory() || info.isSymbolicLink()) {
2873
+ fail(`refusing an unsafe Claude OpenSocrates plugin-data path: ${target}`);
2874
+ }
2875
+ if ((await readdir(target)).length !== 0) {
2876
+ results.push({
2877
+ component: "plugin-data",
2878
+ status: "pending",
2879
+ path: target,
2880
+ detail: "nonempty-unverified-data",
2881
+ });
2882
+ continue;
2883
+ }
2884
+ await rmdir(target);
2885
+ results.push({ component: "plugin-data", status: "removed", path: target });
2886
+ }
2887
+ return results;
2888
+ }
2889
+
2890
+ async function verifyOpenCodePurgeRoot(root) {
2891
+ await requireOwnedRoot(root, "opencode");
2892
+ const manifestPath = join(root, OPENCODE_INSTALL_MANIFEST);
2893
+ await requireRegularFileEntry(manifestPath, "OpenCode installation inventory");
2894
+ const manifest = await readJsonObject(manifestPath);
2895
+ const manifestKeys = Object.keys(manifest).sort();
2896
+ if (
2897
+ !jsonDeepEqual(manifestKeys, ["bridgeSha256", "files", "schema", "version"]) ||
2898
+ manifest.schema !== "opensocrates.opencode-installation/1.0.0" ||
2899
+ typeof manifest.version !== "string" ||
2900
+ manifest.version.trim().length === 0 ||
2901
+ !/^[a-f0-9]{64}$/u.test(manifest.bridgeSha256) ||
2902
+ manifest.files === null ||
2903
+ typeof manifest.files !== "object" ||
2904
+ Array.isArray(manifest.files)
2905
+ ) {
2906
+ fail("OpenCode installation inventory is invalid");
2907
+ }
2908
+ const declared = Object.keys(manifest.files);
2909
+ const actual = new Set(await walkFiles(root));
2910
+ actual.delete(OPENCODE_INSTALL_MANIFEST);
2911
+ if (
2912
+ actual.size !== declared.length ||
2913
+ declared.some((item) => !isSafeArchivePath(item) || !actual.has(item) || !/^[a-f0-9]{64}$/u.test(manifest.files[item]))
2914
+ ) {
2915
+ fail("OpenCode installation inventory does not cover the complete managed skill");
2916
+ }
2917
+ for (const item of declared) {
2918
+ if ((await sha256File(join(root, ...item.split("/")))) !== manifest.files[item]) {
2919
+ fail(`OpenCode installed file checksum mismatch for ${item}`);
2920
+ }
2921
+ }
2922
+ return manifest;
2923
+ }
2924
+
2925
+ async function verifyManagedTreeForPurge(host, root) {
2926
+ if (host === "opencode") return verifyOpenCodePurgeRoot(root);
2927
+ await requireOwnedRoot(root, host);
2928
+ const layout = HOST_LAYOUTS[host];
2929
+ const pluginRoot = join(root, layout.pluginRelative);
2930
+ const identity = await packageIdentityForPurge(pluginRoot, host, {
2931
+ allowedExtra: (item) => FILE_DROP_HOSTS.includes(host) && item === MARKER_NAME,
2932
+ });
2933
+ if (FILE_DROP_HOSTS.includes(host)) return identity;
2934
+
2935
+ const marketplacePath = join(root, layout.marketplaceRelative);
2936
+ await requireRegularFileEntry(marketplacePath, `${host} marketplace manifest`);
2937
+ const marketplace = await readJsonObject(marketplacePath);
2938
+ const expected = expectedMarketplace(host);
2939
+ if (host === "claude") expected.metadata.version = identity.version;
2940
+ if (!jsonDeepEqual(marketplace, expected)) {
2941
+ fail(`${host} managed marketplace does not match the exact owned plugin inventory`);
2942
+ }
2943
+ const allowed = new Set([
2944
+ MARKER_NAME,
2945
+ layout.marketplaceRelative.split(sep).join("/"),
2946
+ ...identity.files.map((item) => join(layout.pluginRelative, ...item.split("/")).split(sep).join("/")),
2947
+ ]);
2948
+ for (const item of await walkFiles(root)) {
2949
+ if (!allowed.has(item)) fail(`${host} managed marketplace contains an unowned file: ${item}`);
2950
+ }
2951
+ return identity;
2952
+ }
2953
+
2954
+ async function removeOwnedManagedRoot(host, paths) {
2955
+ if (!(await entryExists(paths.root))) {
2956
+ return { component: "managed-root", status: "absent", path: paths.root };
2957
+ }
2958
+ await requireSafePathBelow(paths.hostHome, paths.root, `${host} managed root`);
2959
+ await verifyManagedTreeForPurge(host, paths.root);
2960
+ const backup = join(transientParent(host, paths), `.opensocrates.removed-${randomUUID()}`);
2961
+ await requireSafePathBelow(paths.hostHome, backup, `${host} removal backup`);
2962
+ await rename(paths.root, backup);
2963
+ try {
2964
+ await verifyManagedTreeForPurge(host, backup);
2965
+ await rm(backup, { recursive: true });
2966
+ } catch (error) {
2967
+ if ((await entryExists(backup)) && !(await entryExists(paths.root))) {
2968
+ const restored = await recoveryStep(`restore the ${host} managed root after purge cleanup failed`, async () => {
2969
+ await verifyManagedTreeForPurge(host, backup);
2970
+ await rename(backup, paths.root);
2971
+ });
2972
+ if (!restored) {
2973
+ console.error(`error: recoverable ${host} files remain at: ${backup}`);
2974
+ console.error(`error: retry command: opensocrates remove --host ${host} --purge`);
2975
+ }
2976
+ }
2977
+ throw error;
2978
+ }
2979
+ return { component: "managed-root", status: "removed", path: paths.root };
2980
+ }
2981
+
2982
+ async function cleanupTransientRootResidue(host, paths) {
2983
+ const parent = transientParent(host, paths);
2984
+ if (!(await entryExists(parent))) {
2985
+ return { component: "transaction-residue", status: "absent", path: parent };
2986
+ }
2987
+ await requireSafePathBelow(paths.hostHome, parent, `${host} transient parent`, { allowRoot: true });
2988
+ const names = (await readdir(parent)).filter((name) =>
2989
+ /^\.opensocrates\.(?:staging|backup|removed)-[A-Za-z0-9-]+$/u.test(name),
2990
+ );
2991
+ let removed = 0;
2992
+ for (const name of names) {
2993
+ const target = join(parent, name);
2994
+ await requireSafePathBelow(parent, target, `${host} transaction residue`);
2995
+ const info = await lstat(target);
2996
+ if (!info.isDirectory() || info.isSymbolicLink()) {
2997
+ fail(`refusing unsafe ${host} transaction residue: ${target}`);
2998
+ }
2999
+ if ((await readdir(target)).length !== 0) {
3000
+ try {
3001
+ await verifyManagedTreeForPurge(host, target);
3002
+ } catch (error) {
3003
+ const detail = error instanceof Error ? error.message : String(error);
3004
+ fail(`refusing unverifiable ${host} transaction residue at ${target}: ${detail}`);
3005
+ }
3006
+ }
3007
+ await rm(target, { recursive: true });
3008
+ removed += 1;
3009
+ }
3010
+ return {
3011
+ component: "transaction-residue",
3012
+ status: removed > 0 ? "removed" : "absent",
3013
+ path: parent,
3014
+ };
3015
+ }
3016
+
3017
+ async function readOpenCodeBridgeSidecar(target) {
3018
+ const info = await lstat(target);
3019
+ if (!info.isFile() || info.isSymbolicLink()) {
3020
+ fail(`OpenCode bridge ownership sidecar is unsafe: ${target}`);
3021
+ }
3022
+ const sidecar = await readJsonObject(target);
3023
+ const expectedKeys = [...Object.keys(OPENCODE_MARKER), "version", "bridgeSha256"].sort();
3024
+ const observedKeys = Object.keys(sidecar).sort();
3025
+ const marker = Object.fromEntries(Object.keys(OPENCODE_MARKER).map((key) => [key, sidecar[key]]));
1441
3026
  if (
1442
- release.product_version !== PRODUCT_VERSION ||
1443
- release.host !== host ||
1444
- release.schema !== "opensocrates.plugin-release-manifest/1.0.0"
3027
+ expectedKeys.length !== observedKeys.length ||
3028
+ expectedKeys.some((key, index) => key !== observedKeys[index]) ||
3029
+ !markerMatches(marker, "opencode") ||
3030
+ typeof sidecar.version !== "string" ||
3031
+ sidecar.version.trim().length === 0 ||
3032
+ !/^[a-f0-9]{64}$/u.test(sidecar.bridgeSha256)
1445
3033
  ) {
1446
- fail("package release manifest does not match this installer");
3034
+ fail(`OpenCode bridge ownership sidecar is invalid: ${target}`);
1447
3035
  }
1448
- if (HOST_LAYOUTS[host].requiresRuntime) {
1449
- const runtime = join(pluginRoot, "runtime", "darwin-arm64", "opensocrates-runtime", "opensocrates-runtime");
1450
- const runtimeInfo = await stat(runtime);
1451
- if (!runtimeInfo.isFile() || (runtimeInfo.mode & 0o111) === 0) {
1452
- fail("package is missing the executable darwin-arm64 runtime");
3036
+ return sidecar;
3037
+ }
3038
+
3039
+ async function purgeOpenCodeBridge(paths) {
3040
+ const bridgePresent = await entryExists(paths.bridge);
3041
+ const markerPresent = await entryExists(paths.bridgeMarker);
3042
+ if (!bridgePresent && !markerPresent) {
3043
+ return { component: "opencode-bridge", status: "absent", path: paths.bridge };
3044
+ }
3045
+ await requireSafePathBelow(paths.hostHome, paths.bridge, "OpenCode bridge");
3046
+ await requireSafePathBelow(paths.hostHome, paths.bridgeMarker, "OpenCode bridge sidecar");
3047
+ if (bridgePresent && !markerPresent) {
3048
+ fail("refusing to remove an OpenCode bridge without its ownership sidecar");
3049
+ }
3050
+ const sidecar = await readOpenCodeBridgeSidecar(paths.bridgeMarker);
3051
+ if (bridgePresent) {
3052
+ const bridgeInfo = await lstat(paths.bridge);
3053
+ if (!bridgeInfo.isFile() || bridgeInfo.isSymbolicLink() || (await sha256File(paths.bridge)) !== sidecar.bridgeSha256) {
3054
+ fail("refusing to remove an OpenCode bridge whose ownership checksum does not match");
1453
3055
  }
1454
- } else if (
1455
- release.launchers?.length !== 0 ||
1456
- release.runtime_targets?.length !== 0 ||
1457
- (await exists(join(pluginRoot, "runtime"))) ||
1458
- (await exists(join(pluginRoot, "hooks"))) ||
1459
- (await exists(join(pluginRoot, "bin"))) ||
1460
- ((host === "cursor" || host === "grok") && (await exists(join(pluginRoot, "mcp.json")))) ||
1461
- (host === "grok" &&
1462
- ((await exists(join(pluginRoot, "commands"))) ||
1463
- (await exists(join(pluginRoot, "agents"))) ||
1464
- (await exists(join(pluginRoot, ".mcp.json")))))
1465
- ) {
1466
- fail(`${host} package contains an unexpected native runtime or launcher surface`);
3056
+ await rm(paths.bridge);
1467
3057
  }
1468
- if (host === "opencode") {
1469
- if (
1470
- manifest.minimum_opencode_version !== "1.18.18" ||
1471
- manifest.stable_plugin_hook !== "chat.message" ||
1472
- manifest.beta_v2_api !== false ||
1473
- !(await exists(join(pluginRoot, "plugins", "opensocrates.js"))) ||
1474
- !(await exists(join(pluginRoot, "skills", "opensocrates", "SKILL.md")))
1475
- ) {
1476
- fail("OpenCode package does not match the verified stable bridge contract");
3058
+ await rm(paths.bridgeMarker);
3059
+ return { component: "opencode-bridge", status: "removed", path: paths.bridge };
3060
+ }
3061
+
3062
+ async function purgeOpenCodeBridgeResidue(paths) {
3063
+ if (!(await entryExists(paths.bridgeParent))) {
3064
+ return { component: "opencode-bridge-residue", status: "absent", path: paths.bridgeParent };
3065
+ }
3066
+ await requireSafePathBelow(paths.hostHome, paths.bridgeParent, "OpenCode bridge parent");
3067
+ const entries = await readdir(paths.bridgeParent, { withFileTypes: true });
3068
+ const bridgeNames = entries
3069
+ .filter((entry) => /^\.opensocrates\.js\.(?:staging|backup|removed)-[A-Za-z0-9-]+$/u.test(entry.name))
3070
+ .map((entry) => entry.name);
3071
+ const markerNames = entries
3072
+ .filter((entry) =>
3073
+ /^\.opensocrates-managed\.json\.(?:staging|backup|removed)-[A-Za-z0-9-]+$/u.test(entry.name),
3074
+ )
3075
+ .map((entry) => entry.name);
3076
+ const bridges = new Map();
3077
+ for (const name of bridgeNames) {
3078
+ const target = join(paths.bridgeParent, name);
3079
+ await requireSafePathBelow(paths.bridgeParent, target, "OpenCode bridge residue");
3080
+ const info = await lstat(target);
3081
+ if (!info.isFile() || info.isSymbolicLink()) fail(`unsafe OpenCode bridge residue: ${target}`);
3082
+ bridges.set(target, await sha256File(target));
3083
+ }
3084
+ let removed = 0;
3085
+ for (const name of markerNames) {
3086
+ const marker = join(paths.bridgeParent, name);
3087
+ await requireSafePathBelow(paths.bridgeParent, marker, "OpenCode bridge sidecar residue");
3088
+ const sidecar = await readOpenCodeBridgeSidecar(marker);
3089
+ const matches = [...bridges].filter(([, digest]) => digest === sidecar.bridgeSha256);
3090
+ if (matches.length > 1) fail("ambiguous OpenCode bridge transaction residue");
3091
+ if (matches.length === 1) {
3092
+ const [[bridge]] = matches;
3093
+ await rm(bridge);
3094
+ bridges.delete(bridge);
3095
+ removed += 1;
1477
3096
  }
3097
+ await rm(marker);
3098
+ removed += 1;
1478
3099
  }
1479
- return verifyPackageChecksums(pluginRoot);
3100
+ if (bridges.size > 0) fail("refusing OpenCode bridge residue without an ownership sidecar");
3101
+ return {
3102
+ component: "opencode-bridge-residue",
3103
+ status: removed > 0 ? "removed" : "absent",
3104
+ path: paths.bridgeParent,
3105
+ };
1480
3106
  }
1481
3107
 
1482
3108
  async function prepareVerifiedPackage(options, host = options.host) {
@@ -1781,7 +3407,9 @@ async function preflightHost(host, action) {
1781
3407
  }
1782
3408
  const paths = managedPaths(host);
1783
3409
  if (host === "opencode") {
1784
- const presence = await Promise.all([paths.root, paths.bridge, paths.bridgeMarker].map((target) => exists(target)));
3410
+ const presence = await Promise.all(
3411
+ [paths.root, paths.bridge, paths.bridgeMarker].map((target) => entryExists(target)),
3412
+ );
1785
3413
  if (presence.some(Boolean) && !presence.every(Boolean)) {
1786
3414
  fail("OpenCode has a partial or unowned OpenSocrates installation; refusing to replace it");
1787
3415
  }
@@ -1803,7 +3431,7 @@ async function preflightHost(host, action) {
1803
3431
  "refusing to overwrite an unmanaged location",
1804
3432
  );
1805
3433
  }
1806
- const rootExists = await exists(paths.root);
3434
+ const rootExists = await entryExists(paths.root);
1807
3435
  if (rootExists) {
1808
3436
  await requireOwnedRoot(paths.root, host);
1809
3437
  }
@@ -1830,7 +3458,7 @@ async function preflightSelectedHosts(options, action, desiredState) {
1830
3458
  const candidates = assetHosts.length > 0 ? assetHosts : SUPPORTED_HOSTS;
1831
3459
  const desiredHosts = new Set(desiredState.installedHosts);
1832
3460
  const rootPresence = Object.fromEntries(
1833
- await Promise.all(candidates.map(async (host) => [host, await exists(managedPaths(host).root)])),
3461
+ await Promise.all(candidates.map(async (host) => [host, await entryExists(managedPaths(host).root)])),
1834
3462
  );
1835
3463
  const settled = await Promise.allSettled(candidates.map((host) => preflightHost(host, action)));
1836
3464
  const successes = new Map();
@@ -1924,15 +3552,15 @@ async function activateInstallation(transaction) {
1924
3552
  transaction.registrationRemoved = true;
1925
3553
  removeRegistration(host, previousEntry, previousState);
1926
3554
  }
1927
- if (await exists(paths.root)) {
3555
+ if (await entryExists(paths.root)) {
1928
3556
  await rename(paths.root, transaction.backup);
1929
3557
  transaction.backupCreated = true;
1930
3558
  }
1931
- if (host === "opencode" && (await exists(paths.bridge))) {
3559
+ if (host === "opencode" && (await entryExists(paths.bridge))) {
1932
3560
  await rename(paths.bridge, transaction.bridgeBackup);
1933
3561
  transaction.bridgeBackupCreated = true;
1934
3562
  }
1935
- if (host === "opencode" && (await exists(paths.bridgeMarker))) {
3563
+ if (host === "opencode" && (await entryExists(paths.bridgeMarker))) {
1936
3564
  await rename(paths.bridgeMarker, transaction.bridgeMarkerBackup);
1937
3565
  transaction.bridgeMarkerBackupCreated = true;
1938
3566
  }
@@ -2251,8 +3879,11 @@ function removalTransaction(preflight) {
2251
3879
  : null,
2252
3880
  registrationRemoved: false,
2253
3881
  backupCreated: false,
3882
+ rootCommitted: false,
2254
3883
  bridgeBackupCreated: false,
3884
+ bridgeCommitted: false,
2255
3885
  bridgeMarkerBackupCreated: false,
3886
+ bridgeMarkerCommitted: false,
2256
3887
  };
2257
3888
  }
2258
3889
 
@@ -2262,30 +3893,57 @@ async function activateRemoval(transaction) {
2262
3893
  transaction.registrationRemoved = true;
2263
3894
  removeRegistration(host, previousEntry, previousState);
2264
3895
  }
2265
- if (await exists(paths.root)) {
3896
+ if (await entryExists(paths.root)) {
2266
3897
  await rename(paths.root, transaction.backup);
2267
3898
  transaction.backupCreated = true;
2268
3899
  }
2269
- if (host === "opencode" && (await exists(paths.bridge))) {
3900
+ if (host === "opencode" && (await entryExists(paths.bridge))) {
2270
3901
  await rename(paths.bridge, transaction.bridgeBackup);
2271
3902
  transaction.bridgeBackupCreated = true;
2272
3903
  }
2273
- if (host === "opencode" && (await exists(paths.bridgeMarker))) {
3904
+ if (host === "opencode" && (await entryExists(paths.bridgeMarker))) {
2274
3905
  await rename(paths.bridgeMarker, transaction.bridgeMarkerBackup);
2275
3906
  transaction.bridgeMarkerBackupCreated = true;
2276
3907
  }
2277
3908
  }
2278
3909
 
2279
3910
  async function rollbackRemoval(transaction) {
2280
- let restored = !transaction.backupCreated;
2281
- if (transaction.backupCreated && (await exists(transaction.backup))) {
3911
+ let complete = true;
3912
+ let restored = !transaction.rootExists;
3913
+ if (transaction.rootExists && transaction.backupCreated && (await entryExists(transaction.backup))) {
2282
3914
  restored = await recoveryStep(`restore the removed ${transaction.host} files`, async () => {
2283
- await requireOwnedRoot(transaction.backup, transaction.host);
3915
+ await verifyManagedTreeForPurge(transaction.host, transaction.backup);
2284
3916
  await rename(transaction.backup, transaction.paths.root);
3917
+ try {
3918
+ await verifyManagedTreeForPurge(transaction.host, transaction.paths.root);
3919
+ } catch (error) {
3920
+ try {
3921
+ await rename(transaction.paths.root, transaction.backup);
3922
+ } catch (requarantineError) {
3923
+ const detail = requarantineError instanceof Error ? requarantineError.message : String(requarantineError);
3924
+ transaction.backupCreated = await entryExists(transaction.backup);
3925
+ fail(
3926
+ `the restored ${transaction.host} tree failed verification and could not be returned to ` +
3927
+ `${transaction.backup}: ${detail}`,
3928
+ );
3929
+ }
3930
+ throw error;
3931
+ }
3932
+ transaction.backupCreated = false;
3933
+ });
3934
+ } else if (transaction.rootExists && !transaction.rootCommitted && !transaction.backupCreated) {
3935
+ restored = await recoveryStep(`verify the existing ${transaction.host} files`, async () => {
3936
+ await verifyManagedTreeForPurge(transaction.host, transaction.paths.root);
2285
3937
  });
2286
3938
  }
2287
- if (transaction.registrationRemoved && transaction.previousEntry !== null && restored) {
2288
- await recoveryStep(`restore the removed ${transaction.host} registration`, async () => {
3939
+ complete = restored && complete;
3940
+ if (
3941
+ transaction.registrationRemoved &&
3942
+ transaction.previousEntry !== null &&
3943
+ restored &&
3944
+ (await entryExists(transaction.paths.root))
3945
+ ) {
3946
+ const registrationRestored = await recoveryStep(`restore the removed ${transaction.host} registration`, async () => {
2289
3947
  addRegistration(
2290
3948
  transaction.host,
2291
3949
  transaction.paths.root,
@@ -2293,37 +3951,92 @@ async function rollbackRemoval(transaction) {
2293
3951
  { enabled: transaction.previousState.kind !== "disabled" },
2294
3952
  );
2295
3953
  });
3954
+ complete = registrationRestored && complete;
3955
+ } else if (transaction.registrationRemoved && transaction.previousEntry !== null) {
3956
+ complete = false;
2296
3957
  }
2297
3958
  if (transaction.host === "opencode") {
2298
- if (transaction.bridgeBackupCreated && (await exists(transaction.bridgeBackup))) {
2299
- await recoveryStep("restore the removed OpenCode bridge", async () => {
3959
+ let bridgeRestored = !transaction.rootExists;
3960
+ if (
3961
+ transaction.rootExists &&
3962
+ transaction.bridgeBackupCreated &&
3963
+ (await entryExists(transaction.bridgeBackup))
3964
+ ) {
3965
+ bridgeRestored = await recoveryStep("restore the removed OpenCode bridge", async () => {
2300
3966
  await rename(transaction.bridgeBackup, transaction.paths.bridge);
3967
+ transaction.bridgeBackupCreated = false;
2301
3968
  });
3969
+ } else if (transaction.rootExists && !transaction.bridgeCommitted && !transaction.bridgeBackupCreated) {
3970
+ bridgeRestored = await entryExists(transaction.paths.bridge);
2302
3971
  }
2303
- if (transaction.bridgeMarkerBackupCreated && (await exists(transaction.bridgeMarkerBackup))) {
2304
- await recoveryStep("restore the removed OpenCode bridge sidecar", async () => {
3972
+ complete = bridgeRestored && complete;
3973
+ let markerRestored = !transaction.rootExists;
3974
+ if (
3975
+ transaction.rootExists &&
3976
+ transaction.bridgeMarkerBackupCreated &&
3977
+ (await entryExists(transaction.bridgeMarkerBackup))
3978
+ ) {
3979
+ markerRestored = await recoveryStep("restore the removed OpenCode bridge sidecar", async () => {
2305
3980
  await rename(transaction.bridgeMarkerBackup, transaction.paths.bridgeMarker);
3981
+ transaction.bridgeMarkerBackupCreated = false;
2306
3982
  });
3983
+ } else if (
3984
+ transaction.rootExists &&
3985
+ !transaction.bridgeMarkerCommitted &&
3986
+ !transaction.bridgeMarkerBackupCreated
3987
+ ) {
3988
+ markerRestored = await entryExists(transaction.paths.bridgeMarker);
2307
3989
  }
3990
+ complete = markerRestored && complete;
2308
3991
  }
3992
+ transaction.originalStateRestored = complete;
3993
+ return complete;
2309
3994
  }
2310
3995
 
2311
- async function commitRemoval(transaction) {
2312
- if (transaction.backupCreated && (await exists(transaction.backup))) {
2313
- await requireOwnedRoot(transaction.backup, transaction.host);
3996
+ async function commitRemoval(transaction, { beforeBackupDelete = null } = {}) {
3997
+ if (transaction.backupCreated && (await entryExists(transaction.backup))) {
3998
+ await verifyManagedTreeForPurge(transaction.host, transaction.backup);
3999
+ if (beforeBackupDelete !== null) {
4000
+ await beforeBackupDelete(Object.freeze({
4001
+ host: transaction.host,
4002
+ backup: transaction.backup,
4003
+ }));
4004
+ }
2314
4005
  await rm(transaction.backup, { recursive: true });
4006
+ transaction.backupCreated = false;
4007
+ transaction.rootCommitted = true;
2315
4008
  }
2316
4009
  if (transaction.host === "opencode") {
2317
- for (const [created, backup, label] of [
2318
- [transaction.bridgeBackupCreated, transaction.bridgeBackup, "bridge"],
2319
- [transaction.bridgeMarkerBackupCreated, transaction.bridgeMarkerBackup, "bridge sidecar"],
4010
+ for (const [flag, backup, label] of [
4011
+ ["bridgeBackupCreated", transaction.bridgeBackup, "bridge"],
4012
+ ["bridgeMarkerBackupCreated", transaction.bridgeMarkerBackup, "bridge sidecar"],
2320
4013
  ]) {
2321
- if (!created || !(await exists(backup))) continue;
4014
+ if (!transaction[flag] || !(await entryExists(backup))) continue;
2322
4015
  const info = await lstat(backup);
2323
4016
  if (!info.isFile() || info.isSymbolicLink()) {
2324
4017
  fail(`refusing to remove an unsafe OpenCode ${label} backup: ${backup}`);
2325
4018
  }
2326
4019
  await rm(backup);
4020
+ transaction[flag] = false;
4021
+ if (flag === "bridgeBackupCreated") transaction.bridgeCommitted = true;
4022
+ else transaction.bridgeMarkerCommitted = true;
4023
+ }
4024
+ }
4025
+ }
4026
+
4027
+ async function validateRemovalCommit(transaction) {
4028
+ if (transaction.backupCreated && (await entryExists(transaction.backup))) {
4029
+ await verifyManagedTreeForPurge(transaction.host, transaction.backup);
4030
+ }
4031
+ if (transaction.host !== "opencode") return;
4032
+ for (const [created, backup, label] of [
4033
+ [transaction.bridgeBackupCreated, transaction.bridgeBackup, "bridge"],
4034
+ [transaction.bridgeMarkerBackupCreated, transaction.bridgeMarkerBackup, "bridge sidecar"],
4035
+ ]) {
4036
+ if (!created || !(await entryExists(backup))) continue;
4037
+ const info = await lstat(backup);
4038
+ if (!info.isFile() || info.isSymbolicLink()) {
4039
+ fail(`refusing to remove an unsafe OpenCode ${label} backup: ${backup}`);
2327
4040
  }
2328
4041
  }
2329
4042
  }
@@ -2539,14 +4252,42 @@ async function installLaunchAgent(channel, hosts) {
2539
4252
  }
2540
4253
  }
2541
4254
 
4255
+ async function requireOwnedLaunchAgent(target) {
4256
+ const info = await lstat(target);
4257
+ if (!info.isFile() || info.isSymbolicLink()) {
4258
+ fail(`refusing to remove an unsafe LaunchAgent path: ${target}`);
4259
+ }
4260
+ const document = await readFile(target, "utf8");
4261
+ const labelPattern = new RegExp(
4262
+ `<key>\\s*Label\\s*</key>\\s*<string>\\s*${AUTO_UPDATE_LABEL.replaceAll(".", "\\.")}\\s*</string>`,
4263
+ "gu",
4264
+ );
4265
+ const argumentBlocks = [
4266
+ ...document.matchAll(/<key>\s*ProgramArguments\s*<\/key>\s*<array>([\s\S]*?)<\/array>/gu),
4267
+ ];
4268
+ const arguments_ =
4269
+ argumentBlocks.length === 1
4270
+ ? [...argumentBlocks[0][1].matchAll(/<string>([\s\S]*?)<\/string>/gu)].map((match) => match[1].trim())
4271
+ : [];
4272
+ if (
4273
+ [...document.matchAll(labelPattern)].length !== 1 ||
4274
+ arguments_.length !== 5 ||
4275
+ !arguments_[0].startsWith("/") ||
4276
+ arguments_[1] !== "--yes" ||
4277
+ !/^opensocrates@(?:latest|next)$/u.test(arguments_[2]) ||
4278
+ arguments_[3] !== "auto-update" ||
4279
+ arguments_[4] !== "run"
4280
+ ) {
4281
+ fail(`refusing to remove a LaunchAgent without the OpenSocrates ownership identity: ${target}`);
4282
+ }
4283
+ }
4284
+
2542
4285
  async function disableLaunchAgent() {
2543
4286
  const paths = statePaths();
2544
- const agentPresent = await exists(paths.launchAgent);
4287
+ const agentPresent = await entryExists(paths.launchAgent);
2545
4288
  if (agentPresent) {
2546
- const info = await lstat(paths.launchAgent);
2547
- if (!info.isFile() || info.isSymbolicLink()) {
2548
- fail(`refusing to remove an unsafe LaunchAgent path: ${paths.launchAgent}`);
2549
- }
4289
+ await requireSafePathBelow(paths.launchAgentsDirectory, paths.launchAgent, "OpenSocrates LaunchAgent");
4290
+ await requireOwnedLaunchAgent(paths.launchAgent);
2550
4291
  }
2551
4292
  if (process.env.OPENSOCRATES_SKIP_LAUNCHCTL !== "1") {
2552
4293
  stopLoadedLaunchAgent();
@@ -2562,7 +4303,7 @@ async function disableAutoUpdateState(desired) {
2562
4303
  };
2563
4304
  }
2564
4305
 
2565
- async function runRemove(options) {
4306
+ async function runStandardRemove(options, { report = true, beforeBackupDelete = null } = {}) {
2566
4307
  const desired = await readDesiredState();
2567
4308
  const preflights = await preflightSelectedHosts(options, "remove", desired);
2568
4309
  const removedHosts = preflights.map((item) => item.host);
@@ -2577,14 +4318,23 @@ async function runRemove(options) {
2577
4318
  ? { ...desired.autoUpdate, hosts: remainingAutoUpdateHosts }
2578
4319
  : { enabled: false, hosts: [], nextCheckAt: null },
2579
4320
  };
2580
- const launchAgentPresent = await exists(statePaths().launchAgent);
4321
+ const launchAgentPresent = await entryExists(statePaths().launchAgent);
2581
4322
  const schedulerNeedsChange =
2582
4323
  options.host === ALL_HOST ||
2583
4324
  remainingHosts.length === 0 ||
2584
4325
  launchAgentPresent !== keepAutoUpdate ||
2585
4326
  desired.autoUpdate.hosts.some((host) => removedHosts.includes(host));
2586
4327
  const transactions = preflights.map(removalTransaction);
4328
+ // Validate every source tree before the first unregister/rename. The same
4329
+ // check runs again after activation and immediately before deletion to
4330
+ // close TOCTOU gaps without stranding pre-existing drift in a backup.
4331
+ for (const transaction of transactions) {
4332
+ if (transaction.rootExists) {
4333
+ await verifyManagedTreeForPurge(transaction.host, transaction.paths.root);
4334
+ }
4335
+ }
2587
4336
  let schedulerTouched = false;
4337
+ let desiredWritten = false;
2588
4338
  try {
2589
4339
  for (const transaction of transactions) await activateRemoval(transaction);
2590
4340
  if (schedulerNeedsChange) {
@@ -2596,43 +4346,75 @@ async function runRemove(options) {
2596
4346
  }
2597
4347
  }
2598
4348
  await writeDesiredState(nextDesired);
4349
+ desiredWritten = true;
4350
+ for (const transaction of transactions) await validateRemovalCommit(transaction);
4351
+ for (const transaction of transactions) {
4352
+ await commitRemoval(transaction, { beforeBackupDelete });
4353
+ }
2599
4354
  } catch (error) {
2600
- for (const transaction of [...transactions].reverse()) await rollbackRemoval(transaction);
4355
+ let rollbackComplete = true;
4356
+ for (const transaction of [...transactions].reverse()) {
4357
+ const restored = await rollbackRemoval(transaction);
4358
+ rollbackComplete = restored && rollbackComplete;
4359
+ }
4360
+ const unrestoredHosts = new Set(
4361
+ transactions.filter((transaction) => transaction.originalStateRestored !== true).map((item) => item.host),
4362
+ );
4363
+ const recoveredHosts = desired.installedHosts.filter((host) => !unrestoredHosts.has(host));
4364
+ const recoveredAutoUpdateHosts = desired.autoUpdate.hosts.filter((host) => recoveredHosts.includes(host));
4365
+ const keepRecoveredAutoUpdate = desired.autoUpdate.enabled && recoveredAutoUpdateHosts.length > 0;
4366
+ let recoveredDesired = {
4367
+ ...desired,
4368
+ installedHosts: recoveredHosts,
4369
+ activeVersion: recoveredHosts.length === 0 ? null : desired.activeVersion,
4370
+ autoUpdate: keepRecoveredAutoUpdate
4371
+ ? { ...desired.autoUpdate, hosts: recoveredAutoUpdateHosts }
4372
+ : { enabled: false, hosts: [], nextCheckAt: null },
4373
+ };
4374
+ let schedulerRestored = true;
2601
4375
  if (schedulerTouched) {
2602
- const schedulerRestored = await recoveryStep("restore the automatic updater after removal failure", async () => {
2603
- if (desired.autoUpdate.enabled) {
2604
- await installLaunchAgent(desired.channel, desired.autoUpdate.hosts);
4376
+ schedulerRestored = await recoveryStep("reconcile the automatic updater after removal failure", async () => {
4377
+ if (recoveredDesired.autoUpdate.enabled) {
4378
+ await installLaunchAgent(recoveredDesired.channel, recoveredDesired.autoUpdate.hosts);
2605
4379
  } else {
2606
4380
  await disableLaunchAgent();
2607
4381
  }
2608
4382
  });
2609
- if (!schedulerRestored && desired.autoUpdate.enabled) {
2610
- await recoveryStep("record the disabled updater after removal failure", async () => {
2611
- await writeDesiredState({
2612
- ...desired,
2613
- autoUpdate: {
2614
- enabled: false,
2615
- hosts: [],
2616
- nextCheckAt: null,
2617
- },
2618
- });
2619
- });
2620
- }
2621
- } else if (!nextDesired.autoUpdate.enabled && desired.autoUpdate.enabled) {
2622
- await recoveryStep("record the disabled updater after removal failure", async () => {
2623
- await writeDesiredState({
2624
- ...desired,
4383
+ if (!schedulerRestored && recoveredDesired.autoUpdate.enabled) {
4384
+ recoveredDesired = {
4385
+ ...recoveredDesired,
2625
4386
  autoUpdate: {
2626
4387
  enabled: false,
2627
4388
  hosts: [],
2628
4389
  nextCheckAt: null,
2629
4390
  },
2630
- });
4391
+ };
4392
+ }
4393
+ rollbackComplete = schedulerRestored && rollbackComplete;
4394
+ }
4395
+ if (desiredWritten || !rollbackComplete || !schedulerRestored) {
4396
+ const stateRestored = await recoveryStep("record desired state after removal failure", async () => {
4397
+ await writeDesiredState(recoveredDesired);
2631
4398
  });
4399
+ rollbackComplete = stateRestored && rollbackComplete;
4400
+ }
4401
+ if (!rollbackComplete) {
4402
+ console.error("error: removal cleanup is incomplete; no success was recorded.");
4403
+ for (const transaction of transactions) {
4404
+ if (!transaction.backupCreated || !(await entryExists(transaction.backup))) continue;
4405
+ console.error(`error: preserved ${transaction.host} removal residue: ${transaction.backup}`);
4406
+ console.error(
4407
+ `error: safe cleanup retry: opensocrates remove --host ${transaction.host} --purge`,
4408
+ );
4409
+ console.error(
4410
+ "error: if purge reports an integrity failure, inspect that exact UUID residue before manual cleanup: " +
4411
+ `/bin/rm -rf -- ${shellQuote(transaction.backup)}`,
4412
+ );
4413
+ }
2632
4414
  }
2633
4415
  throw error;
2634
4416
  }
2635
- for (const transaction of transactions) await commitRemoval(transaction);
4417
+ if (!report) return { removedHosts, desired: nextDesired };
2636
4418
  for (const host of removedHosts) {
2637
4419
  const label =
2638
4420
  host === "antigravity"
@@ -2649,6 +4431,454 @@ async function runRemove(options) {
2649
4431
  console.log(`OpenSocrates was removed from ${label}.`);
2650
4432
  }
2651
4433
  if (removedHosts.length === 0) console.log("OpenSocrates is not installed on any managed host.");
4434
+ console.log("Removal scope: registrations and installer-managed roots only; this is not a complete uninstall.");
4435
+ const residue = [];
4436
+ for (const host of removedHosts) {
4437
+ const paths = purgePathsFor(host);
4438
+ for (const target of [paths.cacheRoot, paths.cacheMarketplaceRoot, ...paths.pluginData]) {
4439
+ if (target !== null && (await entryExists(target))) residue.push(target);
4440
+ }
4441
+ }
4442
+ for (const target of [statePaths().desiredState, statePaths().receipt]) {
4443
+ if (await entryExists(target)) residue.push(target);
4444
+ }
4445
+ for (const target of [...new Set(residue)]) console.log(`Remaining OpenSocrates path: ${target}`);
4446
+ if (removedHosts.includes("codex")) {
4447
+ console.log("Codex OpenSocrates hook trust is preserved by this removal contract.");
4448
+ }
4449
+ console.log(`Next: close active hosts, then run opensocrates remove --host ${options.host} --purge.`);
4450
+ return { removedHosts, desired: nextDesired };
4451
+ }
4452
+
4453
+ async function purgeRegistration(host, paths) {
4454
+ if (FILE_DROP_HOSTS.includes(host) || host === "opencode") {
4455
+ return { status: "not-applicable", detail: "file-owned-host" };
4456
+ }
4457
+ try {
4458
+ requireHostCli(host);
4459
+ } catch (error) {
4460
+ return {
4461
+ status: "unverified",
4462
+ detail: error instanceof Error ? error.message : String(error),
4463
+ };
4464
+ }
4465
+ if (host === "claude") warnLegacyClaudeInstallation();
4466
+ const entry = marketplaceEntry(host);
4467
+ if (entry !== null && entryRoot(entry, host) !== paths.root) {
4468
+ fail(`${host} OpenSocrates registration points to an unmanaged location`);
4469
+ }
4470
+ const state = pluginState(host);
4471
+ const present = entry !== null || ["installed", "disabled"].includes(state.kind);
4472
+ removeRegistration(host, entry, state);
4473
+ const remainingEntry = marketplaceEntry(host);
4474
+ const remainingState = pluginState(host);
4475
+ if (remainingEntry !== null || ["installed", "disabled"].includes(remainingState.kind)) {
4476
+ fail(`${host} did not confirm removal of the exact ${PLUGIN_ID} registration`);
4477
+ }
4478
+ return { status: present ? "removed" : "absent", detail: null };
4479
+ }
4480
+
4481
+ async function capturePurgeComponent(hostResult, component, action) {
4482
+ try {
4483
+ const value = await action();
4484
+ const items = Array.isArray(value) ? value : [value];
4485
+ hostResult.components.push(...items);
4486
+ return items;
4487
+ } catch (error) {
4488
+ const message = error instanceof Error ? error.message : String(error);
4489
+ hostResult.components.push({ component, status: "failed", path: null, detail: message });
4490
+ hostResult.errors.push(message);
4491
+ return [];
4492
+ }
4493
+ }
4494
+
4495
+ function purgeComponentBlocksCompletion(component) {
4496
+ return new Set(["pending", "failed"]).has(component.status);
4497
+ }
4498
+
4499
+ function finalizePurgeHostResult(hostResult) {
4500
+ const registrationComplete = new Set(["removed", "absent", "not-applicable"]).has(hostResult.registration);
4501
+ hostResult.status =
4502
+ registrationComplete &&
4503
+ !hostResult.components.some(purgeComponentBlocksCompletion) &&
4504
+ !purgeComponentBlocksCompletion(hostResult.extension)
4505
+ ? "complete"
4506
+ : "partial";
4507
+ return hostResult.status === "complete";
4508
+ }
4509
+
4510
+ async function resetPurgeTrustExtension(hostResult, options) {
4511
+ if (hostResult.host !== "codex" || !options.resetTrust) return true;
4512
+ try {
4513
+ const reset = await resetCodexOpenSocratesHookTrust({ hooks: options.trustResetHooks ?? {} });
4514
+ hostResult.extension = {
4515
+ component: "host-security-trust",
4516
+ status: reset.status,
4517
+ removedCount: reset.removedEvents.length,
4518
+ nextAction: null,
4519
+ };
4520
+ return true;
4521
+ } catch (error) {
4522
+ const message = error instanceof Error ? error.message : String(error);
4523
+ hostResult.extension = {
4524
+ component: "host-security-trust",
4525
+ status: "failed",
4526
+ removedCount: 0,
4527
+ nextAction: "retry-purge-with-reset-trust-or-review-exact-sections-manually",
4528
+ detail: message,
4529
+ };
4530
+ hostResult.errors.push(message);
4531
+ return false;
4532
+ }
4533
+ }
4534
+
4535
+ function preservePurgeComponentsAfterTrustFailure(hostResult, paths) {
4536
+ hostResult.registration = "preserved";
4537
+ hostResult.registrationDetail = "not-attempted-after-trust-reset-failure";
4538
+ const preserved = [
4539
+ ["managed-root", paths.root],
4540
+ ["transaction-residue", null],
4541
+ ];
4542
+ if (hostResult.host === "opencode") {
4543
+ preserved.push(["opencode-bridge", paths.bridge], ["opencode-bridge-residue", null]);
4544
+ }
4545
+ if (paths.cacheRoot !== null) preserved.push(["plugin-cache", paths.cacheRoot]);
4546
+ preserved.push(...paths.pluginData.map((path) => ["plugin-data", path]));
4547
+ for (const [component, path] of preserved) {
4548
+ hostResult.components.push({
4549
+ component,
4550
+ status: "preserved",
4551
+ path,
4552
+ detail: "not-attempted-after-trust-reset-failure",
4553
+ });
4554
+ }
4555
+ }
4556
+
4557
+ async function purgeOneHost(hostResult, options) {
4558
+ const paths = purgePathsFor(hostResult.host);
4559
+ if (!(await resetPurgeTrustExtension(hostResult, options))) {
4560
+ preservePurgeComponentsAfterTrustFailure(hostResult, paths);
4561
+ finalizePurgeHostResult(hostResult);
4562
+ return;
4563
+ }
4564
+ try {
4565
+ const registration = await purgeRegistration(hostResult.host, paths);
4566
+ hostResult.registration = registration.status;
4567
+ hostResult.registrationDetail = registration.detail;
4568
+ if (registration.status === "unverified") hostResult.errors.push(registration.detail);
4569
+ } catch (error) {
4570
+ const message = error instanceof Error ? error.message : String(error);
4571
+ hostResult.registration = "failed";
4572
+ hostResult.registrationDetail = message;
4573
+ hostResult.errors.push(message);
4574
+ }
4575
+
4576
+ await capturePurgeComponent(hostResult, "managed-root", () =>
4577
+ removeOwnedManagedRoot(hostResult.host, paths),
4578
+ );
4579
+ if (hostResult.host === "opencode") {
4580
+ await capturePurgeComponent(hostResult, "opencode-bridge", () => purgeOpenCodeBridge(paths));
4581
+ }
4582
+ await capturePurgeComponent(hostResult, "transaction-residue", () =>
4583
+ cleanupTransientRootResidue(hostResult.host, paths),
4584
+ );
4585
+ if (hostResult.host === "opencode") {
4586
+ await capturePurgeComponent(hostResult, "opencode-bridge-residue", () =>
4587
+ purgeOpenCodeBridgeResidue(paths),
4588
+ );
4589
+ }
4590
+ if (paths.cacheRoot !== null) {
4591
+ await capturePurgeComponent(hostResult, "plugin-cache", () => purgeHostCache(hostResult.host, paths));
4592
+ }
4593
+ if (paths.pluginData.length > 0) {
4594
+ await capturePurgeComponent(hostResult, "plugin-data", () => purgeClaudePluginData(paths));
4595
+ }
4596
+ finalizePurgeHostResult(hostResult);
4597
+ }
4598
+
4599
+ function hostDeactivated(hostResult) {
4600
+ const registrationComplete = new Set(["removed", "absent", "not-applicable"]).has(hostResult.registration);
4601
+ const activationComponents = hostResult.components.filter((item) =>
4602
+ new Set(["managed-root", "opencode-bridge"]).has(item.component),
4603
+ );
4604
+ return registrationComplete && !activationComponents.some(purgeComponentBlocksCompletion);
4605
+ }
4606
+
4607
+ async function updatePurgeDesiredState(options, desired, result) {
4608
+ const deactivated = result.hosts.filter(hostDeactivated).map((item) => item.host);
4609
+ const installedHosts = desired.installedHosts.filter((host) => !deactivated.includes(host));
4610
+ const autoUpdateHosts = desired.autoUpdate.hosts.filter((host) => !deactivated.includes(host));
4611
+ const keepAutoUpdate = desired.autoUpdate.enabled && autoUpdateHosts.length > 0;
4612
+ let nextDesired = {
4613
+ ...desired,
4614
+ installedHosts,
4615
+ activeVersion: installedHosts.length === 0 ? null : desired.activeVersion,
4616
+ autoUpdate: keepAutoUpdate
4617
+ ? { ...desired.autoUpdate, hosts: autoUpdateHosts }
4618
+ : { enabled: false, hosts: [], nextCheckAt: null },
4619
+ };
4620
+ const launchAgentPresent = await entryExists(statePaths().launchAgent);
4621
+ const schedulerNeedsChange =
4622
+ deactivated.length > 0 &&
4623
+ (options.host === ALL_HOST ||
4624
+ installedHosts.length === 0 ||
4625
+ launchAgentPresent !== keepAutoUpdate ||
4626
+ desired.autoUpdate.hosts.some((host) => deactivated.includes(host)));
4627
+ if (schedulerNeedsChange) {
4628
+ try {
4629
+ if (keepAutoUpdate) await installLaunchAgent(desired.channel, autoUpdateHosts);
4630
+ else await disableLaunchAgent();
4631
+ result.finalization.components.push({
4632
+ component: "launch-agent",
4633
+ status: launchAgentPresent ? "removed-or-reconciled" : "absent",
4634
+ path: statePaths().launchAgent,
4635
+ });
4636
+ } catch (error) {
4637
+ const message = error instanceof Error ? error.message : String(error);
4638
+ result.finalization.components.push({
4639
+ component: "launch-agent",
4640
+ status: "failed",
4641
+ path: statePaths().launchAgent,
4642
+ detail: message,
4643
+ });
4644
+ result.finalization.errors.push(message);
4645
+ nextDesired = desired;
4646
+ }
4647
+ } else {
4648
+ result.finalization.components.push({
4649
+ component: "launch-agent",
4650
+ status: "preserved",
4651
+ path: statePaths().launchAgent,
4652
+ });
4653
+ }
4654
+ if (!schedulerNeedsChange && jsonDeepEqual(nextDesired, desired)) {
4655
+ result.finalization.components.push({
4656
+ component: "desired-state",
4657
+ status: "preserved",
4658
+ path: statePaths().desiredState,
4659
+ });
4660
+ return desired;
4661
+ }
4662
+ try {
4663
+ nextDesired = await writeDesiredState(nextDesired);
4664
+ result.finalization.components.push({
4665
+ component: "desired-state",
4666
+ status: "updated",
4667
+ path: statePaths().desiredState,
4668
+ });
4669
+ } catch (error) {
4670
+ const message = error instanceof Error ? error.message : String(error);
4671
+ result.finalization.components.push({
4672
+ component: "desired-state",
4673
+ status: "failed",
4674
+ path: statePaths().desiredState,
4675
+ detail: message,
4676
+ });
4677
+ result.finalization.errors.push(message);
4678
+ }
4679
+ return nextDesired;
4680
+ }
4681
+
4682
+ async function preparePurgeStateFinalization() {
4683
+ const paths = statePaths();
4684
+ if (!(await entryExists(paths.directory))) return { directory: paths.directory, tombstones: [] };
4685
+ const directoryInfo = await lstat(paths.directory);
4686
+ if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) {
4687
+ fail(`refusing to finalize an unsafe OpenSocrates state directory: ${paths.directory}`);
4688
+ }
4689
+ const uuid = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
4690
+ const stateTemporary = new RegExp(
4691
+ `^\\.(?:desired-state|auto-update-receipt)\\.json\\.${uuid}\\.tmp$`,
4692
+ "u",
4693
+ );
4694
+ const purgeTombstone = new RegExp(
4695
+ `^\\.purge-finalize-${uuid}-(?:desired-state\\.json|auto-update-receipt\\.json|` +
4696
+ `\\.(?:desired-state|auto-update-receipt)\\.json\\.${uuid}\\.tmp)$`,
4697
+ "u",
4698
+ );
4699
+ const candidates = [paths.desiredState, paths.receipt];
4700
+ const tombstones = [];
4701
+ for (const name of await readdir(paths.directory)) {
4702
+ if (stateTemporary.test(name)) {
4703
+ candidates.push(join(paths.directory, name));
4704
+ } else if (purgeTombstone.test(name)) {
4705
+ tombstones.push({ original: null, tombstone: join(paths.directory, name) });
4706
+ }
4707
+ }
4708
+ const renamed = [];
4709
+ try {
4710
+ for (const item of tombstones) {
4711
+ await requireSafePathBelow(paths.directory, item.tombstone, "OpenSocrates prior purge tombstone");
4712
+ const info = await lstat(item.tombstone);
4713
+ if (!info.isFile() || info.isSymbolicLink()) {
4714
+ fail(`refusing to remove an unsafe prior OpenSocrates purge tombstone: ${item.tombstone}`);
4715
+ }
4716
+ }
4717
+ for (const target of candidates) {
4718
+ if (!(await entryExists(target))) continue;
4719
+ await requireSafePathBelow(paths.directory, target, "OpenSocrates state file");
4720
+ const info = await lstat(target);
4721
+ if (!info.isFile() || info.isSymbolicLink()) {
4722
+ fail(`refusing to remove an unsafe OpenSocrates state file: ${target}`);
4723
+ }
4724
+ const tombstone = join(paths.directory, `.purge-finalize-${randomUUID()}-${basename(target)}`);
4725
+ await rename(target, tombstone);
4726
+ const item = { original: target, tombstone };
4727
+ tombstones.push(item);
4728
+ renamed.push(item);
4729
+ }
4730
+ return { directory: paths.directory, tombstones };
4731
+ } catch (error) {
4732
+ for (const item of [...renamed].reverse()) {
4733
+ await recoveryStep("restore installer state after purge finalization failed", async () => {
4734
+ if (await entryExists(item.tombstone)) await rename(item.tombstone, item.original);
4735
+ });
4736
+ }
4737
+ throw error;
4738
+ }
4739
+ }
4740
+
4741
+ async function completePurgeStateFinalization(plan, result, options) {
4742
+ try {
4743
+ for (const item of plan.tombstones) {
4744
+ if (!(await entryExists(item.tombstone))) continue;
4745
+ await requireSafePathBelow(plan.directory, item.tombstone, "OpenSocrates purge tombstone");
4746
+ const info = await lstat(item.tombstone);
4747
+ if (!info.isFile() || info.isSymbolicLink()) {
4748
+ fail(`refusing to remove an unsafe OpenSocrates purge tombstone: ${item.tombstone}`);
4749
+ }
4750
+ await rm(item.tombstone);
4751
+ }
4752
+ if (await entryExists(plan.directory)) await rmdir(plan.directory);
4753
+ result.finalization.components.push({
4754
+ component: "state-directory",
4755
+ status: "removed",
4756
+ path: plan.directory,
4757
+ });
4758
+ result.finalization.status = "complete";
4759
+ } catch (error) {
4760
+ const message = error instanceof Error ? error.message : String(error);
4761
+ result.finalization.components.push({
4762
+ component: "state-directory",
4763
+ status: "failed",
4764
+ path: plan.directory,
4765
+ detail: message,
4766
+ });
4767
+ result.finalization.errors.push(message);
4768
+ result.finalization.status = "partial";
4769
+ result.status = "partial";
4770
+ console.error(`error: OpenSocrates state cleanup remains at: ${plan.directory}`);
4771
+ console.error(
4772
+ `error: retry command: opensocrates remove --host ${options.host} --purge` +
4773
+ (options.resetTrust ? " --reset-trust" : ""),
4774
+ );
4775
+ }
4776
+ }
4777
+
4778
+ async function runPurgeLocked(options) {
4779
+ const hosts = options.host === ALL_HOST ? SUPPORTED_HOSTS : [options.host];
4780
+ const result = createPurgeResult(hosts, { resetTrust: options.resetTrust });
4781
+ const desired = await readDesiredState();
4782
+ for (const hostResult of result.hosts) await purgeOneHost(hostResult, options);
4783
+ const nextDesired = await updatePurgeDesiredState(options, desired, result);
4784
+ const hostsComplete = result.hosts.every((item) => item.status === "complete");
4785
+ const finalizationComplete = result.finalization.errors.length === 0;
4786
+ result.status = hostsComplete && finalizationComplete ? "complete" : "partial";
4787
+ let stateFinalization = null;
4788
+ if (
4789
+ result.status === "complete" &&
4790
+ nextDesired.installedHosts.length === 0 &&
4791
+ !nextDesired.autoUpdate.enabled
4792
+ ) {
4793
+ try {
4794
+ stateFinalization = await preparePurgeStateFinalization();
4795
+ result.finalization.status = "ready";
4796
+ } catch (error) {
4797
+ const message = error instanceof Error ? error.message : String(error);
4798
+ result.finalization.components.push({
4799
+ component: "state-finalization",
4800
+ status: "failed",
4801
+ path: statePaths().directory,
4802
+ detail: message,
4803
+ });
4804
+ result.finalization.errors.push(message);
4805
+ result.finalization.status = "partial";
4806
+ result.status = "partial";
4807
+ }
4808
+ } else {
4809
+ result.finalization.status = result.status === "complete" ? "preserved" : "deferred";
4810
+ }
4811
+ return { result, stateFinalization };
4812
+ }
4813
+
4814
+ function reportPurgeResult(result) {
4815
+ for (const host of result.hosts) {
4816
+ console.log(`${host.host}: purge ${host.status}; registration ${host.registration}.`);
4817
+ if (
4818
+ new Set(["unverified", "failed"]).has(host.registration) &&
4819
+ typeof host.registrationDetail === "string"
4820
+ ) {
4821
+ console.error(`${host.host}: registration ${host.registration} (${host.registrationDetail})`);
4822
+ }
4823
+ for (const component of host.components) {
4824
+ if (new Set(["pending", "failed"]).has(component.status)) {
4825
+ console.error(
4826
+ `${host.host}: ${component.component} ${component.status}` +
4827
+ (component.path ? ` at ${component.path}` : "") +
4828
+ (component.detail ? ` (${component.detail})` : ""),
4829
+ );
4830
+ }
4831
+ }
4832
+ if (host.extension.status === "preserved") {
4833
+ console.log(
4834
+ `${host.host}: host security trust was preserved; rerun purge with --reset-trust to reset only ` +
4835
+ "the exact OpenSocrates hook approvals.",
4836
+ );
4837
+ } else if (host.extension.status === "reset") {
4838
+ console.log(
4839
+ `${host.host}: host security trust reset completed for ${host.extension.removedCount} exact ` +
4840
+ "OpenSocrates hook entries.",
4841
+ );
4842
+ } else if (host.extension.status === "absent") {
4843
+ console.log(`${host.host}: no exact OpenSocrates host security trust entries were present.`);
4844
+ } else if (host.extension.status === "failed") {
4845
+ console.error(
4846
+ `${host.host}: host security trust reset failed; no successful trust mutation is claimed.`,
4847
+ );
4848
+ console.error(
4849
+ `${host.host}: rerun the same purge with --reset-trust after resolving Codex config validation, ` +
4850
+ "or back up the config and manually review only the seven exact OpenSocrates hook sections.",
4851
+ );
4852
+ }
4853
+ }
4854
+ for (const component of result.finalization.components) {
4855
+ if (new Set(["pending", "failed"]).has(component.status)) {
4856
+ console.error(
4857
+ `finalization: ${component.component} ${component.status}` +
4858
+ (component.path ? ` at ${component.path}` : "") +
4859
+ (component.detail ? ` (${component.detail})` : ""),
4860
+ );
4861
+ }
4862
+ }
4863
+ if (result.status === "complete") {
4864
+ console.log("OpenSocrates purge completed for registrations and provably owned payloads.");
4865
+ console.log("User task, project, chat, plan, and history data was preserved.");
4866
+ } else {
4867
+ console.error("OpenSocrates purge is incomplete; no complete-uninstall success is claimed.");
4868
+ console.error("Resolve the reported item, close active hosts if needed, and rerun the same purge command.");
4869
+ }
4870
+ }
4871
+
4872
+ async function runPurge(options) {
4873
+ const outcome = await withOperationLock(() => runPurgeLocked(options));
4874
+ if (outcome.stateFinalization !== null) {
4875
+ await completePurgeStateFinalization(outcome.stateFinalization, outcome.result, options);
4876
+ }
4877
+ reportPurgeResult(outcome.result);
4878
+ if (outcome.result.status !== "complete") {
4879
+ fail("purge remains pending or failed for one or more components");
4880
+ }
4881
+ return outcome.result;
2652
4882
  }
2653
4883
 
2654
4884
  async function enableAutoUpdate(options) {
@@ -2897,7 +5127,7 @@ async function verifyPackages(options) {
2897
5127
  }
2898
5128
  }
2899
5129
 
2900
- export async function main(argv = process.argv.slice(2)) {
5130
+ export async function main(argv = process.argv.slice(2), internalDependencies = {}) {
2901
5131
  const options = parseCli(argv);
2902
5132
  if (options.action === "help") {
2903
5133
  showHelp();
@@ -2915,9 +5145,18 @@ export async function main(argv = process.argv.slice(2)) {
2915
5145
  await showAutoUpdateStatus();
2916
5146
  return 0;
2917
5147
  }
5148
+ if (options.action === "remove" && options.purge) {
5149
+ await runPurge({
5150
+ ...options,
5151
+ trustResetHooks: internalDependencies.trustResetHooks ?? {},
5152
+ });
5153
+ return 0;
5154
+ }
2918
5155
  return withOperationLock(async () => {
2919
5156
  if (options.action === "remove") {
2920
- await runRemove(options);
5157
+ await runStandardRemove(options, {
5158
+ beforeBackupDelete: internalDependencies.beforeRemovalBackupDelete ?? null,
5159
+ });
2921
5160
  return 0;
2922
5161
  }
2923
5162
  if (options.action === "auto-update") {