opensocrates 1.1.5 → 1.2.1

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,11 +1,19 @@
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
- import { constants as fsConstants, createReadStream, createWriteStream, realpathSync } from "node:fs";
5
+ import {
6
+ constants as fsConstants,
7
+ createReadStream,
8
+ createWriteStream,
9
+ existsSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ } from "node:fs";
6
13
  import {
7
14
  access,
8
15
  chmod,
16
+ chown,
9
17
  cp,
10
18
  lstat,
11
19
  mkdir,
@@ -13,8 +21,10 @@ import {
13
21
  open,
14
22
  readFile,
15
23
  readdir,
24
+ realpath,
16
25
  rename,
17
26
  rm,
27
+ rmdir,
18
28
  stat,
19
29
  writeFile,
20
30
  } from "node:fs/promises";
@@ -24,13 +34,20 @@ import { Readable } from "node:stream";
24
34
  import { pipeline } from "node:stream/promises";
25
35
  import { fileURLToPath } from "node:url";
26
36
 
27
- export const PRODUCT_VERSION = "1.1.5";
37
+ export const PRODUCT_VERSION = "1.2.1";
28
38
  export const REPOSITORY = "ParkerHwang/OpenSocrates";
29
39
  export const MARKETPLACE_NAME = "opensocrates";
30
40
  export const PLUGIN_NAME = "opensocrates";
31
41
  export const PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
32
42
  export const DEFAULT_HOST = "codex";
33
- export const SUPPORTED_HOSTS = Object.freeze(["claude", "codex"]);
43
+ export const SUPPORTED_HOSTS = Object.freeze([
44
+ "antigravity",
45
+ "claude",
46
+ "codex",
47
+ "cursor",
48
+ "grok",
49
+ "opencode",
50
+ ]);
34
51
  export const ALL_HOST = "all";
35
52
  export const HOST_CHOICES = Object.freeze([ALL_HOST, ...SUPPORTED_HOSTS]);
36
53
  export function assetNameFor(host = DEFAULT_HOST) {
@@ -54,16 +71,75 @@ const CLAUDE_MARKER = Object.freeze({
54
71
  pluginName: PLUGIN_NAME,
55
72
  host: "claude",
56
73
  });
74
+ const CURSOR_MARKER = Object.freeze({
75
+ schemaVersion: 1,
76
+ marketplaceName: MARKETPLACE_NAME,
77
+ pluginName: PLUGIN_NAME,
78
+ host: "cursor",
79
+ registrationKind: "file-drop",
80
+ });
81
+ const ANTIGRAVITY_MARKER = Object.freeze({
82
+ schemaVersion: 1,
83
+ marketplaceName: MARKETPLACE_NAME,
84
+ pluginName: PLUGIN_NAME,
85
+ host: "antigravity",
86
+ registrationKind: "file-drop",
87
+ });
88
+ const GROK_MARKER = Object.freeze({
89
+ schemaVersion: 1,
90
+ marketplaceName: MARKETPLACE_NAME,
91
+ pluginName: PLUGIN_NAME,
92
+ host: "grok",
93
+ registrationKind: "file-drop",
94
+ });
95
+ const OPENCODE_MARKER = Object.freeze({
96
+ schemaVersion: 1,
97
+ marketplaceName: MARKETPLACE_NAME,
98
+ pluginName: PLUGIN_NAME,
99
+ host: "opencode",
100
+ registrationKind: "automatic-file-discovery",
101
+ });
102
+ const OPENCODE_INSTALL_MANIFEST = ".opensocrates-installation.json";
103
+ const OPENCODE_BRIDGE_MARKER = ".opensocrates-managed.json";
104
+ // OpenCode is discovered automatically from its config directory, so it is
105
+ // deliberately not a file-drop host.
106
+ const FILE_DROP_HOSTS = Object.freeze(["antigravity", "cursor", "grok"]);
57
107
  const HOST_LAYOUTS = Object.freeze({
108
+ antigravity: {
109
+ marketplaceRelative: null,
110
+ pluginRelative: ".",
111
+ manifestRelative: "plugin.json",
112
+ requiresRuntime: false,
113
+ },
58
114
  claude: {
59
115
  marketplaceRelative: join(".claude-plugin", "marketplace.json"),
60
116
  pluginRelative: join("plugins", PLUGIN_NAME),
61
117
  manifestRelative: join(".claude-plugin", "plugin.json"),
118
+ requiresRuntime: true,
62
119
  },
63
120
  codex: {
64
121
  marketplaceRelative: join(".agents", "plugins", "marketplace.json"),
65
122
  pluginRelative: join("build", "generated", "plugins", "codex"),
66
123
  manifestRelative: join(".codex-plugin", "plugin.json"),
124
+ requiresRuntime: true,
125
+ },
126
+ cursor: {
127
+ marketplaceRelative: null,
128
+ pluginRelative: ".",
129
+ manifestRelative: "plugin.json",
130
+ requiresRuntime: false,
131
+ },
132
+ grok: {
133
+ marketplaceRelative: null,
134
+ pluginRelative: ".",
135
+ manifestRelative: "plugin.json",
136
+ requiresRuntime: false,
137
+ },
138
+ opencode: {
139
+ marketplaceRelative: null,
140
+ pluginRelative: ".",
141
+ manifestRelative: "opencode-plugin.json",
142
+ requiresRuntime: false,
67
143
  },
68
144
  });
69
145
  const MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024 * 1024;
@@ -76,6 +152,1057 @@ const AUTO_UPDATE_MAX_INTERVAL_HOURS = 24 * 7;
76
152
  const AUTO_UPDATE_DEFAULT_INTERVAL_HOURS = 24;
77
153
  const AUTO_UPDATE_POLL_SECONDS = 60 * 60;
78
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
+ }
79
1206
 
80
1207
  export class InstallerError extends Error {}
81
1208
 
@@ -96,10 +1223,29 @@ async function exists(target) {
96
1223
  }
97
1224
  }
98
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
+
99
1247
  export function statePaths() {
100
- const stateDirectory = resolve(
101
- process.env.OPENSOCRATES_STATE_DIR || join(homedir(), ".opensocrates"),
102
- );
1248
+ const stateDirectory = resolve(process.env.OPENSOCRATES_STATE_DIR || join(homedir(), ".opensocrates"));
103
1249
  const launchAgentsDirectory = resolve(
104
1250
  process.env.OPENSOCRATES_LAUNCH_AGENTS_DIR || join(homedir(), "Library", "LaunchAgents"),
105
1251
  );
@@ -135,12 +1281,7 @@ function defaultDesiredState() {
135
1281
  }
136
1282
 
137
1283
  function normalizeDesiredState(value) {
138
- if (
139
- value === null ||
140
- typeof value !== "object" ||
141
- Array.isArray(value) ||
142
- value.schema !== DESIRED_STATE_SCHEMA
143
- ) {
1284
+ if (value === null || typeof value !== "object" || Array.isArray(value) || value.schema !== DESIRED_STATE_SCHEMA) {
144
1285
  fail("the OpenSocrates desired-state manifest has an unsupported schema");
145
1286
  }
146
1287
  const installedHosts = value.installedHosts;
@@ -166,16 +1307,10 @@ function normalizeDesiredState(value) {
166
1307
  fail("the OpenSocrates desired-state manifest has an invalid update policy");
167
1308
  }
168
1309
  const autoUpdateHosts =
169
- value.autoUpdate.hosts === undefined
170
- ? value.autoUpdate.enabled
171
- ? installedHosts
172
- : []
173
- : value.autoUpdate.hosts;
1310
+ value.autoUpdate.hosts === undefined ? (value.autoUpdate.enabled ? installedHosts : []) : value.autoUpdate.hosts;
174
1311
  if (
175
1312
  !Array.isArray(autoUpdateHosts) ||
176
- autoUpdateHosts.some(
177
- (host) => !SUPPORTED_HOSTS.includes(host) || !installedHosts.includes(host),
178
- ) ||
1313
+ autoUpdateHosts.some((host) => !SUPPORTED_HOSTS.includes(host) || !installedHosts.includes(host)) ||
179
1314
  new Set(autoUpdateHosts).size !== autoUpdateHosts.length ||
180
1315
  (value.autoUpdate.enabled && autoUpdateHosts.length === 0) ||
181
1316
  (!value.autoUpdate.enabled && autoUpdateHosts.length > 0)
@@ -200,9 +1335,10 @@ function normalizeDesiredState(value) {
200
1335
 
201
1336
  export async function readDesiredState() {
202
1337
  const { desiredState } = statePaths();
203
- if (!(await exists(desiredState))) {
1338
+ if (!(await entryExists(desiredState))) {
204
1339
  return defaultDesiredState();
205
1340
  }
1341
+ await requireRegularFileEntry(desiredState, "OpenSocrates desired-state file");
206
1342
  let value;
207
1343
  try {
208
1344
  value = JSON.parse(await readFile(desiredState, "utf8"));
@@ -234,7 +1370,11 @@ async function atomicWritePrivateFile(target, contents) {
234
1370
  await ensurePrivateDirectory(parent);
235
1371
  const temporary = join(parent, `.${basename(target)}.${randomUUID()}.tmp`);
236
1372
  try {
237
- await writeFile(temporary, contents, { encoding: "utf8", mode: 0o600, flag: "wx" });
1373
+ await writeFile(temporary, contents, {
1374
+ encoding: "utf8",
1375
+ mode: 0o600,
1376
+ flag: "wx",
1377
+ });
238
1378
  await chmod(temporary, 0o600);
239
1379
  await rename(temporary, target);
240
1380
  } finally {
@@ -246,10 +1386,7 @@ async function atomicWritePrivateFile(target, contents) {
246
1386
 
247
1387
  export async function writeDesiredState(value) {
248
1388
  const normalized = normalizeDesiredState(value);
249
- await atomicWritePrivateFile(
250
- statePaths().desiredState,
251
- `${JSON.stringify(normalized, null, 2)}\n`,
252
- );
1389
+ await atomicWritePrivateFile(statePaths().desiredState, `${JSON.stringify(normalized, null, 2)}\n`);
253
1390
  return normalized;
254
1391
  }
255
1392
 
@@ -260,14 +1397,14 @@ async function writeAutoUpdateReceipt({ version, checkedAt, hosts, result, error
260
1397
  checkedAt,
261
1398
  hosts: [...hosts]
262
1399
  .sort((left, right) => left.host.localeCompare(right.host))
263
- .map(({ host, result: hostResult }) => ({ host, result: hostResult })),
1400
+ .map(({ host, result: hostResult }) => ({
1401
+ host,
1402
+ result: hostResult,
1403
+ })),
264
1404
  result,
265
1405
  errorCategory: errorCategory ?? null,
266
1406
  };
267
- await atomicWritePrivateFile(
268
- statePaths().receipt,
269
- `${JSON.stringify(receipt, null, 2)}\n`,
270
- );
1407
+ await atomicWritePrivateFile(statePaths().receipt, `${JSON.stringify(receipt, null, 2)}\n`);
271
1408
  }
272
1409
 
273
1410
  function nowIso() {
@@ -303,10 +1440,7 @@ async function acquireOperationLock() {
303
1440
  try {
304
1441
  const handle = await open(paths.lock, "wx", 0o600);
305
1442
  try {
306
- await handle.writeFile(
307
- `${JSON.stringify({ pid: process.pid, startedAt: nowIso() })}\n`,
308
- "utf8",
309
- );
1443
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid, startedAt: nowIso() })}\n`, "utf8");
310
1444
  } catch (writeError) {
311
1445
  await handle.close().catch(() => undefined);
312
1446
  await rm(paths.lock, { force: true }).catch(() => undefined);
@@ -338,9 +1472,7 @@ async function acquireOperationLock() {
338
1472
  }
339
1473
  stale =
340
1474
  ownerAlive === false ||
341
- (ownerAlive === null &&
342
- Number.isFinite(started) &&
343
- Date.now() - started > LOCK_STALE_MILLISECONDS);
1475
+ (ownerAlive === null && Number.isFinite(started) && Date.now() - started > LOCK_STALE_MILLISECONDS);
344
1476
  } catch {
345
1477
  const info = await lstat(paths.lock);
346
1478
  stale = Date.now() - info.mtimeMs > LOCK_STALE_MILLISECONDS;
@@ -372,17 +1504,44 @@ function jsonEqual(left, right) {
372
1504
  );
373
1505
  }
374
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
+
375
1535
  function markerFor(host) {
1536
+ if (host === "cursor") return CURSOR_MARKER;
1537
+ if (host === "antigravity") return ANTIGRAVITY_MARKER;
1538
+ if (host === "grok") return GROK_MARKER;
1539
+ if (host === "opencode") return OPENCODE_MARKER;
376
1540
  return host === "claude" ? CLAUDE_MARKER : CODEX_MARKER;
377
1541
  }
378
1542
 
379
1543
  export function markerMatches(value, host = DEFAULT_HOST) {
380
- return (
381
- value !== null &&
382
- typeof value === "object" &&
383
- !Array.isArray(value) &&
384
- jsonEqual(value, markerFor(host))
385
- );
1544
+ return value !== null && typeof value === "object" && !Array.isArray(value) && jsonEqual(value, markerFor(host));
386
1545
  }
387
1546
 
388
1547
  export function isSafeArchivePath(value) {
@@ -422,15 +1581,7 @@ export function parseChecksumText(text, expectedName = ASSET_NAME) {
422
1581
  }
423
1582
 
424
1583
  export function parseCli(argv) {
425
- const actions = new Set([
426
- "install",
427
- "status",
428
- "update",
429
- "remove",
430
- "verify",
431
- "auto-update",
432
- "help",
433
- ]);
1584
+ const actions = new Set(["install", "status", "update", "remove", "verify", "auto-update", "help"]);
434
1585
  const args = [...argv];
435
1586
  let action = "install";
436
1587
  if (args[0] && !args[0].startsWith("-")) {
@@ -457,14 +1608,13 @@ export function parseCli(argv) {
457
1608
  host: action === "auto-update" ? ALL_HOST : DEFAULT_HOST,
458
1609
  asset: null,
459
1610
  checksum: null,
460
- hostAssets: {
461
- claude: { asset: null, checksum: null },
462
- codex: { asset: null, checksum: null },
463
- },
1611
+ hostAssets: Object.fromEntries(SUPPORTED_HOSTS.map((host) => [host, { asset: null, checksum: null }])),
464
1612
  channel: "stable",
465
1613
  intervalHours: AUTO_UPDATE_DEFAULT_INTERVAL_HOURS,
466
1614
  allowMajor: false,
467
1615
  force: false,
1616
+ purge: false,
1617
+ resetTrust: false,
468
1618
  };
469
1619
  const seenOptions = new Set();
470
1620
  while (args.length > 0) {
@@ -492,6 +1642,16 @@ export function parseCli(argv) {
492
1642
  options.force = true;
493
1643
  continue;
494
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
+ }
495
1655
  if (flag === "--channel") {
496
1656
  seenOptions.add("channel");
497
1657
  const channel = args.shift();
@@ -518,7 +1678,7 @@ export function parseCli(argv) {
518
1678
  options.intervalHours = value;
519
1679
  continue;
520
1680
  }
521
- const qualified = flag?.match(/^--(asset|checksum)-(claude|codex)$/u);
1681
+ const qualified = flag?.match(new RegExp(`^--(asset|checksum)-(${SUPPORTED_HOSTS.join("|")})$`, "u"));
522
1682
  if (qualified) {
523
1683
  seenOptions.add(qualified[1]);
524
1684
  const value = args.shift();
@@ -550,17 +1710,7 @@ export function parseCli(argv) {
550
1710
  if (options.host === ALL_HOST && options.asset !== null) {
551
1711
  fail("--host all requires host-qualified --asset-<host> and --checksum-<host> options");
552
1712
  }
553
- if (
554
- options.host === ALL_HOST &&
555
- SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset !== null) &&
556
- SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset === null)
557
- ) {
558
- fail("local --host all verification requires asset and checksum pairs for both hosts");
559
- }
560
- if (
561
- options.host !== ALL_HOST &&
562
- SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset !== null)
563
- ) {
1713
+ if (options.host !== ALL_HOST && SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset !== null)) {
564
1714
  fail("host-qualified asset options are only valid with --host all");
565
1715
  }
566
1716
  if (["status", "remove", "help"].includes(options.action) && options.asset !== null) {
@@ -575,13 +1725,11 @@ export function parseCli(argv) {
575
1725
  if (
576
1726
  options.action === "auto-update" &&
577
1727
  options.autoUpdateAction !== "run" &&
578
- (options.asset !== null ||
579
- SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset !== null))
1728
+ (options.asset !== null || SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset !== null))
580
1729
  ) {
581
1730
  fail(`auto-update ${options.autoUpdateAction} does not accept local asset options`);
582
1731
  }
583
- const autoUpdateEnable =
584
- options.action === "auto-update" && options.autoUpdateAction === "enable";
1732
+ const autoUpdateEnable = options.action === "auto-update" && options.autoUpdateAction === "enable";
585
1733
  const autoUpdateRun = options.action === "auto-update" && options.autoUpdateAction === "run";
586
1734
  for (const policyOption of ["allow-major", "channel", "interval-hours"]) {
587
1735
  if (seenOptions.has(policyOption) && !autoUpdateEnable) {
@@ -591,6 +1739,15 @@ export function parseCli(argv) {
591
1739
  if (seenOptions.has("force") && !autoUpdateRun) {
592
1740
  fail("--force is only valid with auto-update run");
593
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
+ }
594
1751
  if (
595
1752
  seenOptions.has("host") &&
596
1753
  options.action === "auto-update" &&
@@ -608,12 +1765,12 @@ function showHelp() {
608
1765
  console.log(`OpenSocrates ${PRODUCT_VERSION}
609
1766
 
610
1767
  Usage:
611
- opensocrates install [--host all|codex|claude] [--asset ZIP --checksum SHA256]
612
- opensocrates status [--host all|codex|claude]
613
- opensocrates update [--host all|codex|claude] [--asset ZIP --checksum SHA256]
614
- opensocrates remove [--host all|codex|claude]
615
- opensocrates verify [--host all|codex|claude] [--asset ZIP --checksum SHA256]
616
- opensocrates auto-update enable [--host all|codex|claude]
1768
+ opensocrates install [--host all|antigravity|claude|codex|cursor|grok|opencode] [--asset ZIP --checksum FILE]
1769
+ opensocrates status [--host all|antigravity|claude|codex|cursor|grok|opencode]
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]
1773
+ opensocrates auto-update enable [--host all|antigravity|claude|codex|cursor|grok|opencode]
617
1774
  [--channel stable|next] [--interval-hours ${AUTO_UPDATE_DEFAULT_INTERVAL_HOURS}]
618
1775
  [--allow-major]
619
1776
  opensocrates auto-update status
@@ -621,17 +1778,45 @@ Usage:
621
1778
 
622
1779
  Without --asset, install, update, and verify download the v${PRODUCT_VERSION}
623
1780
  package and checksum from GitHub Releases. The default lifecycle host is codex.
624
- For offline --host all verification, use --asset-claude/--checksum-claude
625
- and --asset-codex/--checksum-codex together. Automatic updates are opt-in.
1781
+ With --host all, supplied host-qualified asset/checksum pairs define the exact
1782
+ transaction set and are never mixed with downloads for other hosts. Without
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.
626
1793
  `);
627
1794
  }
628
1795
 
629
1796
  function managedPaths(host) {
630
1797
  const configured =
631
- host === "claude" ? process.env.CLAUDE_CONFIG_DIR : process.env.CODEX_HOME;
632
- const configuredHome = resolve(
633
- configured ? configured : join(homedir(), host === "claude" ? ".claude" : ".codex"),
634
- );
1798
+ host === "antigravity"
1799
+ ? process.env.ANTIGRAVITY_CONFIG_DIR
1800
+ : host === "cursor"
1801
+ ? process.env.CURSOR_CONFIG_DIR
1802
+ : host === "grok"
1803
+ ? process.env.GROK_HOME
1804
+ : host === "opencode"
1805
+ ? process.env.OPENCODE_CONFIG_DIR
1806
+ : host === "claude"
1807
+ ? process.env.CLAUDE_CONFIG_DIR
1808
+ : process.env.CODEX_HOME;
1809
+ const defaultHome =
1810
+ host === "antigravity"
1811
+ ? join(homedir(), ".gemini", "config")
1812
+ : host === "cursor"
1813
+ ? join(homedir(), ".cursor")
1814
+ : host === "grok"
1815
+ ? join(homedir(), ".grok")
1816
+ : host === "opencode"
1817
+ ? join(homedir(), ".config", "opencode")
1818
+ : join(homedir(), host === "claude" ? ".claude" : ".codex");
1819
+ const configuredHome = resolve(configured ? configured : defaultHome);
635
1820
  let hostHome = configuredHome;
636
1821
  try {
637
1822
  hostHome = realpathSync(configuredHome);
@@ -640,18 +1825,68 @@ function managedPaths(host) {
640
1825
  // explicit, non-recursive target. Lexical resolution is the only
641
1826
  // available normalization until then.
642
1827
  }
643
- const root = join(hostHome, "managed-marketplaces", MARKETPLACE_NAME);
1828
+ const root =
1829
+ host === "antigravity"
1830
+ ? join(hostHome, "plugins", PLUGIN_NAME)
1831
+ : host === "cursor"
1832
+ ? join(hostHome, "plugins", "local", PLUGIN_NAME)
1833
+ : host === "grok"
1834
+ ? join(hostHome, "plugins", PLUGIN_NAME)
1835
+ : host === "opencode"
1836
+ ? join(hostHome, "skills", PLUGIN_NAME)
1837
+ : join(hostHome, "managed-marketplaces", MARKETPLACE_NAME);
644
1838
  const layout = HOST_LAYOUTS[host];
645
1839
  return {
646
1840
  hostHome,
647
1841
  root,
648
1842
  parent: dirname(root),
649
1843
  marker: join(root, MARKER_NAME),
650
- marketplace: join(root, layout.marketplaceRelative),
1844
+ marketplace: typeof layout.marketplaceRelative === "string" ? join(root, layout.marketplaceRelative) : null,
651
1845
  plugin: join(root, layout.pluginRelative),
1846
+ bridge: host === "opencode" ? join(hostHome, "plugins", "opensocrates.js") : null,
1847
+ bridgeMarker: host === "opencode" ? join(hostHome, "plugins", OPENCODE_BRIDGE_MARKER) : null,
1848
+ bridgeParent: host === "opencode" ? join(hostHome, "plugins") : null,
652
1849
  };
653
1850
  }
654
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
+
1874
+ // Directory that holds staging trees and rollback backups for one host.
1875
+ //
1876
+ // Grok scans every directory below ~/.grok/plugins, including dot-prefixed
1877
+ // ones, so a staging tree or rollback backup left there would be discovered as
1878
+ // a second OpenSocrates plugin. An interrupted install must not be able to
1879
+ // leave a shadow copy inside Grok's plugin discovery root, so both transient
1880
+ // directories live in the Grok home instead, next to the scanned directory.
1881
+ function transientParent(host, paths) {
1882
+ return host === "grok" ? paths.hostHome : paths.parent;
1883
+ }
1884
+
1885
+ export function transientPathsFor(host) {
1886
+ const paths = managedPaths(host);
1887
+ return { root: paths.root, parent: paths.parent, transient: transientParent(host, paths) };
1888
+ }
1889
+
655
1890
  function codexBinary() {
656
1891
  return process.env.CODEX_BIN || "codex";
657
1892
  }
@@ -660,6 +1895,26 @@ function claudeBinary() {
660
1895
  return process.env.CLAUDE_BIN || "claude";
661
1896
  }
662
1897
 
1898
+ function cursorBinary() {
1899
+ return process.env.CURSOR_BIN || "cursor";
1900
+ }
1901
+
1902
+ function cursorAppPaths() {
1903
+ return ["/Applications/Cursor.app", join(homedir(), "Applications", "Cursor.app")];
1904
+ }
1905
+
1906
+ function antigravityBinary() {
1907
+ return process.env.AGY_BIN || "agy";
1908
+ }
1909
+
1910
+ function grokBinary() {
1911
+ return process.env.GROK_BIN || "grok";
1912
+ }
1913
+
1914
+ function opencodeBinary() {
1915
+ return process.env.OPENCODE_BIN || "opencode";
1916
+ }
1917
+
663
1918
  function run(command, args, { allowFailure = false } = {}) {
664
1919
  const result = spawnSync(command, args, {
665
1920
  encoding: "utf8",
@@ -707,6 +1962,10 @@ function runClaudeJson(args) {
707
1962
  return runJson(claudeBinary(), args, "Claude Code");
708
1963
  }
709
1964
 
1965
+ function runGrokJson(args) {
1966
+ return runJson(grokBinary(), args, "Grok Build");
1967
+ }
1968
+
710
1969
  function claudeListEntries(payload, wrapper, label) {
711
1970
  if (Array.isArray(payload)) {
712
1971
  return payload;
@@ -752,11 +2011,7 @@ function claudeMarketplaceEntries(payload) {
752
2011
  }
753
2012
 
754
2013
  function claudePluginEntries(payload) {
755
- return requireUniqueClaudeEntries(
756
- claudeListEntries(payload, "plugins", "plugin list"),
757
- "id",
758
- "plugin list",
759
- );
2014
+ return requireUniqueClaudeEntries(claudeListEntries(payload, "plugins", "plugin list"), "id", "plugin list");
760
2015
  }
761
2016
 
762
2017
  function versionAtLeast(value, minimum) {
@@ -773,12 +2028,63 @@ function versionAtLeast(value, minimum) {
773
2028
  }
774
2029
 
775
2030
  function requireHostCli(host, { authenticated = false } = {}) {
2031
+ if (host === "grok") {
2032
+ const result = run(grokBinary(), ["--version"]);
2033
+ if (!versionAtLeast(result.stdout, [1, 0, 3])) {
2034
+ fail(`Grok Build 1.0.3 or later is required; got ${result.stdout.trim()}`);
2035
+ }
2036
+ return;
2037
+ }
2038
+ if (host === "cursor") {
2039
+ if (process.env.CURSOR_CONFIG_DIR) return;
2040
+ if (process.env.CURSOR_BIN) {
2041
+ const result = run(cursorBinary(), ["--version"]);
2042
+ if (!versionAtLeast(result.stdout, [2, 5, 0])) {
2043
+ fail(`Cursor 2.5.0 or later is required; got ${result.stdout.trim()}`);
2044
+ }
2045
+ return;
2046
+ }
2047
+ const app = cursorAppPaths().find((candidate) => existsSync(candidate));
2048
+ if (app) {
2049
+ const result = run(
2050
+ "/usr/bin/plutil",
2051
+ ["-extract", "CFBundleShortVersionString", "raw", join(app, "Contents", "Info.plist")],
2052
+ { allowFailure: true },
2053
+ );
2054
+ if (result.status !== 0 || !versionAtLeast(result.stdout, [2, 5, 0])) {
2055
+ fail("Cursor 2.5.0 or later is required for Agent Plugin support");
2056
+ }
2057
+ return;
2058
+ }
2059
+ const result = run(cursorBinary(), ["--version"]);
2060
+ if (!versionAtLeast(result.stdout, [2, 5, 0])) {
2061
+ fail(`Cursor 2.5.0 or later is required; got ${result.stdout.trim()}`);
2062
+ }
2063
+ return;
2064
+ }
2065
+ if (host === "antigravity") {
2066
+ if (process.env.ANTIGRAVITY_CONFIG_DIR) return;
2067
+ run(antigravityBinary(), ["--version"]);
2068
+ return;
2069
+ }
2070
+ if (host === "opencode") {
2071
+ // OPENCODE_CONFIG_DIR only redirects where the managed files are written;
2072
+ // it does not waive the supported-host gate. Unlike the content-only
2073
+ // file-drop hosts above, OpenCode receives an executing bridge bound to the
2074
+ // stable chat.message contract, and the shipped package declares
2075
+ // minimum_opencode_version 1.18.18 with a fail-closed <2.0.0 ceiling.
2076
+ // Installing that bridge without confirming the host version would ship
2077
+ // executable code against an unverified plugin API.
2078
+ const result = run(opencodeBinary(), ["--version"]);
2079
+ if (!versionAtLeast(result.stdout, [1, 18, 18]) || versionAtLeast(result.stdout, [2, 0, 0])) {
2080
+ fail(`OpenCode >=1.18.18 and <2.0.0 is required; got ${result.stdout.trim()}`);
2081
+ }
2082
+ return;
2083
+ }
776
2084
  if (host === "claude") {
777
2085
  const result = run(claudeBinary(), ["--version"]);
778
2086
  if (!versionAtLeast(result.stdout, [2, 1, 205])) {
779
- fail(
780
- `Claude Code 2.1.205 or later is required for structured selector output; got ${result.stdout.trim()}`,
781
- );
2087
+ fail(`Claude Code 2.1.205 or later is required for structured selector output; got ${result.stdout.trim()}`);
782
2088
  }
783
2089
  if (authenticated) {
784
2090
  run(claudeBinary(), ["auth", "status"]);
@@ -792,6 +2098,12 @@ function requireHostCli(host, { authenticated = false } = {}) {
792
2098
  }
793
2099
 
794
2100
  function marketplaceEntries(host) {
2101
+ if (FILE_DROP_HOSTS.includes(host) || host === "opencode") {
2102
+ const paths = managedPaths(host);
2103
+ const present =
2104
+ existsSync(paths.root) || (host === "opencode" && (existsSync(paths.bridge) || existsSync(paths.bridgeMarker)));
2105
+ return present ? [{ name: MARKETPLACE_NAME, root: paths.root }] : [];
2106
+ }
795
2107
  const payload =
796
2108
  host === "claude"
797
2109
  ? runClaudeJson(["plugin", "marketplace", "list", "--json"])
@@ -812,7 +2124,124 @@ function marketplaceEntry(host) {
812
2124
  return matches[0] ?? null;
813
2125
  }
814
2126
 
815
- function pluginState(host) {
2127
+ // Read Grok Build's machine-readable plugin state.
2128
+ //
2129
+ // The managed Grok files are host-independent content, so inspecting or
2130
+ // removing what the installer owns must not depend on a runnable `grok`
2131
+ // command: a user who uninstalls Grok Build still has to be able to clean up.
2132
+ // Transactional callers pass requireHostState so that install and update keep
2133
+ // confirming activation against the host itself.
2134
+ function grokInspection(requireHostState) {
2135
+ if (requireHostState) return runGrokJson(["inspect", "--json"]);
2136
+ const result = run(grokBinary(), ["inspect", "--json"], { allowFailure: true });
2137
+ if (result.error || result.status !== 0) {
2138
+ const detail = result.error?.message || result.stderr?.trim() || `exit ${result.status}`;
2139
+ console.warn(
2140
+ `warning: could not read Grok Build plugin state (${detail}); ` +
2141
+ "reporting the managed files only",
2142
+ );
2143
+ return null;
2144
+ }
2145
+ try {
2146
+ const payload = JSON.parse(result.stdout);
2147
+ if (payload === null || typeof payload !== "object") {
2148
+ fail("Grok Build returned a non-container JSON value");
2149
+ }
2150
+ return payload;
2151
+ } catch (error) {
2152
+ if (error instanceof InstallerError) throw error;
2153
+ fail(`Grok Build returned invalid JSON for inspect --json: ${error.message}`);
2154
+ }
2155
+ }
2156
+
2157
+ function pluginState(host, { requireHostState = false } = {}) {
2158
+ if (FILE_DROP_HOSTS.includes(host)) {
2159
+ const paths = managedPaths(host);
2160
+ if (!existsSync(paths.root)) return { kind: "missing", version: null };
2161
+ let manifest;
2162
+ try {
2163
+ manifest = JSON.parse(readFileSync(join(paths.root, "plugin.json"), "utf8"));
2164
+ } catch (error) {
2165
+ fail(`${host} plugin manifest is unreadable: ${error.message}`);
2166
+ }
2167
+ if (
2168
+ manifest === null ||
2169
+ typeof manifest !== "object" ||
2170
+ Array.isArray(manifest) ||
2171
+ (host === "cursor" &&
2172
+ manifest.$schema !== "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json") ||
2173
+ (host === "grok" && manifest.skills !== "./skills") ||
2174
+ manifest.name !== PLUGIN_NAME ||
2175
+ typeof manifest.version !== "string" ||
2176
+ manifest.version.trim().length === 0
2177
+ ) {
2178
+ fail(`${host} reported an invalid OpenSocrates plugin manifest`);
2179
+ }
2180
+ if (host !== "grok") return { kind: "installed", version: manifest.version };
2181
+ const payload = grokInspection(requireHostState);
2182
+ if (payload === null) return { kind: "installed", version: manifest.version };
2183
+ if (!Array.isArray(payload.plugins)) {
2184
+ fail("Grok Build inspect returned an unexpected plugin schema");
2185
+ }
2186
+ const matches = payload.plugins.filter((entry) => entry?.name === PLUGIN_NAME);
2187
+ if (matches.length !== 1) {
2188
+ fail(
2189
+ matches.length === 0
2190
+ ? "Grok Build did not discover the managed OpenSocrates plugin"
2191
+ : "Grok Build reported duplicate OpenSocrates plugins",
2192
+ );
2193
+ }
2194
+ const entry = matches[0];
2195
+ if (
2196
+ typeof entry.enabled !== "boolean" ||
2197
+ typeof entry.path !== "string" ||
2198
+ entry.provides?.skills !== 1 ||
2199
+ entry.provides?.agents !== 0 ||
2200
+ entry.provides?.hooks !== false ||
2201
+ entry.provides?.mcpServers !== 0
2202
+ ) {
2203
+ fail("Grok Build reported an invalid OpenSocrates plugin state");
2204
+ }
2205
+ let observedPath = resolve(entry.path);
2206
+ try {
2207
+ observedPath = realpathSync(observedPath);
2208
+ } catch {
2209
+ // The managed root exists here, so a missing reported path is invalid below.
2210
+ }
2211
+ let expectedPath = resolve(paths.root);
2212
+ try {
2213
+ expectedPath = realpathSync(expectedPath);
2214
+ } catch {
2215
+ // Ownership checks report a more specific error for a missing root.
2216
+ }
2217
+ if (observedPath !== expectedPath) {
2218
+ fail(`Grok Build resolved OpenSocrates from an unmanaged location: ${observedPath}`);
2219
+ }
2220
+ return { kind: entry.enabled ? "installed" : "disabled", version: manifest.version };
2221
+ }
2222
+ if (host === "opencode") {
2223
+ const paths = managedPaths(host);
2224
+ if (!existsSync(paths.root) && !existsSync(paths.bridge) && !existsSync(paths.bridgeMarker)) {
2225
+ return { kind: "missing", version: null };
2226
+ }
2227
+ let manifest;
2228
+ try {
2229
+ manifest = JSON.parse(readFileSync(join(paths.root, ".opensocrates-package", "opencode-plugin.json"), "utf8"));
2230
+ } catch (error) {
2231
+ fail(`opencode installed manifest is unreadable: ${error.message}`);
2232
+ }
2233
+ if (
2234
+ manifest === null ||
2235
+ typeof manifest !== "object" ||
2236
+ Array.isArray(manifest) ||
2237
+ manifest.name !== PLUGIN_NAME ||
2238
+ typeof manifest.version !== "string" ||
2239
+ manifest.version.trim().length === 0
2240
+ ) {
2241
+ fail("opencode reported an invalid OpenSocrates installation manifest");
2242
+ }
2243
+ return { kind: "installed", version: manifest.version };
2244
+ }
816
2245
  if (host === "claude") {
817
2246
  const installed = claudePluginEntries(runClaudeJson(["plugin", "list", "--json"]));
818
2247
  const matches = installed.filter((entry) => entry?.id === PLUGIN_ID);
@@ -831,9 +2260,7 @@ function pluginState(host) {
831
2260
  version: entry.version ?? null,
832
2261
  };
833
2262
  }
834
- const payload = runCodexJson([
835
- "plugin", "list", "--marketplace", MARKETPLACE_NAME, "--available", "--json",
836
- ]);
2263
+ const payload = runCodexJson(["plugin", "list", "--marketplace", MARKETPLACE_NAME, "--available", "--json"]);
837
2264
  const installed = Array.isArray(payload.installed) ? payload.installed : null;
838
2265
  const available = Array.isArray(payload.available) ? payload.available : null;
839
2266
  if (installed === null || available === null) {
@@ -845,10 +2272,16 @@ function pluginState(host) {
845
2272
  fail(`Codex reported duplicate entries for ${PLUGIN_ID}`);
846
2273
  }
847
2274
  if (installedMatches.length === 1) {
848
- return { kind: "installed", version: installedMatches[0].version ?? null };
2275
+ return {
2276
+ kind: "installed",
2277
+ version: installedMatches[0].version ?? null,
2278
+ };
849
2279
  }
850
2280
  if (availableMatches.length === 1) {
851
- return { kind: "available", version: availableMatches[0].version ?? null };
2281
+ return {
2282
+ kind: "available",
2283
+ version: availableMatches[0].version ?? null,
2284
+ };
852
2285
  }
853
2286
  return { kind: "missing", version: null };
854
2287
  }
@@ -861,13 +2294,12 @@ function detectLegacyClaudeInstallation() {
861
2294
  entry.name.toLowerCase() === MARKETPLACE_NAME,
862
2295
  );
863
2296
  const plugins = claudePluginEntries(runClaudeJson(["plugin", "list", "--json"]));
864
- const legacyPlugins = plugins.filter(
865
- (entry) =>
866
- entry.id !== PLUGIN_ID &&
867
- entry.id.toLowerCase() === PLUGIN_ID,
868
- );
2297
+ const legacyPlugins = plugins.filter((entry) => entry.id !== PLUGIN_ID && entry.id.toLowerCase() === PLUGIN_ID);
869
2298
  const names = marketplaces.map((entry) => entry.name).join(", ") || "OpenSocrates";
870
- return { found: marketplaces.length > 0 || legacyPlugins.length > 0, names };
2299
+ return {
2300
+ found: marketplaces.length > 0 || legacyPlugins.length > 0,
2301
+ names,
2302
+ };
871
2303
  }
872
2304
 
873
2305
  // Install and update write into the managed root, so a case-variant pre-1.0
@@ -918,18 +2350,99 @@ async function requireOwnedRoot(root, host) {
918
2350
  if (!info.isDirectory() || info.isSymbolicLink()) {
919
2351
  fail(`managed marketplace path is not an owned directory: ${root}`);
920
2352
  }
921
- 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);
922
2356
  if (!markerMatches(marker, host)) {
923
2357
  fail(`managed marketplace path has no valid ownership marker: ${root}`);
924
2358
  }
925
2359
  }
926
2360
 
927
- async function sha256File(target) {
928
- const hash = createHash("sha256");
929
- for await (const chunk of createReadStream(target)) {
930
- hash.update(chunk);
2361
+ async function sha256File(target) {
2362
+ const hash = createHash("sha256");
2363
+ for await (const chunk of createReadStream(target)) {
2364
+ hash.update(chunk);
2365
+ }
2366
+ return hash.digest("hex");
2367
+ }
2368
+
2369
+ function openCodeSidecar(bridgeSha256) {
2370
+ return {
2371
+ ...OPENCODE_MARKER,
2372
+ version: PRODUCT_VERSION,
2373
+ bridgeSha256,
2374
+ };
2375
+ }
2376
+
2377
+ async function ensureSafeDirectory(target, label) {
2378
+ if (await exists(target)) {
2379
+ const info = await lstat(target);
2380
+ if (!info.isDirectory() || info.isSymbolicLink()) {
2381
+ fail(`refusing to use an unsafe ${label} directory: ${target}`);
2382
+ }
2383
+ return;
2384
+ }
2385
+ await mkdir(target, { recursive: true, mode: 0o700 });
2386
+ const info = await lstat(target);
2387
+ if (!info.isDirectory() || info.isSymbolicLink()) {
2388
+ fail(`refusing to use an unsafe ${label} directory: ${target}`);
2389
+ }
2390
+ }
2391
+
2392
+ async function ensureOpenCodeDirectories(paths) {
2393
+ await ensureSafeDirectory(paths.hostHome, "OpenCode configuration");
2394
+ await ensureSafeDirectory(paths.parent, "OpenCode skills");
2395
+ await ensureSafeDirectory(paths.bridgeParent, "OpenCode plugins");
2396
+ }
2397
+
2398
+ async function verifyOpenCodeInstallation(paths) {
2399
+ await requireOwnedRoot(paths.root, "opencode");
2400
+ for (const [target, label] of [
2401
+ [paths.bridge, "plugin bridge"],
2402
+ [paths.bridgeMarker, "plugin ownership sidecar"],
2403
+ ]) {
2404
+ if (!(await exists(target))) fail(`OpenCode ${label} is missing: ${target}`);
2405
+ const info = await lstat(target);
2406
+ if (!info.isFile() || info.isSymbolicLink()) {
2407
+ fail(`OpenCode ${label} is not an owned regular file: ${target}`);
2408
+ }
931
2409
  }
932
- return hash.digest("hex");
2410
+ const sidecar = await readJsonObject(paths.bridgeMarker);
2411
+ const sidecarMarker = Object.fromEntries(Object.keys(OPENCODE_MARKER).map((key) => [key, sidecar[key]]));
2412
+ if (
2413
+ !markerMatches(sidecarMarker, "opencode") ||
2414
+ sidecar.version !== PRODUCT_VERSION ||
2415
+ typeof sidecar.bridgeSha256 !== "string" ||
2416
+ sidecar.bridgeSha256 !== (await sha256File(paths.bridge))
2417
+ ) {
2418
+ fail("OpenCode plugin bridge has an invalid ownership sidecar or checksum");
2419
+ }
2420
+ const manifest = await readJsonObject(join(paths.root, OPENCODE_INSTALL_MANIFEST));
2421
+ if (
2422
+ manifest.schema !== "opensocrates.opencode-installation/1.0.0" ||
2423
+ manifest.version !== PRODUCT_VERSION ||
2424
+ manifest.bridgeSha256 !== sidecar.bridgeSha256 ||
2425
+ manifest.files === null ||
2426
+ typeof manifest.files !== "object" ||
2427
+ Array.isArray(manifest.files)
2428
+ ) {
2429
+ fail("OpenCode installation inventory is invalid");
2430
+ }
2431
+ const actualFiles = new Set(await walkFiles(paths.root));
2432
+ actualFiles.delete(OPENCODE_INSTALL_MANIFEST);
2433
+ const declaredFiles = Object.keys(manifest.files);
2434
+ if (
2435
+ actualFiles.size !== declaredFiles.length ||
2436
+ declaredFiles.some((item) => !isSafeArchivePath(item) || !actualFiles.has(item))
2437
+ ) {
2438
+ fail("OpenCode installation inventory does not cover the complete managed skill");
2439
+ }
2440
+ for (const item of declaredFiles) {
2441
+ if (manifest.files[item] !== (await sha256File(join(paths.root, ...item.split("/"))))) {
2442
+ fail(`OpenCode installed file checksum mismatch for ${item}`);
2443
+ }
2444
+ }
2445
+ return manifest;
933
2446
  }
934
2447
 
935
2448
  async function downloadFile(url, destination) {
@@ -962,6 +2475,11 @@ function localAssetInputs(options, host) {
962
2475
  return { asset: options.asset, checksum: options.checksum };
963
2476
  }
964
2477
 
2478
+ function qualifiedAssetHosts(options) {
2479
+ if (options.host !== ALL_HOST) return [];
2480
+ return SUPPORTED_HOSTS.filter((host) => options.hostAssets[host].asset !== null);
2481
+ }
2482
+
965
2483
  async function resolveAssetInputs(options, scratch, host) {
966
2484
  const local = localAssetInputs(options, host);
967
2485
  if (local.asset !== null) {
@@ -1063,10 +2581,7 @@ async function verifyPackageChecksums(pluginRoot) {
1063
2581
  }
1064
2582
  }
1065
2583
  const actualFiles = new Set((await walkFiles(pluginRoot)).filter((item) => item !== "checksums.sha256"));
1066
- if (
1067
- actualFiles.size !== declared.size ||
1068
- [...actualFiles].some((item) => !declared.has(item))
1069
- ) {
2584
+ if (actualFiles.size !== declared.size || [...actualFiles].some((item) => !declared.has(item))) {
1070
2585
  fail("package checksum manifest does not cover the complete archive");
1071
2586
  }
1072
2587
  return declared.size;
@@ -1088,20 +2603,508 @@ async function verifyExtractedPackage(pluginRoot, host) {
1088
2603
  ) {
1089
2604
  fail("package release manifest does not match this installer");
1090
2605
  }
1091
- const runtime = join(
1092
- pluginRoot,
1093
- "runtime",
1094
- "darwin-arm64",
1095
- "opensocrates-runtime",
1096
- "opensocrates-runtime",
1097
- );
1098
- const runtimeInfo = await stat(runtime);
1099
- if (!runtimeInfo.isFile() || (runtimeInfo.mode & 0o111) === 0) {
1100
- fail("package is missing the executable darwin-arm64 runtime");
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
+ }
1101
2636
  }
1102
2637
  return verifyPackageChecksums(pluginRoot);
1103
2638
  }
1104
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]]));
3026
+ if (
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)
3033
+ ) {
3034
+ fail(`OpenCode bridge ownership sidecar is invalid: ${target}`);
3035
+ }
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");
3055
+ }
3056
+ await rm(paths.bridge);
3057
+ }
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;
3096
+ }
3097
+ await rm(marker);
3098
+ removed += 1;
3099
+ }
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
+ };
3106
+ }
3107
+
1105
3108
  async function prepareVerifiedPackage(options, host = options.host) {
1106
3109
  if (!SUPPORTED_HOSTS.includes(host)) {
1107
3110
  fail(`cannot prepare a package for unsupported host ${JSON.stringify(host)}`);
@@ -1121,17 +3124,11 @@ async function prepareVerifiedPackage(options, host = options.host) {
1121
3124
  }
1122
3125
 
1123
3126
  async function prepareVerifiedPackages(options, hosts) {
1124
- const settled = await Promise.allSettled(
1125
- hosts.map((host) => prepareVerifiedPackage(options, host)),
1126
- );
1127
- const prepared = settled
1128
- .filter((result) => result.status === "fulfilled")
1129
- .map((result) => result.value);
3127
+ const settled = await Promise.allSettled(hosts.map((host) => prepareVerifiedPackage(options, host)));
3128
+ const prepared = settled.filter((result) => result.status === "fulfilled").map((result) => result.value);
1130
3129
  const failure = settled.find((result) => result.status === "rejected");
1131
3130
  if (failure) {
1132
- await Promise.all(
1133
- prepared.map((item) => rm(item.scratch, { recursive: true, force: true })),
1134
- );
3131
+ await Promise.all(prepared.map((item) => rm(item.scratch, { recursive: true, force: true })));
1135
3132
  throw failure.reason;
1136
3133
  }
1137
3134
  return prepared;
@@ -1150,8 +3147,7 @@ function expectedMarketplace(host) {
1150
3147
  {
1151
3148
  name: PLUGIN_NAME,
1152
3149
  source: `./plugins/${PLUGIN_NAME}`,
1153
- description:
1154
- "Local reasoning-system selection for Claude Code and Cowork, plus one /opensocrates entry.",
3150
+ description: "Local reasoning-system selection for Claude Code and Cowork, plus one /opensocrates entry.",
1155
3151
  category: "workflow",
1156
3152
  },
1157
3153
  ],
@@ -1181,11 +3177,23 @@ async function buildStagingTree(parent, pluginSource, host) {
1181
3177
  const layout = HOST_LAYOUTS[host];
1182
3178
  const staging = await mkdtemp(join(parent, ".opensocrates.staging-"));
1183
3179
  try {
3180
+ if (FILE_DROP_HOSTS.includes(host)) {
3181
+ await cp(pluginSource, staging, { recursive: true, preserveTimestamps: true });
3182
+ await verifyExtractedPackage(staging, host);
3183
+ await writeFile(join(staging, MARKER_NAME), `${JSON.stringify(markerFor(host), null, 2)}\n`, {
3184
+ encoding: "utf8",
3185
+ mode: 0o600,
3186
+ });
3187
+ return staging;
3188
+ }
1184
3189
  const marketplace = join(staging, layout.marketplaceRelative);
1185
3190
  const plugin = join(staging, layout.pluginRelative);
1186
3191
  await mkdir(dirname(marketplace), { recursive: true, mode: 0o700 });
1187
3192
  await mkdir(dirname(plugin), { recursive: true, mode: 0o700 });
1188
- await cp(pluginSource, plugin, { recursive: true, preserveTimestamps: true });
3193
+ await cp(pluginSource, plugin, {
3194
+ recursive: true,
3195
+ preserveTimestamps: true,
3196
+ });
1189
3197
  await writeFile(marketplace, `${JSON.stringify(expectedMarketplace(host), null, 2)}\n`, {
1190
3198
  encoding: "utf8",
1191
3199
  mode: 0o600,
@@ -1202,20 +3210,72 @@ async function buildStagingTree(parent, pluginSource, host) {
1202
3210
  }
1203
3211
  }
1204
3212
 
3213
+ async function buildOpenCodeStaging(paths, pluginSource) {
3214
+ await ensureOpenCodeDirectories(paths);
3215
+ const root = await mkdtemp(join(paths.parent, ".opensocrates.staging-"));
3216
+ const bridge = join(paths.bridgeParent, `.opensocrates.js.staging-${randomUUID()}`);
3217
+ const bridgeMarker = join(paths.bridgeParent, `.opensocrates-managed.json.staging-${randomUUID()}`);
3218
+ try {
3219
+ await cp(join(pluginSource, "skills", "opensocrates"), root, {
3220
+ recursive: true,
3221
+ preserveTimestamps: true,
3222
+ });
3223
+ const metadata = join(root, ".opensocrates-package");
3224
+ await mkdir(metadata, { mode: 0o700 });
3225
+ for (const name of ["opencode-plugin.json", "release-manifest.json", "checksums.sha256"]) {
3226
+ await cp(join(pluginSource, name), join(metadata, name), {
3227
+ preserveTimestamps: true,
3228
+ });
3229
+ }
3230
+ await writeFile(join(root, MARKER_NAME), `${JSON.stringify(markerFor("opencode"), null, 2)}\n`, {
3231
+ encoding: "utf8",
3232
+ mode: 0o600,
3233
+ });
3234
+ await cp(join(pluginSource, "plugins", "opensocrates.js"), bridge, {
3235
+ preserveTimestamps: true,
3236
+ });
3237
+ const bridgeSha256 = await sha256File(bridge);
3238
+ const files = {};
3239
+ for (const item of (await walkFiles(root)).sort()) {
3240
+ files[item] = await sha256File(join(root, ...item.split("/")));
3241
+ }
3242
+ await writeFile(
3243
+ join(root, OPENCODE_INSTALL_MANIFEST),
3244
+ `${JSON.stringify(
3245
+ {
3246
+ schema: "opensocrates.opencode-installation/1.0.0",
3247
+ version: PRODUCT_VERSION,
3248
+ bridgeSha256,
3249
+ files,
3250
+ },
3251
+ null,
3252
+ 2,
3253
+ )}\n`,
3254
+ { encoding: "utf8", mode: 0o600 },
3255
+ );
3256
+ await writeFile(bridgeMarker, `${JSON.stringify(openCodeSidecar(bridgeSha256), null, 2)}\n`, {
3257
+ encoding: "utf8",
3258
+ mode: 0o600,
3259
+ });
3260
+ return { root, bridge, bridgeMarker };
3261
+ } catch (error) {
3262
+ await rm(root, { recursive: true, force: true });
3263
+ await rm(bridge, { force: true });
3264
+ await rm(bridgeMarker, { force: true });
3265
+ throw error;
3266
+ }
3267
+ }
3268
+
1205
3269
  function entryRoot(entry, host) {
1206
3270
  if (entry === null) {
1207
3271
  return null;
1208
3272
  }
1209
3273
  let value;
1210
3274
  if (host === "claude") {
1211
- const fields = ["path", "installLocation"].filter(
1212
- (field) => entry[field] !== undefined && entry[field] !== null,
1213
- );
3275
+ const fields = ["path", "installLocation"].filter((field) => entry[field] !== undefined && entry[field] !== null);
1214
3276
  if (
1215
3277
  fields.length === 0 ||
1216
- fields.some(
1217
- (field) => typeof entry[field] !== "string" || entry[field].trim().length === 0,
1218
- )
3278
+ fields.some((field) => typeof entry[field] !== "string" || entry[field].trim().length === 0)
1219
3279
  ) {
1220
3280
  fail(`${host} marketplace ${MARKETPLACE_NAME} has no usable root`);
1221
3281
  }
@@ -1250,14 +3310,13 @@ function entryRoot(entry, host) {
1250
3310
  }
1251
3311
 
1252
3312
  function removeRegistration(host, entry, state) {
3313
+ if (FILE_DROP_HOSTS.includes(host) || host === "opencode") return;
1253
3314
  if (host === "claude") {
1254
3315
  if (["installed", "disabled"].includes(state.kind)) {
1255
3316
  run(claudeBinary(), ["plugin", "uninstall", PLUGIN_ID, "--scope", "user"]);
1256
3317
  }
1257
3318
  if (entry !== null) {
1258
- run(claudeBinary(), [
1259
- "plugin", "marketplace", "remove", MARKETPLACE_NAME, "--scope", "user",
1260
- ]);
3319
+ run(claudeBinary(), ["plugin", "marketplace", "remove", MARKETPLACE_NAME, "--scope", "user"]);
1261
3320
  }
1262
3321
  return;
1263
3322
  }
@@ -1270,6 +3329,7 @@ function removeRegistration(host, entry, state) {
1270
3329
  }
1271
3330
 
1272
3331
  function addRegistration(host, root, installPlugin, { enabled = true } = {}) {
3332
+ if (FILE_DROP_HOSTS.includes(host) || host === "opencode") return null;
1273
3333
  if (host === "claude") {
1274
3334
  run(claudeBinary(), ["plugin", "marketplace", "add", root, "--scope", "user"]);
1275
3335
  if (installPlugin) {
@@ -1288,6 +3348,7 @@ function addRegistration(host, root, installPlugin, { enabled = true } = {}) {
1288
3348
  }
1289
3349
 
1290
3350
  function removeRegistrationBestEffort(host) {
3351
+ if (FILE_DROP_HOSTS.includes(host) || host === "opencode") return;
1291
3352
  const entry = (() => {
1292
3353
  try {
1293
3354
  return marketplaceEntry(host);
@@ -1324,15 +3385,19 @@ async function recoveryStep(label, action) {
1324
3385
  await action();
1325
3386
  return true;
1326
3387
  } catch (recoveryError) {
1327
- const detail =
1328
- recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
3388
+ const detail = recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
1329
3389
  console.error(`warning: rollback step failed (${label}): ${detail}`);
1330
3390
  return false;
1331
3391
  }
1332
3392
  }
1333
3393
 
1334
3394
  async function preflightHost(host, action) {
1335
- requireHostCli(host, { authenticated: ["install", "update"].includes(action) });
3395
+ if (
3396
+ (!FILE_DROP_HOSTS.includes(host) && host !== "opencode") ||
3397
+ ["install", "update"].includes(action)
3398
+ ) {
3399
+ requireHostCli(host, { authenticated: ["install", "update"].includes(action) });
3400
+ }
1336
3401
  if (host === "claude") {
1337
3402
  if (["install", "update"].includes(action)) {
1338
3403
  requireNoLegacyClaudeInstallation();
@@ -1341,6 +3406,24 @@ async function preflightHost(host, action) {
1341
3406
  }
1342
3407
  }
1343
3408
  const paths = managedPaths(host);
3409
+ if (host === "opencode") {
3410
+ const presence = await Promise.all(
3411
+ [paths.root, paths.bridge, paths.bridgeMarker].map((target) => entryExists(target)),
3412
+ );
3413
+ if (presence.some(Boolean) && !presence.every(Boolean)) {
3414
+ fail("OpenCode has a partial or unowned OpenSocrates installation; refusing to replace it");
3415
+ }
3416
+ if (presence.every(Boolean)) await verifyOpenCodeInstallation(paths);
3417
+ const previousEntry = presence.every(Boolean) ? { name: MARKETPLACE_NAME, root: paths.root } : null;
3418
+ const previousState = previousEntry === null ? { kind: "missing", version: null } : pluginState(host);
3419
+ return {
3420
+ host,
3421
+ paths,
3422
+ previousEntry,
3423
+ previousState,
3424
+ rootExists: presence[0],
3425
+ };
3426
+ }
1344
3427
  const previousEntry = marketplaceEntry(host);
1345
3428
  if (previousEntry !== null && entryRoot(previousEntry, host) !== paths.root) {
1346
3429
  fail(
@@ -1348,7 +3431,7 @@ async function preflightHost(host, action) {
1348
3431
  "refusing to overwrite an unmanaged location",
1349
3432
  );
1350
3433
  }
1351
- const rootExists = await exists(paths.root);
3434
+ const rootExists = await entryExists(paths.root);
1352
3435
  if (rootExists) {
1353
3436
  await requireOwnedRoot(paths.root, host);
1354
3437
  }
@@ -1356,7 +3439,9 @@ async function preflightHost(host, action) {
1356
3439
  fail(`${host} has a managed registration whose root is missing: ${paths.root}`);
1357
3440
  }
1358
3441
  const previousState =
1359
- previousEntry === null ? { kind: "missing", version: null } : pluginState(host);
3442
+ previousEntry === null
3443
+ ? { kind: "missing", version: null }
3444
+ : pluginState(host, { requireHostState: ["install", "update"].includes(action) });
1360
3445
  return { host, paths, previousEntry, previousState, rootExists };
1361
3446
  }
1362
3447
 
@@ -1364,32 +3449,39 @@ async function preflightSelectedHosts(options, action, desiredState) {
1364
3449
  if (options.host !== ALL_HOST) {
1365
3450
  return [await preflightHost(options.host, action)];
1366
3451
  }
3452
+ // A pre-release or offline caller may supply only the exact host archives it
3453
+ // intends to transact. Never mix those candidate packages with downloads
3454
+ // for other locally available hosts: the qualified asset set is the exact
3455
+ // transaction boundary. With no local assets, --host all retains discovery
3456
+ // of every ready host.
3457
+ const assetHosts = qualifiedAssetHosts(options);
3458
+ const candidates = assetHosts.length > 0 ? assetHosts : SUPPORTED_HOSTS;
1367
3459
  const desiredHosts = new Set(desiredState.installedHosts);
1368
3460
  const rootPresence = Object.fromEntries(
1369
- await Promise.all(
1370
- SUPPORTED_HOSTS.map(async (host) => [host, await exists(managedPaths(host).root)]),
1371
- ),
1372
- );
1373
- const settled = await Promise.allSettled(
1374
- SUPPORTED_HOSTS.map((host) => preflightHost(host, action)),
3461
+ await Promise.all(candidates.map(async (host) => [host, await entryExists(managedPaths(host).root)])),
1375
3462
  );
3463
+ const settled = await Promise.allSettled(candidates.map((host) => preflightHost(host, action)));
1376
3464
  const successes = new Map();
1377
3465
  const failures = new Map();
1378
3466
  settled.forEach((result, index) => {
1379
- const host = SUPPORTED_HOSTS[index];
3467
+ const host = candidates[index];
1380
3468
  if (result.status === "fulfilled") successes.set(host, result.value);
1381
3469
  else failures.set(host, result.reason);
1382
3470
  });
1383
3471
 
1384
3472
  const required = new Set();
1385
- if (action === "install") {
1386
- for (const host of SUPPORTED_HOSTS) {
3473
+ if (assetHosts.length > 0) {
3474
+ for (const host of assetHosts) required.add(host);
3475
+ } else if (action === "install") {
3476
+ for (const host of candidates) {
1387
3477
  if (desiredHosts.has(host) || rootPresence[host]) required.add(host);
1388
3478
  }
1389
3479
  } else if (desiredHosts.size > 0) {
1390
- for (const host of desiredHosts) required.add(host);
3480
+ for (const host of desiredHosts) {
3481
+ if (candidates.includes(host)) required.add(host);
3482
+ }
1391
3483
  } else if (action === "remove") {
1392
- for (const host of SUPPORTED_HOSTS) {
3484
+ for (const host of candidates) {
1393
3485
  if (rootPresence[host]) required.add(host);
1394
3486
  }
1395
3487
  }
@@ -1401,7 +3493,9 @@ async function preflightSelectedHosts(options, action, desiredState) {
1401
3493
  }
1402
3494
 
1403
3495
  let selected;
1404
- if (action === "update" && desiredHosts.size > 0) {
3496
+ if (assetHosts.length > 0) {
3497
+ selected = assetHosts.map((host) => successes.get(host)).filter(Boolean);
3498
+ } else if (action === "update" && desiredHosts.size > 0) {
1405
3499
  selected = [...desiredHosts].map((host) => successes.get(host)).filter(Boolean);
1406
3500
  } else if (action === "remove") {
1407
3501
  selected = [...successes.values()].filter(
@@ -1427,14 +3521,26 @@ async function preflightSelectedHosts(options, action, desiredState) {
1427
3521
  async function stageInstallation(preflight, pluginSource) {
1428
3522
  const { host, paths } = preflight;
1429
3523
  await mkdir(paths.parent, { recursive: true, mode: 0o700 });
1430
- const staging = await buildStagingTree(paths.parent, pluginSource, host);
3524
+ const transient = transientParent(host, paths);
3525
+ await mkdir(transient, { recursive: true, mode: 0o700 });
3526
+ const staging =
3527
+ host === "opencode"
3528
+ ? await buildOpenCodeStaging(paths, pluginSource)
3529
+ : await buildStagingTree(transient, pluginSource, host);
1431
3530
  return {
1432
3531
  ...preflight,
1433
3532
  staging,
1434
- backup: join(paths.parent, `.opensocrates.backup-${randomUUID()}`),
3533
+ backup: join(transient, `.opensocrates.backup-${randomUUID()}`),
3534
+ bridgeBackup: host === "opencode" ? join(paths.bridgeParent, `.opensocrates.js.backup-${randomUUID()}`) : null,
3535
+ bridgeMarkerBackup:
3536
+ host === "opencode" ? join(paths.bridgeParent, `.opensocrates-managed.json.backup-${randomUUID()}`) : null,
1435
3537
  registrationRemoved: false,
1436
3538
  backupCreated: false,
1437
3539
  newRootActive: false,
3540
+ newBridgeActive: false,
3541
+ newBridgeMarkerActive: false,
3542
+ bridgeBackupCreated: false,
3543
+ bridgeMarkerBackupCreated: false,
1438
3544
  activationStarted: false,
1439
3545
  };
1440
3546
  }
@@ -1446,17 +3552,31 @@ async function activateInstallation(transaction) {
1446
3552
  transaction.registrationRemoved = true;
1447
3553
  removeRegistration(host, previousEntry, previousState);
1448
3554
  }
1449
- if (await exists(paths.root)) {
3555
+ if (await entryExists(paths.root)) {
1450
3556
  await rename(paths.root, transaction.backup);
1451
3557
  transaction.backupCreated = true;
1452
3558
  }
1453
- await rename(transaction.staging, paths.root);
3559
+ if (host === "opencode" && (await entryExists(paths.bridge))) {
3560
+ await rename(paths.bridge, transaction.bridgeBackup);
3561
+ transaction.bridgeBackupCreated = true;
3562
+ }
3563
+ if (host === "opencode" && (await entryExists(paths.bridgeMarker))) {
3564
+ await rename(paths.bridgeMarker, transaction.bridgeMarkerBackup);
3565
+ transaction.bridgeMarkerBackupCreated = true;
3566
+ }
3567
+ await rename(host === "opencode" ? transaction.staging.root : transaction.staging, paths.root);
1454
3568
  transaction.newRootActive = true;
3569
+ if (host === "opencode") {
3570
+ await rename(transaction.staging.bridge, paths.bridge);
3571
+ transaction.newBridgeActive = true;
3572
+ await rename(transaction.staging.bridgeMarker, paths.bridgeMarker);
3573
+ transaction.newBridgeMarkerActive = true;
3574
+ await verifyOpenCodeInstallation(paths);
3575
+ }
1455
3576
  const result = addRegistration(host, paths.root, true);
1456
- const state = pluginState(host);
3577
+ const state = pluginState(host, { requireHostState: true });
1457
3578
  if (
1458
- (host === "codex" &&
1459
- (result?.pluginId !== PLUGIN_ID || result?.version !== PRODUCT_VERSION)) ||
3579
+ (host === "codex" && (result?.pluginId !== PLUGIN_ID || result?.version !== PRODUCT_VERSION)) ||
1460
3580
  state.kind !== "installed" ||
1461
3581
  state.version !== PRODUCT_VERSION
1462
3582
  ) {
@@ -1479,41 +3599,56 @@ async function rollbackInstallation(transaction) {
1479
3599
  await rm(paths.root, { recursive: true, force: true });
1480
3600
  });
1481
3601
  }
3602
+ if (host === "opencode") {
3603
+ if (transaction.newBridgeActive && (await exists(paths.bridge))) {
3604
+ await recoveryStep("remove the failed OpenCode bridge", async () => {
3605
+ await rm(paths.bridge, { force: true });
3606
+ });
3607
+ }
3608
+ if (transaction.newBridgeMarkerActive && (await exists(paths.bridgeMarker))) {
3609
+ await recoveryStep("remove the failed OpenCode bridge sidecar", async () => {
3610
+ await rm(paths.bridgeMarker, { force: true });
3611
+ });
3612
+ }
3613
+ if (transaction.bridgeBackupCreated && (await exists(transaction.bridgeBackup))) {
3614
+ await recoveryStep("restore the previous OpenCode bridge", async () => {
3615
+ await rename(transaction.bridgeBackup, paths.bridge);
3616
+ });
3617
+ }
3618
+ if (transaction.bridgeMarkerBackupCreated && (await exists(transaction.bridgeMarkerBackup))) {
3619
+ await recoveryStep("restore the previous OpenCode bridge sidecar", async () => {
3620
+ await rename(transaction.bridgeMarkerBackup, paths.bridgeMarker);
3621
+ });
3622
+ }
3623
+ }
1482
3624
  if (transaction.backupCreated && (await exists(transaction.backup))) {
1483
3625
  restored = await recoveryStep(`restore the previous ${host} installation`, async () => {
1484
3626
  await requireOwnedRoot(transaction.backup, host);
1485
3627
  await rename(transaction.backup, paths.root);
1486
3628
  });
1487
3629
  }
1488
- if (
1489
- transaction.registrationRemoved &&
1490
- previousEntry !== null &&
1491
- (restored || !transaction.backupCreated)
1492
- ) {
3630
+ if (transaction.registrationRemoved && previousEntry !== null && (restored || !transaction.backupCreated)) {
1493
3631
  await recoveryStep(`re-register the previous ${host} installation`, async () => {
1494
- addRegistration(
1495
- host,
1496
- paths.root,
1497
- ["installed", "disabled"].includes(previousState.kind),
1498
- { enabled: previousState.kind !== "disabled" },
1499
- );
3632
+ addRegistration(host, paths.root, ["installed", "disabled"].includes(previousState.kind), {
3633
+ enabled: previousState.kind !== "disabled",
3634
+ });
1500
3635
  });
1501
3636
  }
1502
3637
  if (transaction.backupCreated && !restored) {
1503
- console.error(
1504
- `error: the previous ${host} OpenSocrates installation could not be restored automatically.`,
1505
- );
3638
+ console.error(`error: the previous ${host} OpenSocrates installation could not be restored automatically.`);
1506
3639
  console.error(`error: your previous files are preserved at: ${transaction.backup}`);
1507
3640
  console.error(`error: recovery command: /bin/rm -rf -- ${shellQuote(paths.root)}`);
1508
- console.error(
1509
- `error: recovery command: /bin/mv -- ${shellQuote(transaction.backup)} ${shellQuote(paths.root)}`,
1510
- );
3641
+ console.error(`error: recovery command: /bin/mv -- ${shellQuote(transaction.backup)} ${shellQuote(paths.root)}`);
1511
3642
  console.error(`error: recovery command: opensocrates install --host ${host}`);
1512
3643
  }
1513
3644
  }
1514
3645
 
1515
3646
  async function cleanupInstallationTransaction(transaction) {
1516
- if (await exists(transaction.staging)) {
3647
+ if (transaction.host === "opencode") {
3648
+ await rm(transaction.staging.root, { recursive: true, force: true });
3649
+ await rm(transaction.staging.bridge, { force: true });
3650
+ await rm(transaction.staging.bridgeMarker, { force: true });
3651
+ } else if (await exists(transaction.staging)) {
1517
3652
  await rm(transaction.staging, { recursive: true, force: true });
1518
3653
  }
1519
3654
  }
@@ -1524,6 +3659,14 @@ async function commitInstallation(transaction) {
1524
3659
  await rm(transaction.backup, { recursive: true });
1525
3660
  transaction.backupCreated = false;
1526
3661
  }
3662
+ if (transaction.host === "opencode") {
3663
+ for (const [created, backup] of [
3664
+ [transaction.bridgeBackupCreated, transaction.bridgeBackup],
3665
+ [transaction.bridgeMarkerBackupCreated, transaction.bridgeMarkerBackup],
3666
+ ]) {
3667
+ if (created && (await exists(backup))) await rm(backup);
3668
+ }
3669
+ }
1527
3670
  }
1528
3671
 
1529
3672
  function installedDesiredState(previous, hosts) {
@@ -1546,8 +3689,7 @@ async function runInstallOrUpdate(options, action) {
1546
3689
  for (const preflight of preflights) {
1547
3690
  const item = packages.get(preflight.host);
1548
3691
  console.log(
1549
- `${preflight.host}: verified OpenSocrates ${PRODUCT_VERSION} and ` +
1550
- `${item.checkedFiles} package files`,
3692
+ `${preflight.host}: verified OpenSocrates ${PRODUCT_VERSION} and ` + `${item.checkedFiles} package files`,
1551
3693
  );
1552
3694
  transactions.push(await stageInstallation(preflight, item.pluginRoot));
1553
3695
  }
@@ -1563,9 +3705,7 @@ async function runInstallOrUpdate(options, action) {
1563
3705
  throw error;
1564
3706
  } finally {
1565
3707
  await Promise.all(transactions.map(cleanupInstallationTransaction));
1566
- await Promise.all(
1567
- prepared.map((item) => rm(item.scratch, { recursive: true, force: true })),
1568
- );
3708
+ await Promise.all(prepared.map((item) => rm(item.scratch, { recursive: true, force: true })));
1569
3709
  }
1570
3710
  for (const transaction of transactions) {
1571
3711
  const cleaned = await recoveryStep(`remove the committed ${transaction.host} backup`, async () => {
@@ -1576,9 +3716,7 @@ async function runInstallOrUpdate(options, action) {
1576
3716
  }
1577
3717
  }
1578
3718
  const verb = action === "update" ? "updated" : "installed";
1579
- console.log(
1580
- `OpenSocrates ${PRODUCT_VERSION} ${verb} successfully for ${hosts.join(", ")}.`,
1581
- );
3719
+ console.log(`OpenSocrates ${PRODUCT_VERSION} ${verb} successfully for ${hosts.join(", ")}.`);
1582
3720
  if (hosts.includes("codex")) {
1583
3721
  console.log(
1584
3722
  "Codex approval required: open one interactive Codex session and approve the " +
@@ -1586,14 +3724,39 @@ async function runInstallOrUpdate(options, action) {
1586
3724
  "codex exec silently skips hooks that have not been trusted.",
1587
3725
  );
1588
3726
  }
1589
- console.log("Start new host tasks to load the updated skills and hooks.");
3727
+ if (hosts.includes("cursor")) {
3728
+ console.log(
3729
+ "Cursor: run Developer: Reload Window, then invoke /opensocrates from Agent chat. " +
3730
+ "This experimental package adds no automatic OpenSocrates hook selector.",
3731
+ );
3732
+ }
3733
+ if (hosts.includes("grok")) {
3734
+ console.log(
3735
+ "Grok Build: start a new task for automatic native-skill selection, or invoke /opensocrates explicitly.",
3736
+ );
3737
+ }
3738
+ if (hosts.includes("opencode")) {
3739
+ console.log(
3740
+ "OpenCode: automatic same-turn selection uses the stable chat.message bridge; " +
3741
+ "the native opensocrates skill remains available as an explicit fallback.",
3742
+ );
3743
+ }
3744
+ console.log("Start new host tasks to load the updated OpenSocrates integration.");
1590
3745
  return hosts;
1591
3746
  }
1592
3747
 
1593
3748
  async function inspectHostStatus(host) {
1594
- requireHostCli(host);
3749
+ if (!FILE_DROP_HOSTS.includes(host) && host !== "opencode") requireHostCli(host);
1595
3750
  if (host === "claude") warnLegacyClaudeInstallation();
1596
3751
  const paths = managedPaths(host);
3752
+ if (host === "opencode") {
3753
+ const presence = await Promise.all([paths.root, paths.bridge, paths.bridgeMarker].map((target) => exists(target)));
3754
+ if (!presence.some(Boolean)) return { host, kind: "missing", version: null, paths };
3755
+ if (!presence.every(Boolean)) return { host, kind: "files-only", version: null, paths };
3756
+ await verifyOpenCodeInstallation(paths);
3757
+ const state = pluginState(host);
3758
+ return { host, kind: state.kind, version: state.version, paths };
3759
+ }
1597
3760
  const entry = marketplaceEntry(host);
1598
3761
  if (entry === null) {
1599
3762
  if (await exists(paths.root)) {
@@ -1624,14 +3787,19 @@ async function showStatus(host) {
1624
3787
  const status = await inspectHostStatus(candidate);
1625
3788
  const expected = desired.installedHosts.includes(candidate);
1626
3789
  const hostDrift = expected
1627
- ? status.kind !== "installed" ||
1628
- desired.activeVersion === null ||
1629
- status.version !== desired.activeVersion
3790
+ ? status.kind !== "installed" || desired.activeVersion === null || status.version !== desired.activeVersion
1630
3791
  : status.kind !== "missing";
1631
3792
  drift ||= hostDrift;
1632
3793
  if (status.kind === "installed") {
1633
3794
  console.log(
1634
3795
  `${candidate}: installed ${status.version ?? "unknown"}` +
3796
+ (["antigravity", "cursor"].includes(candidate)
3797
+ ? " (experimental explicit-skill tier)"
3798
+ : candidate === "grok"
3799
+ ? " (native skill; automatic and explicit invocation)"
3800
+ : candidate === "opencode"
3801
+ ? " (stable same-turn bridge plus native skill fallback)"
3802
+ : "") +
1635
3803
  (expected
1636
3804
  ? hostDrift
1637
3805
  ? ` (drift from ${desired.activeVersion ?? "desired state"})`
@@ -1651,9 +3819,7 @@ async function showStatus(host) {
1651
3819
  } else if (status.kind === "files-only") {
1652
3820
  console.log(`${candidate}: managed files present but not registered`);
1653
3821
  } else {
1654
- console.log(
1655
- `${candidate}: not installed` + (expected ? " (drift: desired host is missing)" : ""),
1656
- );
3822
+ console.log(`${candidate}: not installed` + (expected ? " (drift: desired host is missing)" : ""));
1657
3823
  }
1658
3824
  } catch (error) {
1659
3825
  if (desired.installedHosts.includes(candidate)) drift = true;
@@ -1668,11 +3834,24 @@ async function showStatus(host) {
1668
3834
  }
1669
3835
  const status = await inspectHostStatus(host);
1670
3836
  if (status.kind === "installed") {
1671
- console.log(`OpenSocrates ${status.version ?? "unknown"} is installed.`);
3837
+ console.log(
3838
+ `OpenSocrates ${status.version ?? "unknown"} is installed.` +
3839
+ (host === "antigravity"
3840
+ ? " Antigravity support is experimental and explicit-skill only."
3841
+ : host === "cursor"
3842
+ ? " Cursor support is experimental and explicit-skill first."
3843
+ : host === "grok"
3844
+ ? " Grok Build uses automatic native-skill selection and explicit /opensocrates invocation."
3845
+ : host === "opencode"
3846
+ ? " OpenCode uses the stable same-turn bridge and native skill fallback."
3847
+ : ""),
3848
+ );
1672
3849
  } else if (status.kind === "disabled") {
1673
3850
  console.log(
1674
3851
  `OpenSocrates ${status.version ?? "unknown"} is installed but disabled. ` +
1675
- "Run install or update to re-enable it.",
3852
+ (host === "grok"
3853
+ ? "Remove the OpenSocrates disabled entry from Grok Build configuration, then run status again."
3854
+ : "Run install or update to re-enable it."),
1676
3855
  );
1677
3856
  } else if (status.kind === "available") {
1678
3857
  console.log(`OpenSocrates ${status.version ?? "unknown"} is available but not installed.`);
@@ -1686,9 +3865,25 @@ async function showStatus(host) {
1686
3865
  function removalTransaction(preflight) {
1687
3866
  return {
1688
3867
  ...preflight,
1689
- backup: join(preflight.paths.parent, `.opensocrates.removed-${randomUUID()}`),
3868
+ backup: join(
3869
+ transientParent(preflight.host, preflight.paths),
3870
+ `.opensocrates.removed-${randomUUID()}`,
3871
+ ),
3872
+ bridgeBackup:
3873
+ preflight.host === "opencode"
3874
+ ? join(preflight.paths.bridgeParent, `.opensocrates.js.removed-${randomUUID()}`)
3875
+ : null,
3876
+ bridgeMarkerBackup:
3877
+ preflight.host === "opencode"
3878
+ ? join(preflight.paths.bridgeParent, `.opensocrates-managed.json.removed-${randomUUID()}`)
3879
+ : null,
1690
3880
  registrationRemoved: false,
1691
3881
  backupCreated: false,
3882
+ rootCommitted: false,
3883
+ bridgeBackupCreated: false,
3884
+ bridgeCommitted: false,
3885
+ bridgeMarkerBackupCreated: false,
3886
+ bridgeMarkerCommitted: false,
1692
3887
  };
1693
3888
  }
1694
3889
 
@@ -1698,22 +3893,57 @@ async function activateRemoval(transaction) {
1698
3893
  transaction.registrationRemoved = true;
1699
3894
  removeRegistration(host, previousEntry, previousState);
1700
3895
  }
1701
- if (await exists(paths.root)) {
3896
+ if (await entryExists(paths.root)) {
1702
3897
  await rename(paths.root, transaction.backup);
1703
3898
  transaction.backupCreated = true;
1704
3899
  }
3900
+ if (host === "opencode" && (await entryExists(paths.bridge))) {
3901
+ await rename(paths.bridge, transaction.bridgeBackup);
3902
+ transaction.bridgeBackupCreated = true;
3903
+ }
3904
+ if (host === "opencode" && (await entryExists(paths.bridgeMarker))) {
3905
+ await rename(paths.bridgeMarker, transaction.bridgeMarkerBackup);
3906
+ transaction.bridgeMarkerBackupCreated = true;
3907
+ }
1705
3908
  }
1706
3909
 
1707
3910
  async function rollbackRemoval(transaction) {
1708
- let restored = !transaction.backupCreated;
1709
- 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))) {
1710
3914
  restored = await recoveryStep(`restore the removed ${transaction.host} files`, async () => {
1711
- await requireOwnedRoot(transaction.backup, transaction.host);
3915
+ await verifyManagedTreeForPurge(transaction.host, transaction.backup);
1712
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);
1713
3937
  });
1714
3938
  }
1715
- if (transaction.registrationRemoved && transaction.previousEntry !== null && restored) {
1716
- 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 () => {
1717
3947
  addRegistration(
1718
3948
  transaction.host,
1719
3949
  transaction.paths.root,
@@ -1721,13 +3951,93 @@ async function rollbackRemoval(transaction) {
1721
3951
  { enabled: transaction.previousState.kind !== "disabled" },
1722
3952
  );
1723
3953
  });
3954
+ complete = registrationRestored && complete;
3955
+ } else if (transaction.registrationRemoved && transaction.previousEntry !== null) {
3956
+ complete = false;
3957
+ }
3958
+ if (transaction.host === "opencode") {
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 () => {
3966
+ await rename(transaction.bridgeBackup, transaction.paths.bridge);
3967
+ transaction.bridgeBackupCreated = false;
3968
+ });
3969
+ } else if (transaction.rootExists && !transaction.bridgeCommitted && !transaction.bridgeBackupCreated) {
3970
+ bridgeRestored = await entryExists(transaction.paths.bridge);
3971
+ }
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 () => {
3980
+ await rename(transaction.bridgeMarkerBackup, transaction.paths.bridgeMarker);
3981
+ transaction.bridgeMarkerBackupCreated = false;
3982
+ });
3983
+ } else if (
3984
+ transaction.rootExists &&
3985
+ !transaction.bridgeMarkerCommitted &&
3986
+ !transaction.bridgeMarkerBackupCreated
3987
+ ) {
3988
+ markerRestored = await entryExists(transaction.paths.bridgeMarker);
3989
+ }
3990
+ complete = markerRestored && complete;
1724
3991
  }
3992
+ transaction.originalStateRestored = complete;
3993
+ return complete;
1725
3994
  }
1726
3995
 
1727
- async function commitRemoval(transaction) {
1728
- if (transaction.backupCreated && (await exists(transaction.backup))) {
1729
- 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
+ }
1730
4005
  await rm(transaction.backup, { recursive: true });
4006
+ transaction.backupCreated = false;
4007
+ transaction.rootCommitted = true;
4008
+ }
4009
+ if (transaction.host === "opencode") {
4010
+ for (const [flag, backup, label] of [
4011
+ ["bridgeBackupCreated", transaction.bridgeBackup, "bridge"],
4012
+ ["bridgeMarkerBackupCreated", transaction.bridgeMarkerBackup, "bridge sidecar"],
4013
+ ]) {
4014
+ if (!transaction[flag] || !(await entryExists(backup))) continue;
4015
+ const info = await lstat(backup);
4016
+ if (!info.isFile() || info.isSymbolicLink()) {
4017
+ fail(`refusing to remove an unsafe OpenCode ${label} backup: ${backup}`);
4018
+ }
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}`);
4040
+ }
1731
4041
  }
1732
4042
  }
1733
4043
 
@@ -1769,29 +4079,15 @@ function launchctlDomain() {
1769
4079
 
1770
4080
  function launchAgentDocument(npx, channel, environment) {
1771
4081
  const packageTag = channel === "next" ? "next" : "latest";
1772
- const arguments_ = [
1773
- npx,
1774
- "--yes",
1775
- `opensocrates@${packageTag}`,
1776
- "auto-update",
1777
- "run",
1778
- ];
1779
- const argumentsXml = arguments_
1780
- .map((argument) => ` <string>${xmlEscape(argument)}</string>`)
1781
- .join("\n");
1782
- const environmentEntries = Object.entries(environment).sort(([left], [right]) =>
1783
- left.localeCompare(right),
1784
- );
4082
+ const arguments_ = [npx, "--yes", `opensocrates@${packageTag}`, "auto-update", "run"];
4083
+ const argumentsXml = arguments_.map((argument) => ` <string>${xmlEscape(argument)}</string>`).join("\n");
4084
+ const environmentEntries = Object.entries(environment).sort(([left], [right]) => left.localeCompare(right));
1785
4085
  const environmentXml =
1786
4086
  environmentEntries.length === 0
1787
4087
  ? ""
1788
4088
  : `\n <key>EnvironmentVariables</key>\n <dict>\n` +
1789
4089
  environmentEntries
1790
- .map(
1791
- ([key, value]) =>
1792
- ` <key>${xmlEscape(key)}</key>\n` +
1793
- ` <string>${xmlEscape(value)}</string>`,
1794
- )
4090
+ .map(([key, value]) => ` <key>${xmlEscape(key)}</key>\n` + ` <string>${xmlEscape(value)}</string>`)
1795
4091
  .join("\n") +
1796
4092
  "\n </dict>";
1797
4093
  return `<?xml version="1.0" encoding="UTF-8"?>
@@ -1824,7 +4120,11 @@ function launchAgentTarget() {
1824
4120
  }
1825
4121
 
1826
4122
  function launchAgentLoaded() {
1827
- return run(launchctlBinary(), ["print", launchAgentTarget()], { allowFailure: true }).status === 0;
4123
+ return (
4124
+ run(launchctlBinary(), ["print", launchAgentTarget()], {
4125
+ allowFailure: true,
4126
+ }).status === 0
4127
+ );
1828
4128
  }
1829
4129
 
1830
4130
  function stopLoadedLaunchAgent() {
@@ -1841,17 +4141,69 @@ function stopLoadedLaunchAgent() {
1841
4141
  async function updaterEnvironment(hosts, npx) {
1842
4142
  const node = await executablePath("node", "OPENSOCRATES_NODE_BIN");
1843
4143
  const environment = {
1844
- PATH: [...new Set([dirname(npx), dirname(node), "/usr/bin", "/bin", "/usr/sbin", "/sbin"])].join(
1845
- ":",
1846
- ),
4144
+ PATH: [...new Set([dirname(npx), dirname(node), "/usr/bin", "/bin", "/usr/sbin", "/sbin"])].join(":"),
1847
4145
  };
1848
4146
  for (const host of hosts) {
4147
+ if (host === "cursor") {
4148
+ if (process.env.CURSOR_CONFIG_DIR) {
4149
+ environment.CURSOR_CONFIG_DIR = resolve(process.env.CURSOR_CONFIG_DIR);
4150
+ continue;
4151
+ }
4152
+ const app = cursorAppPaths().find((candidate) => existsSync(candidate));
4153
+ if (app) continue;
4154
+ const executable = await executablePath("cursor", "CURSOR_BIN");
4155
+ environment.CURSOR_BIN = executable;
4156
+ environment.PATH = [...new Set([dirname(executable), ...environment.PATH.split(":")])].join(":");
4157
+ continue;
4158
+ }
4159
+ if (host === "antigravity") {
4160
+ if (process.env.ANTIGRAVITY_CONFIG_DIR) {
4161
+ environment.ANTIGRAVITY_CONFIG_DIR = resolve(process.env.ANTIGRAVITY_CONFIG_DIR);
4162
+ continue;
4163
+ }
4164
+ const executable = await executablePath("agy", "AGY_BIN");
4165
+ environment.AGY_BIN = executable;
4166
+ environment.PATH = [...new Set([dirname(executable), ...environment.PATH.split(":")])].join(":");
4167
+ continue;
4168
+ }
4169
+ if (host === "opencode") {
4170
+ // OPENCODE_CONFIG_DIR redirects configuration only. Unlike the
4171
+ // content-only hosts above, a scheduled OpenCode update re-runs the same
4172
+ // requireHostCli version gate as an interactive install, so recording
4173
+ // the config directory and stopping would leave the LaunchAgent without
4174
+ // a resolvable opencode: enable would succeed in a shell that has it on
4175
+ // PATH, then every scheduled run would fail the gate whenever opencode
4176
+ // lives outside the launchd default PATH.
4177
+ if (process.env.OPENCODE_CONFIG_DIR) {
4178
+ environment.OPENCODE_CONFIG_DIR = resolve(process.env.OPENCODE_CONFIG_DIR);
4179
+ }
4180
+ const executable = await executablePath("opencode", "OPENCODE_BIN");
4181
+ environment.OPENCODE_BIN = executable;
4182
+ environment.PATH = [...new Set([dirname(executable), ...environment.PATH.split(":")])].join(":");
4183
+ continue;
4184
+ }
4185
+ if (host === "grok") {
4186
+ const executable = await executablePath("grok", "GROK_BIN");
4187
+ environment.GROK_BIN = executable;
4188
+ environment.PATH = [...new Set([dirname(executable), ...environment.PATH.split(":")])].join(
4189
+ ":",
4190
+ );
4191
+ continue;
4192
+ }
1849
4193
  const key = host === "claude" ? "CLAUDE_BIN" : "CODEX_BIN";
1850
4194
  const executable = await executablePath(host, key);
1851
4195
  environment[key] = executable;
1852
4196
  environment.PATH = [...new Set([dirname(executable), ...environment.PATH.split(":")])].join(":");
1853
4197
  }
1854
- for (const key of ["CLAUDE_CONFIG_DIR", "CODEX_HOME", "OPENSOCRATES_STATE_DIR"]) {
4198
+ for (const key of [
4199
+ "ANTIGRAVITY_CONFIG_DIR",
4200
+ "CLAUDE_CONFIG_DIR",
4201
+ "CODEX_HOME",
4202
+ "CURSOR_CONFIG_DIR",
4203
+ "GROK_HOME",
4204
+ "OPENCODE_CONFIG_DIR",
4205
+ "OPENSOCRATES_STATE_DIR",
4206
+ ]) {
1855
4207
  if (process.env[key]) environment[key] = resolve(process.env[key]);
1856
4208
  }
1857
4209
  return environment;
@@ -1859,10 +4211,7 @@ async function updaterEnvironment(hosts, npx) {
1859
4211
 
1860
4212
  async function installLaunchAgent(channel, hosts) {
1861
4213
  if (process.platform !== "darwin" || process.arch !== "arm64") {
1862
- fail(
1863
- `automatic updates currently support darwin-arm64 only; detected ` +
1864
- `${process.platform}-${process.arch}`,
1865
- );
4214
+ fail(`automatic updates currently support darwin-arm64 only; detected ` + `${process.platform}-${process.arch}`);
1866
4215
  }
1867
4216
  const paths = statePaths();
1868
4217
  const npx = await executablePath("npx", "OPENSOCRATES_NPX_BIN");
@@ -1883,22 +4232,16 @@ async function installLaunchAgent(channel, hosts) {
1883
4232
  const wasLoaded = launchAgentLoaded();
1884
4233
  if (wasLoaded) stopLoadedLaunchAgent();
1885
4234
  await atomicWritePrivateFile(paths.launchAgent, document);
1886
- const launched = run(
1887
- launchctlBinary(),
1888
- ["bootstrap", launchctlDomain(), paths.launchAgent],
1889
- { allowFailure: true },
1890
- );
4235
+ const launched = run(launchctlBinary(), ["bootstrap", launchctlDomain(), paths.launchAgent], { allowFailure: true });
1891
4236
  if (launched.status !== 0) {
1892
4237
  if (previousDocument === null) {
1893
4238
  await rm(paths.launchAgent, { force: true });
1894
4239
  } else {
1895
4240
  await atomicWritePrivateFile(paths.launchAgent, previousDocument);
1896
4241
  if (wasLoaded) {
1897
- const restored = run(
1898
- launchctlBinary(),
1899
- ["bootstrap", launchctlDomain(), paths.launchAgent],
1900
- { allowFailure: true },
1901
- );
4242
+ const restored = run(launchctlBinary(), ["bootstrap", launchctlDomain(), paths.launchAgent], {
4243
+ allowFailure: true,
4244
+ });
1902
4245
  if (restored.status !== 0) {
1903
4246
  console.error("warning: the previous OpenSocrates LaunchAgent could not be reloaded");
1904
4247
  }
@@ -1909,14 +4252,42 @@ async function installLaunchAgent(channel, hosts) {
1909
4252
  }
1910
4253
  }
1911
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
+
1912
4285
  async function disableLaunchAgent() {
1913
4286
  const paths = statePaths();
1914
- const agentPresent = await exists(paths.launchAgent);
4287
+ const agentPresent = await entryExists(paths.launchAgent);
1915
4288
  if (agentPresent) {
1916
- const info = await lstat(paths.launchAgent);
1917
- if (!info.isFile() || info.isSymbolicLink()) {
1918
- fail(`refusing to remove an unsafe LaunchAgent path: ${paths.launchAgent}`);
1919
- }
4289
+ await requireSafePathBelow(paths.launchAgentsDirectory, paths.launchAgent, "OpenSocrates LaunchAgent");
4290
+ await requireOwnedLaunchAgent(paths.launchAgent);
1920
4291
  }
1921
4292
  if (process.env.OPENSOCRATES_SKIP_LAUNCHCTL !== "1") {
1922
4293
  stopLoadedLaunchAgent();
@@ -1932,14 +4303,12 @@ async function disableAutoUpdateState(desired) {
1932
4303
  };
1933
4304
  }
1934
4305
 
1935
- async function runRemove(options) {
4306
+ async function runStandardRemove(options, { report = true, beforeBackupDelete = null } = {}) {
1936
4307
  const desired = await readDesiredState();
1937
4308
  const preflights = await preflightSelectedHosts(options, "remove", desired);
1938
4309
  const removedHosts = preflights.map((item) => item.host);
1939
4310
  const remainingHosts = desired.installedHosts.filter((host) => !removedHosts.includes(host));
1940
- const remainingAutoUpdateHosts = desired.autoUpdate.hosts.filter(
1941
- (host) => !removedHosts.includes(host),
1942
- );
4311
+ const remainingAutoUpdateHosts = desired.autoUpdate.hosts.filter((host) => !removedHosts.includes(host));
1943
4312
  const keepAutoUpdate = desired.autoUpdate.enabled && remainingAutoUpdateHosts.length > 0;
1944
4313
  const nextDesired = {
1945
4314
  ...desired,
@@ -1949,14 +4318,23 @@ async function runRemove(options) {
1949
4318
  ? { ...desired.autoUpdate, hosts: remainingAutoUpdateHosts }
1950
4319
  : { enabled: false, hosts: [], nextCheckAt: null },
1951
4320
  };
1952
- const launchAgentPresent = await exists(statePaths().launchAgent);
4321
+ const launchAgentPresent = await entryExists(statePaths().launchAgent);
1953
4322
  const schedulerNeedsChange =
1954
4323
  options.host === ALL_HOST ||
1955
4324
  remainingHosts.length === 0 ||
1956
4325
  launchAgentPresent !== keepAutoUpdate ||
1957
4326
  desired.autoUpdate.hosts.some((host) => removedHosts.includes(host));
1958
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
+ }
1959
4336
  let schedulerTouched = false;
4337
+ let desiredWritten = false;
1960
4338
  try {
1961
4339
  for (const transaction of transactions) await activateRemoval(transaction);
1962
4340
  if (schedulerNeedsChange) {
@@ -1968,42 +4346,539 @@ async function runRemove(options) {
1968
4346
  }
1969
4347
  }
1970
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
+ }
1971
4354
  } catch (error) {
1972
- 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;
1973
4375
  if (schedulerTouched) {
1974
- const schedulerRestored = await recoveryStep(
1975
- "restore the automatic updater after removal failure",
1976
- async () => {
1977
- if (desired.autoUpdate.enabled) {
1978
- await installLaunchAgent(desired.channel, desired.autoUpdate.hosts);
1979
- } else {
1980
- await disableLaunchAgent();
1981
- }
1982
- },
1983
- );
1984
- if (!schedulerRestored && desired.autoUpdate.enabled) {
1985
- await recoveryStep("record the disabled updater after removal failure", async () => {
1986
- await writeDesiredState({
1987
- ...desired,
1988
- autoUpdate: { enabled: false, hosts: [], nextCheckAt: null },
1989
- });
1990
- });
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);
4379
+ } else {
4380
+ await disableLaunchAgent();
4381
+ }
4382
+ });
4383
+ if (!schedulerRestored && recoveredDesired.autoUpdate.enabled) {
4384
+ recoveredDesired = {
4385
+ ...recoveredDesired,
4386
+ autoUpdate: {
4387
+ enabled: false,
4388
+ hosts: [],
4389
+ nextCheckAt: null,
4390
+ },
4391
+ };
1991
4392
  }
1992
- } else if (!nextDesired.autoUpdate.enabled && desired.autoUpdate.enabled) {
1993
- await recoveryStep("record the disabled updater after removal failure", async () => {
1994
- await writeDesiredState({
1995
- ...desired,
1996
- autoUpdate: { enabled: false, hosts: [], nextCheckAt: null },
1997
- });
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);
1998
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
+ }
1999
4414
  }
2000
4415
  throw error;
2001
4416
  }
2002
- for (const transaction of transactions) await commitRemoval(transaction);
4417
+ if (!report) return { removedHosts, desired: nextDesired };
2003
4418
  for (const host of removedHosts) {
2004
- console.log(`OpenSocrates was removed from ${host === "claude" ? "Claude" : "Codex"}.`);
4419
+ const label =
4420
+ host === "antigravity"
4421
+ ? "Antigravity"
4422
+ : host === "claude"
4423
+ ? "Claude"
4424
+ : host === "cursor"
4425
+ ? "Cursor"
4426
+ : host === "grok"
4427
+ ? "Grok Build"
4428
+ : host === "opencode"
4429
+ ? "OpenCode"
4430
+ : "Codex";
4431
+ console.log(`OpenSocrates was removed from ${label}.`);
2005
4432
  }
2006
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;
2007
4882
  }
2008
4883
 
2009
4884
  async function enableAutoUpdate(options) {
@@ -2042,19 +4917,14 @@ async function enableAutoUpdate(options) {
2042
4917
  const observedVersions = new Set();
2043
4918
  for (const host of installedHosts) {
2044
4919
  const preflight = preflights.get(host) ?? (await preflightHost(host, "update"));
2045
- if (
2046
- preflight.previousState.kind !== "installed" ||
2047
- typeof preflight.previousState.version !== "string"
2048
- ) {
4920
+ if (preflight.previousState.kind !== "installed" || typeof preflight.previousState.version !== "string") {
2049
4921
  fail(`cannot determine the installed OpenSocrates version on ${host}`);
2050
4922
  }
2051
4923
  preflights.set(host, preflight);
2052
4924
  observedVersions.add(preflight.previousState.version);
2053
4925
  }
2054
4926
  if (observedVersions.size !== 1) {
2055
- fail(
2056
- "installed hosts do not share one known version; run update --host all before enabling automatic updates",
2057
- );
4927
+ fail("installed hosts do not share one known version; run update --host all before enabling automatic updates");
2058
4928
  }
2059
4929
  [activeVersion] = observedVersions;
2060
4930
  }
@@ -2158,18 +5028,20 @@ async function runScheduledUpdate(options) {
2158
5028
  for (const host of hosts) ready.push(await preflightHost(host, "update"));
2159
5029
  const alreadyCurrent =
2160
5030
  desired.activeVersion === PRODUCT_VERSION &&
2161
- ready.every(
2162
- (item) =>
2163
- item.previousState.kind === "installed" && item.previousState.version === PRODUCT_VERSION,
2164
- );
5031
+ ready.every((item) => item.previousState.kind === "installed" && item.previousState.version === PRODUCT_VERSION);
2165
5032
  if (!alreadyCurrent) {
2166
- if (hosts.length === SUPPORTED_HOSTS.length) {
5033
+ if (hosts.length > 1) {
2167
5034
  await runInstallOrUpdate({ ...options, host: ALL_HOST }, "update");
2168
5035
  } else {
2169
5036
  const [host] = hosts;
2170
5037
  const local = localAssetInputs(options, host);
2171
5038
  await runInstallOrUpdate(
2172
- { ...options, host, asset: local.asset, checksum: local.checksum },
5039
+ {
5040
+ ...options,
5041
+ host,
5042
+ asset: local.asset,
5043
+ checksum: local.checksum,
5044
+ },
2173
5045
  "update",
2174
5046
  );
2175
5047
  }
@@ -2179,15 +5051,16 @@ async function runScheduledUpdate(options) {
2179
5051
  ...refreshed,
2180
5052
  availableVersion: PRODUCT_VERSION,
2181
5053
  lastCheckAt: checkedAt,
2182
- lastSuccessfulUpdateAt: alreadyCurrent
2183
- ? refreshed.lastSuccessfulUpdateAt
2184
- : checkedAt,
5054
+ lastSuccessfulUpdateAt: alreadyCurrent ? refreshed.lastSuccessfulUpdateAt : checkedAt,
2185
5055
  autoUpdate: { ...refreshed.autoUpdate, nextCheckAt: next },
2186
5056
  });
2187
5057
  await writeAutoUpdateReceipt({
2188
5058
  version: PRODUCT_VERSION,
2189
5059
  checkedAt,
2190
- hosts: hosts.map((host) => ({ host, result: alreadyCurrent ? "current" : "updated" })),
5060
+ hosts: hosts.map((host) => ({
5061
+ host,
5062
+ result: alreadyCurrent ? "current" : "updated",
5063
+ })),
2191
5064
  result: alreadyCurrent ? "no-update" : "updated",
2192
5065
  errorCategory: null,
2193
5066
  });
@@ -2226,23 +5099,35 @@ function requireSupportedPlatform() {
2226
5099
  }
2227
5100
 
2228
5101
  async function verifyPackages(options) {
2229
- const hosts = options.host === ALL_HOST ? SUPPORTED_HOSTS : [options.host];
5102
+ const assetHosts = qualifiedAssetHosts(options);
5103
+ const hosts =
5104
+ options.host === ALL_HOST ? (assetHosts.length > 0 ? assetHosts : SUPPORTED_HOSTS) : [options.host];
2230
5105
  const prepared = await prepareVerifiedPackages(options, hosts);
2231
5106
  try {
2232
5107
  for (const item of prepared) {
2233
5108
  console.log(
2234
- `${item.host}: verified OpenSocrates ${PRODUCT_VERSION} release and ` +
2235
- `${item.checkedFiles} package files.`,
5109
+ `${item.host}: verified OpenSocrates ${PRODUCT_VERSION} release and ` + `${item.checkedFiles} package files.`,
2236
5110
  );
5111
+ if (item.host === "opencode") {
5112
+ const paths = managedPaths("opencode");
5113
+ const presence = await Promise.all(
5114
+ [paths.root, paths.bridge, paths.bridgeMarker].map((target) => exists(target)),
5115
+ );
5116
+ if (presence.some(Boolean)) {
5117
+ if (!presence.every(Boolean)) {
5118
+ fail("OpenCode installed state is partial and failed verification");
5119
+ }
5120
+ await verifyOpenCodeInstallation(paths);
5121
+ console.log("opencode: verified installed bridge, skill inventory, and ownership.");
5122
+ }
5123
+ }
2237
5124
  }
2238
5125
  } finally {
2239
- await Promise.all(
2240
- prepared.map((item) => rm(item.scratch, { recursive: true, force: true })),
2241
- );
5126
+ await Promise.all(prepared.map((item) => rm(item.scratch, { recursive: true, force: true })));
2242
5127
  }
2243
5128
  }
2244
5129
 
2245
- export async function main(argv = process.argv.slice(2)) {
5130
+ export async function main(argv = process.argv.slice(2), internalDependencies = {}) {
2246
5131
  const options = parseCli(argv);
2247
5132
  if (options.action === "help") {
2248
5133
  showHelp();
@@ -2260,9 +5145,18 @@ export async function main(argv = process.argv.slice(2)) {
2260
5145
  await showAutoUpdateStatus();
2261
5146
  return 0;
2262
5147
  }
5148
+ if (options.action === "remove" && options.purge) {
5149
+ await runPurge({
5150
+ ...options,
5151
+ trustResetHooks: internalDependencies.trustResetHooks ?? {},
5152
+ });
5153
+ return 0;
5154
+ }
2263
5155
  return withOperationLock(async () => {
2264
5156
  if (options.action === "remove") {
2265
- await runRemove(options);
5157
+ await runStandardRemove(options, {
5158
+ beforeBackupDelete: internalDependencies.beforeRemovalBackupDelete ?? null,
5159
+ });
2266
5160
  return 0;
2267
5161
  }
2268
5162
  if (options.action === "auto-update") {