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