opensocrates 1.1.5 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +127 -0
- package/README.ko.md +196 -61
- package/README.md +218 -68
- package/VERSION +1 -1
- package/installer/opensocrates.mjs +3211 -317
- package/package.json +8 -3
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
4
|
import { createHash, randomInt, randomUUID } from "node:crypto";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
constants as fsConstants,
|
|
7
|
+
createReadStream,
|
|
8
|
+
createWriteStream,
|
|
9
|
+
existsSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
realpathSync,
|
|
12
|
+
} from "node:fs";
|
|
6
13
|
import {
|
|
7
14
|
access,
|
|
8
15
|
chmod,
|
|
16
|
+
chown,
|
|
9
17
|
cp,
|
|
10
18
|
lstat,
|
|
11
19
|
mkdir,
|
|
@@ -13,8 +21,10 @@ import {
|
|
|
13
21
|
open,
|
|
14
22
|
readFile,
|
|
15
23
|
readdir,
|
|
24
|
+
realpath,
|
|
16
25
|
rename,
|
|
17
26
|
rm,
|
|
27
|
+
rmdir,
|
|
18
28
|
stat,
|
|
19
29
|
writeFile,
|
|
20
30
|
} from "node:fs/promises";
|
|
@@ -24,13 +34,20 @@ import { Readable } from "node:stream";
|
|
|
24
34
|
import { pipeline } from "node:stream/promises";
|
|
25
35
|
import { fileURLToPath } from "node:url";
|
|
26
36
|
|
|
27
|
-
export const PRODUCT_VERSION = "1.1
|
|
37
|
+
export const PRODUCT_VERSION = "1.2.1";
|
|
28
38
|
export const REPOSITORY = "ParkerHwang/OpenSocrates";
|
|
29
39
|
export const MARKETPLACE_NAME = "opensocrates";
|
|
30
40
|
export const PLUGIN_NAME = "opensocrates";
|
|
31
41
|
export const PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
|
|
32
42
|
export const DEFAULT_HOST = "codex";
|
|
33
|
-
export const SUPPORTED_HOSTS = Object.freeze([
|
|
43
|
+
export const SUPPORTED_HOSTS = Object.freeze([
|
|
44
|
+
"antigravity",
|
|
45
|
+
"claude",
|
|
46
|
+
"codex",
|
|
47
|
+
"cursor",
|
|
48
|
+
"grok",
|
|
49
|
+
"opencode",
|
|
50
|
+
]);
|
|
34
51
|
export const ALL_HOST = "all";
|
|
35
52
|
export const HOST_CHOICES = Object.freeze([ALL_HOST, ...SUPPORTED_HOSTS]);
|
|
36
53
|
export function assetNameFor(host = DEFAULT_HOST) {
|
|
@@ -54,16 +71,75 @@ const CLAUDE_MARKER = Object.freeze({
|
|
|
54
71
|
pluginName: PLUGIN_NAME,
|
|
55
72
|
host: "claude",
|
|
56
73
|
});
|
|
74
|
+
const CURSOR_MARKER = Object.freeze({
|
|
75
|
+
schemaVersion: 1,
|
|
76
|
+
marketplaceName: MARKETPLACE_NAME,
|
|
77
|
+
pluginName: PLUGIN_NAME,
|
|
78
|
+
host: "cursor",
|
|
79
|
+
registrationKind: "file-drop",
|
|
80
|
+
});
|
|
81
|
+
const ANTIGRAVITY_MARKER = Object.freeze({
|
|
82
|
+
schemaVersion: 1,
|
|
83
|
+
marketplaceName: MARKETPLACE_NAME,
|
|
84
|
+
pluginName: PLUGIN_NAME,
|
|
85
|
+
host: "antigravity",
|
|
86
|
+
registrationKind: "file-drop",
|
|
87
|
+
});
|
|
88
|
+
const GROK_MARKER = Object.freeze({
|
|
89
|
+
schemaVersion: 1,
|
|
90
|
+
marketplaceName: MARKETPLACE_NAME,
|
|
91
|
+
pluginName: PLUGIN_NAME,
|
|
92
|
+
host: "grok",
|
|
93
|
+
registrationKind: "file-drop",
|
|
94
|
+
});
|
|
95
|
+
const OPENCODE_MARKER = Object.freeze({
|
|
96
|
+
schemaVersion: 1,
|
|
97
|
+
marketplaceName: MARKETPLACE_NAME,
|
|
98
|
+
pluginName: PLUGIN_NAME,
|
|
99
|
+
host: "opencode",
|
|
100
|
+
registrationKind: "automatic-file-discovery",
|
|
101
|
+
});
|
|
102
|
+
const OPENCODE_INSTALL_MANIFEST = ".opensocrates-installation.json";
|
|
103
|
+
const OPENCODE_BRIDGE_MARKER = ".opensocrates-managed.json";
|
|
104
|
+
// OpenCode is discovered automatically from its config directory, so it is
|
|
105
|
+
// deliberately not a file-drop host.
|
|
106
|
+
const FILE_DROP_HOSTS = Object.freeze(["antigravity", "cursor", "grok"]);
|
|
57
107
|
const HOST_LAYOUTS = Object.freeze({
|
|
108
|
+
antigravity: {
|
|
109
|
+
marketplaceRelative: null,
|
|
110
|
+
pluginRelative: ".",
|
|
111
|
+
manifestRelative: "plugin.json",
|
|
112
|
+
requiresRuntime: false,
|
|
113
|
+
},
|
|
58
114
|
claude: {
|
|
59
115
|
marketplaceRelative: join(".claude-plugin", "marketplace.json"),
|
|
60
116
|
pluginRelative: join("plugins", PLUGIN_NAME),
|
|
61
117
|
manifestRelative: join(".claude-plugin", "plugin.json"),
|
|
118
|
+
requiresRuntime: true,
|
|
62
119
|
},
|
|
63
120
|
codex: {
|
|
64
121
|
marketplaceRelative: join(".agents", "plugins", "marketplace.json"),
|
|
65
122
|
pluginRelative: join("build", "generated", "plugins", "codex"),
|
|
66
123
|
manifestRelative: join(".codex-plugin", "plugin.json"),
|
|
124
|
+
requiresRuntime: true,
|
|
125
|
+
},
|
|
126
|
+
cursor: {
|
|
127
|
+
marketplaceRelative: null,
|
|
128
|
+
pluginRelative: ".",
|
|
129
|
+
manifestRelative: "plugin.json",
|
|
130
|
+
requiresRuntime: false,
|
|
131
|
+
},
|
|
132
|
+
grok: {
|
|
133
|
+
marketplaceRelative: null,
|
|
134
|
+
pluginRelative: ".",
|
|
135
|
+
manifestRelative: "plugin.json",
|
|
136
|
+
requiresRuntime: false,
|
|
137
|
+
},
|
|
138
|
+
opencode: {
|
|
139
|
+
marketplaceRelative: null,
|
|
140
|
+
pluginRelative: ".",
|
|
141
|
+
manifestRelative: "opencode-plugin.json",
|
|
142
|
+
requiresRuntime: false,
|
|
67
143
|
},
|
|
68
144
|
});
|
|
69
145
|
const MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024 * 1024;
|
|
@@ -76,6 +152,1057 @@ const AUTO_UPDATE_MAX_INTERVAL_HOURS = 24 * 7;
|
|
|
76
152
|
const AUTO_UPDATE_DEFAULT_INTERVAL_HOURS = 24;
|
|
77
153
|
const AUTO_UPDATE_POLL_SECONDS = 60 * 60;
|
|
78
154
|
const LOCK_STALE_MILLISECONDS = 2 * 60 * 60 * 1000;
|
|
155
|
+
export const PURGE_RESULT_SCHEMA = "opensocrates.purge-result/1.0.0";
|
|
156
|
+
export const CODEX_TRUST_EVENTS = Object.freeze([
|
|
157
|
+
"pre_tool_use",
|
|
158
|
+
"post_tool_use",
|
|
159
|
+
"pre_compact",
|
|
160
|
+
"session_start",
|
|
161
|
+
"session_end",
|
|
162
|
+
"user_prompt_submit",
|
|
163
|
+
"stop",
|
|
164
|
+
]);
|
|
165
|
+
const CODEX_TRUST_KEY_PREFIX = `${PLUGIN_ID}:hooks/hooks.json:`;
|
|
166
|
+
const MAX_CODEX_CONFIG_BYTES = 16 * 1024 * 1024;
|
|
167
|
+
const CODEX_APP_SERVER_ARGS = Object.freeze(["app-server", "--stdio"]);
|
|
168
|
+
const CODEX_APP_SERVER_TIMEOUT_MILLISECONDS = 15_000;
|
|
169
|
+
const CODEX_APP_SERVER_TERMINATION_MILLISECONDS = 1_000;
|
|
170
|
+
const MAX_CODEX_APP_SERVER_LINE_BYTES = 256 * 1024;
|
|
171
|
+
const MAX_CODEX_APP_SERVER_OUTPUT_BYTES = 1024 * 1024;
|
|
172
|
+
const MAX_CODEX_APP_SERVER_NOTIFICATIONS = 128;
|
|
173
|
+
|
|
174
|
+
// Keep host security state separate from installer-owned payload cleanup so an
|
|
175
|
+
// explicit trust reset can fail without weakening purge path-ownership checks.
|
|
176
|
+
export function purgeExtensionResult(host, resetTrust = false) {
|
|
177
|
+
return host === "codex"
|
|
178
|
+
? {
|
|
179
|
+
component: "host-security-trust",
|
|
180
|
+
status: resetTrust ? "pending" : "preserved",
|
|
181
|
+
nextAction: resetTrust ? null : "rerun-purge-with-reset-trust",
|
|
182
|
+
}
|
|
183
|
+
: {
|
|
184
|
+
component: "host-security-trust",
|
|
185
|
+
status: "not-applicable",
|
|
186
|
+
nextAction: null,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function createPurgeResult(hosts, { resetTrust = false } = {}) {
|
|
191
|
+
return {
|
|
192
|
+
schema: PURGE_RESULT_SCHEMA,
|
|
193
|
+
status: "pending",
|
|
194
|
+
hosts: [...hosts].sort().map((host) => ({
|
|
195
|
+
host,
|
|
196
|
+
status: "pending",
|
|
197
|
+
registration: "not-checked",
|
|
198
|
+
registrationDetail: null,
|
|
199
|
+
components: [],
|
|
200
|
+
extension: purgeExtensionResult(host, resetTrust),
|
|
201
|
+
errors: [],
|
|
202
|
+
})),
|
|
203
|
+
finalization: {
|
|
204
|
+
status: "pending",
|
|
205
|
+
components: [],
|
|
206
|
+
errors: [],
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function trustResetFailure(message) {
|
|
212
|
+
fail(`Codex OpenSocrates hook trust was not reset: ${message}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function decodeTomlBasicString(source, start) {
|
|
216
|
+
let value = "";
|
|
217
|
+
let index = start + 1;
|
|
218
|
+
while (index < source.length) {
|
|
219
|
+
const character = source[index];
|
|
220
|
+
if (character === '"') {
|
|
221
|
+
return { value, next: index + 1, kind: "basic", raw: source.slice(start, index + 1) };
|
|
222
|
+
}
|
|
223
|
+
if (character === "\\") {
|
|
224
|
+
const escaped = source[index + 1];
|
|
225
|
+
const simple = new Map([
|
|
226
|
+
['"', '"'],
|
|
227
|
+
["\\", "\\"],
|
|
228
|
+
["b", "\b"],
|
|
229
|
+
["t", "\t"],
|
|
230
|
+
["n", "\n"],
|
|
231
|
+
["f", "\f"],
|
|
232
|
+
["r", "\r"],
|
|
233
|
+
]);
|
|
234
|
+
if (simple.has(escaped)) {
|
|
235
|
+
value += simple.get(escaped);
|
|
236
|
+
index += 2;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const digits = escaped === "u" ? 4 : escaped === "U" ? 8 : 0;
|
|
240
|
+
if (digits > 0) {
|
|
241
|
+
const encoded = source.slice(index + 2, index + 2 + digits);
|
|
242
|
+
if (!new RegExp(`^[0-9A-Fa-f]{${digits}}$`, "u").test(encoded)) return null;
|
|
243
|
+
const codePoint = Number.parseInt(encoded, 16);
|
|
244
|
+
if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return null;
|
|
245
|
+
value += String.fromCodePoint(codePoint);
|
|
246
|
+
index += 2 + digits;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
if (character.codePointAt(0) < 0x20 || character.codePointAt(0) === 0x7f) return null;
|
|
252
|
+
value += character;
|
|
253
|
+
index += 1;
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function decodeTomlLiteralString(source, start) {
|
|
259
|
+
const end = source.indexOf("'", start + 1);
|
|
260
|
+
if (end < 0) return null;
|
|
261
|
+
const value = source.slice(start + 1, end);
|
|
262
|
+
if ([...value].some((character) => character.codePointAt(0) < 0x20 || character.codePointAt(0) === 0x7f)) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
return { value, next: end + 1, kind: "literal", raw: source.slice(start, end + 1) };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function parseTomlDottedKey(source) {
|
|
269
|
+
const segments = [];
|
|
270
|
+
let index = 0;
|
|
271
|
+
const skipWhitespace = () => {
|
|
272
|
+
while (source[index] === " " || source[index] === "\t") index += 1;
|
|
273
|
+
};
|
|
274
|
+
skipWhitespace();
|
|
275
|
+
while (index < source.length) {
|
|
276
|
+
let segment;
|
|
277
|
+
if (source[index] === '"') {
|
|
278
|
+
segment = decodeTomlBasicString(source, index);
|
|
279
|
+
} else if (source[index] === "'") {
|
|
280
|
+
segment = decodeTomlLiteralString(source, index);
|
|
281
|
+
} else {
|
|
282
|
+
const match = source.slice(index).match(/^[A-Za-z0-9_-]+/u);
|
|
283
|
+
if (!match) return null;
|
|
284
|
+
segment = {
|
|
285
|
+
value: match[0],
|
|
286
|
+
next: index + match[0].length,
|
|
287
|
+
kind: "bare",
|
|
288
|
+
raw: match[0],
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
if (segment === null) return null;
|
|
292
|
+
segments.push(segment);
|
|
293
|
+
index = segment.next;
|
|
294
|
+
skipWhitespace();
|
|
295
|
+
if (index === source.length) return segments;
|
|
296
|
+
if (source[index] !== ".") return null;
|
|
297
|
+
index += 1;
|
|
298
|
+
skipWhitespace();
|
|
299
|
+
if (index === source.length) return null;
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function scanTomlLine(line, carriedContext) {
|
|
305
|
+
let state = carriedContext.stringState;
|
|
306
|
+
let arrayDepth = carriedContext.arrayDepth;
|
|
307
|
+
let inlineTableDepth = carriedContext.inlineTableDepth;
|
|
308
|
+
let commentIndex = -1;
|
|
309
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
310
|
+
if (state === "multiline-basic") {
|
|
311
|
+
if (line[index] === "\\") {
|
|
312
|
+
index += 1;
|
|
313
|
+
} else if (line.startsWith('"""', index)) {
|
|
314
|
+
state = null;
|
|
315
|
+
index += 2;
|
|
316
|
+
}
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (state === "multiline-literal") {
|
|
320
|
+
if (line.startsWith("'''", index)) {
|
|
321
|
+
state = null;
|
|
322
|
+
index += 2;
|
|
323
|
+
}
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (state === "basic") {
|
|
327
|
+
if (line[index] === "\\") index += 1;
|
|
328
|
+
else if (line[index] === '"') state = null;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (state === "literal") {
|
|
332
|
+
if (line[index] === "'") state = null;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (line[index] === "#") {
|
|
336
|
+
commentIndex = index;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
if (line.startsWith('"""', index)) {
|
|
340
|
+
state = "multiline-basic";
|
|
341
|
+
index += 2;
|
|
342
|
+
} else if (line.startsWith("'''", index)) {
|
|
343
|
+
state = "multiline-literal";
|
|
344
|
+
index += 2;
|
|
345
|
+
} else if (line[index] === '"') {
|
|
346
|
+
state = "basic";
|
|
347
|
+
} else if (line[index] === "'") {
|
|
348
|
+
state = "literal";
|
|
349
|
+
} else if (line[index] === "[") {
|
|
350
|
+
arrayDepth += 1;
|
|
351
|
+
} else if (line[index] === "]") {
|
|
352
|
+
arrayDepth -= 1;
|
|
353
|
+
if (arrayDepth < 0) return { invalid: true, commentIndex, context: carriedContext };
|
|
354
|
+
} else if (line[index] === "{") {
|
|
355
|
+
inlineTableDepth += 1;
|
|
356
|
+
} else if (line[index] === "}") {
|
|
357
|
+
inlineTableDepth -= 1;
|
|
358
|
+
if (inlineTableDepth < 0) return { invalid: true, commentIndex, context: carriedContext };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (state === "basic" || state === "literal") {
|
|
362
|
+
return { invalid: true, commentIndex, context: carriedContext };
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
invalid: false,
|
|
366
|
+
commentIndex,
|
|
367
|
+
context: { stringState: state, arrayDepth, inlineTableDepth },
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function tomlLines(source) {
|
|
372
|
+
const lines = [];
|
|
373
|
+
let start = 0;
|
|
374
|
+
while (start < source.length) {
|
|
375
|
+
const newline = source.indexOf("\n", start);
|
|
376
|
+
const end = newline < 0 ? source.length : newline + 1;
|
|
377
|
+
const contentEnd = newline < 0 ? source.length : newline > start && source[newline - 1] === "\r" ? newline - 1 : newline;
|
|
378
|
+
lines.push({ start, end, contentEnd, body: source.slice(start, contentEnd) });
|
|
379
|
+
start = end;
|
|
380
|
+
}
|
|
381
|
+
return lines;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function parseTomlTableHeader(code) {
|
|
385
|
+
const leading = code.match(/^[\t ]*/u)?.[0].length ?? 0;
|
|
386
|
+
const trimmedEnd = code.trimEnd().length;
|
|
387
|
+
const candidate = code.slice(leading, trimmedEnd);
|
|
388
|
+
const array = candidate.startsWith("[[");
|
|
389
|
+
if (!candidate.startsWith("[") || (array ? !candidate.endsWith("]]") : !candidate.endsWith("]"))) {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
const openWidth = array ? 2 : 1;
|
|
393
|
+
const closeWidth = array ? 2 : 1;
|
|
394
|
+
const keySource = candidate.slice(openWidth, candidate.length - closeWidth);
|
|
395
|
+
const segments = parseTomlDottedKey(keySource);
|
|
396
|
+
if (segments === null) return null;
|
|
397
|
+
return {
|
|
398
|
+
array,
|
|
399
|
+
raw: candidate,
|
|
400
|
+
segments,
|
|
401
|
+
syntaxStart: leading,
|
|
402
|
+
syntaxEnd: leading + candidate.length,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function matchingTrustKey(segments) {
|
|
407
|
+
if (
|
|
408
|
+
segments.length < 3 ||
|
|
409
|
+
segments[0].value !== "hooks" ||
|
|
410
|
+
segments[1].value !== "state" ||
|
|
411
|
+
!segments[2].value.startsWith(CODEX_TRUST_KEY_PREFIX)
|
|
412
|
+
) {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
return segments[2].value;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function isCanonicalTrustHeader(header, key) {
|
|
419
|
+
return !header.array && header.raw === `[hooks.state.${JSON.stringify(key)}]`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function assignmentKeySegments(code) {
|
|
423
|
+
let state = null;
|
|
424
|
+
for (let index = 0; index < code.length; index += 1) {
|
|
425
|
+
if (state === "basic") {
|
|
426
|
+
if (code[index] === "\\") index += 1;
|
|
427
|
+
else if (code[index] === '"') state = null;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (state === "literal") {
|
|
431
|
+
if (code[index] === "'") state = null;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (code[index] === '"') state = "basic";
|
|
435
|
+
else if (code[index] === "'") state = "literal";
|
|
436
|
+
else if (code[index] === "=") return parseTomlDottedKey(code.slice(0, index));
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function lineContainsTrustNamespaceString(code) {
|
|
442
|
+
for (let index = 0; index < code.length; index += 1) {
|
|
443
|
+
let decoded = null;
|
|
444
|
+
if (code[index] === '"') decoded = decodeTomlBasicString(code, index);
|
|
445
|
+
else if (code[index] === "'") decoded = decodeTomlLiteralString(code, index);
|
|
446
|
+
if (decoded === null) continue;
|
|
447
|
+
if (decoded.value.startsWith(CODEX_TRUST_KEY_PREFIX)) return true;
|
|
448
|
+
index = decoded.next - 1;
|
|
449
|
+
}
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function pathContainsTrustNamespace(segments) {
|
|
454
|
+
return matchingTrustKey(segments) !== null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function trustEventFromKey(key) {
|
|
458
|
+
const suffix = key.slice(CODEX_TRUST_KEY_PREFIX.length);
|
|
459
|
+
const match = suffix.match(/^([a-z_]+):(\d+):(\d+)$/u);
|
|
460
|
+
if (!match || !CODEX_TRUST_EVENTS.includes(match[1]) || match[2] !== "0" || match[3] !== "0") {
|
|
461
|
+
trustResetFailure("the matching trust namespace has a noncanonical event or index");
|
|
462
|
+
}
|
|
463
|
+
return match[1];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function addRemovalSpan(spans, line, start, end) {
|
|
467
|
+
if (start >= end) trustResetFailure("the matching trust section has an unsupported shape");
|
|
468
|
+
spans.push({ start: line.start + start, end: line.start + end });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Remove only the syntax bytes owned by canonical OpenSocrates hook trust
|
|
473
|
+
* sections. Comments, whitespace, newline style, unrelated keys, and the
|
|
474
|
+
* parent hooks.state table are retained byte-for-byte.
|
|
475
|
+
*/
|
|
476
|
+
export function stripCodexOpenSocratesTrustSections(contents) {
|
|
477
|
+
const original = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
|
|
478
|
+
const utf8Bom = original.subarray(0, 3).equals(Buffer.from([0xef, 0xbb, 0xbf]));
|
|
479
|
+
const encodedSource = utf8Bom ? original.subarray(3) : original;
|
|
480
|
+
let source;
|
|
481
|
+
try {
|
|
482
|
+
source = new TextDecoder("utf-8", { fatal: true }).decode(encodedSource);
|
|
483
|
+
} catch {
|
|
484
|
+
trustResetFailure("the configuration is not valid UTF-8");
|
|
485
|
+
}
|
|
486
|
+
const spans = [];
|
|
487
|
+
const found = new Map();
|
|
488
|
+
let currentTarget = null;
|
|
489
|
+
let currentTableSegments = [];
|
|
490
|
+
let context = { stringState: null, arrayDepth: 0, inlineTableDepth: 0 };
|
|
491
|
+
for (const line of tomlLines(source)) {
|
|
492
|
+
const startContext = { ...context };
|
|
493
|
+
const scanned = scanTomlLine(line.body, context);
|
|
494
|
+
if (scanned.invalid) trustResetFailure("the configuration has an unsupported string shape");
|
|
495
|
+
context = scanned.context;
|
|
496
|
+
const code = scanned.commentIndex < 0 ? line.body : line.body.slice(0, scanned.commentIndex);
|
|
497
|
+
const firstNonWhitespace = code.search(/[^\t ]/u);
|
|
498
|
+
let header = null;
|
|
499
|
+
if (
|
|
500
|
+
startContext.stringState === null &&
|
|
501
|
+
startContext.arrayDepth === 0 &&
|
|
502
|
+
startContext.inlineTableDepth === 0 &&
|
|
503
|
+
firstNonWhitespace >= 0 &&
|
|
504
|
+
code[firstNonWhitespace] === "["
|
|
505
|
+
) {
|
|
506
|
+
header = parseTomlTableHeader(code);
|
|
507
|
+
if (header === null) trustResetFailure("the configuration has an unsupported table header");
|
|
508
|
+
}
|
|
509
|
+
if (header !== null) {
|
|
510
|
+
if (currentTarget !== null && !currentTarget.hashSeen) {
|
|
511
|
+
trustResetFailure("a matching trust section is missing its canonical trusted_hash key");
|
|
512
|
+
}
|
|
513
|
+
currentTarget = null;
|
|
514
|
+
currentTableSegments = header.segments;
|
|
515
|
+
const key = matchingTrustKey(header.segments);
|
|
516
|
+
if (key !== null) {
|
|
517
|
+
if (!isCanonicalTrustHeader(header, key)) {
|
|
518
|
+
trustResetFailure("the matching trust namespace uses a noncanonical table shape");
|
|
519
|
+
}
|
|
520
|
+
const event = trustEventFromKey(key);
|
|
521
|
+
if (found.has(event)) trustResetFailure("a matching trust event appears more than once");
|
|
522
|
+
currentTarget = { event, hashSeen: false };
|
|
523
|
+
found.set(event, currentTarget);
|
|
524
|
+
addRemovalSpan(spans, line, header.syntaxStart, header.syntaxEnd);
|
|
525
|
+
}
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
if (currentTarget === null) {
|
|
529
|
+
if (code.trim().length === 0 || startContext.stringState !== null) continue;
|
|
530
|
+
const assignment = assignmentKeySegments(code);
|
|
531
|
+
const semanticPath = assignment === null ? [] : [...currentTableSegments, ...assignment];
|
|
532
|
+
if (
|
|
533
|
+
pathContainsTrustNamespace(semanticPath) ||
|
|
534
|
+
((semanticPath[0]?.value === "hooks" ||
|
|
535
|
+
(currentTableSegments[0]?.value === "hooks" && currentTableSegments[1]?.value === "state")) &&
|
|
536
|
+
lineContainsTrustNamespaceString(code))
|
|
537
|
+
) {
|
|
538
|
+
trustResetFailure("the matching trust namespace uses a noncanonical assignment shape");
|
|
539
|
+
}
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (code.trim().length === 0) continue;
|
|
543
|
+
if (
|
|
544
|
+
startContext.stringState !== null ||
|
|
545
|
+
context.stringState !== null ||
|
|
546
|
+
startContext.arrayDepth !== 0 ||
|
|
547
|
+
startContext.inlineTableDepth !== 0 ||
|
|
548
|
+
context.arrayDepth !== 0 ||
|
|
549
|
+
context.inlineTableDepth !== 0
|
|
550
|
+
) {
|
|
551
|
+
trustResetFailure("a matching trust section contains a multiline value");
|
|
552
|
+
}
|
|
553
|
+
const assignment = code.match(/^(\s*)trusted_hash\s*=\s*"(?:[^"\\\r\n]|\\.)*"(\s*)$/u);
|
|
554
|
+
if (assignment === null || currentTarget.hashSeen) {
|
|
555
|
+
trustResetFailure("a matching trust section contains an unexpected or duplicate key");
|
|
556
|
+
}
|
|
557
|
+
currentTarget.hashSeen = true;
|
|
558
|
+
const syntaxStart = assignment[1].length;
|
|
559
|
+
const syntaxEnd = code.length - assignment[2].length;
|
|
560
|
+
addRemovalSpan(spans, line, syntaxStart, syntaxEnd);
|
|
561
|
+
}
|
|
562
|
+
if (context.stringState !== null || context.arrayDepth !== 0 || context.inlineTableDepth !== 0) {
|
|
563
|
+
trustResetFailure("the configuration has an unterminated multiline value");
|
|
564
|
+
}
|
|
565
|
+
if (currentTarget !== null && !currentTarget.hashSeen) {
|
|
566
|
+
trustResetFailure("a matching trust section is missing its canonical trusted_hash key");
|
|
567
|
+
}
|
|
568
|
+
spans.sort((left, right) => left.start - right.start);
|
|
569
|
+
let cursor = 0;
|
|
570
|
+
let updated = "";
|
|
571
|
+
for (const span of spans) {
|
|
572
|
+
if (span.start < cursor) trustResetFailure("matching trust syntax overlaps unexpectedly");
|
|
573
|
+
updated += source.slice(cursor, span.start);
|
|
574
|
+
cursor = span.end;
|
|
575
|
+
}
|
|
576
|
+
updated += source.slice(cursor);
|
|
577
|
+
const updatedBytes = Buffer.from(updated, "utf8");
|
|
578
|
+
return {
|
|
579
|
+
contents: utf8Bom ? Buffer.concat([original.subarray(0, 3), updatedBytes]) : updatedBytes,
|
|
580
|
+
removedEvents: CODEX_TRUST_EVENTS.filter((event) => found.has(event)),
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function currentUid() {
|
|
585
|
+
if (typeof process.getuid !== "function") {
|
|
586
|
+
trustResetFailure("file ownership cannot be verified on this platform");
|
|
587
|
+
}
|
|
588
|
+
return process.getuid();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function assertOwnedDirectory(info) {
|
|
592
|
+
if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== currentUid() || (info.mode & 0o022) !== 0) {
|
|
593
|
+
trustResetFailure("the Codex configuration directory is not a safe owner-controlled directory");
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function assertOwnedConfigFile(info) {
|
|
598
|
+
if (!info.isFile() || info.isSymbolicLink() || info.uid !== currentUid() || info.nlink !== 1) {
|
|
599
|
+
trustResetFailure("the Codex configuration is not a safe owner-controlled regular file");
|
|
600
|
+
}
|
|
601
|
+
if (info.size > MAX_CODEX_CONFIG_BYTES) {
|
|
602
|
+
trustResetFailure("the Codex configuration is too large for a bounded trust reset");
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function sameSnapshot(left, right) {
|
|
607
|
+
return (
|
|
608
|
+
left.dev === right.dev &&
|
|
609
|
+
left.ino === right.ino &&
|
|
610
|
+
left.size === right.size &&
|
|
611
|
+
left.mtimeNs === right.mtimeNs &&
|
|
612
|
+
left.ctimeNs === right.ctimeNs &&
|
|
613
|
+
left.mode === right.mode &&
|
|
614
|
+
left.uid === right.uid &&
|
|
615
|
+
left.gid === right.gid &&
|
|
616
|
+
left.nlink === right.nlink
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function readConfigSnapshot(target) {
|
|
621
|
+
const noFollow = fsConstants.O_NOFOLLOW ?? 0;
|
|
622
|
+
let handle;
|
|
623
|
+
let operationFailed = false;
|
|
624
|
+
try {
|
|
625
|
+
handle = await open(target, fsConstants.O_RDONLY | noFollow);
|
|
626
|
+
const before = await handle.stat({ bigint: true });
|
|
627
|
+
if (
|
|
628
|
+
!before.isFile() ||
|
|
629
|
+
before.isSymbolicLink() ||
|
|
630
|
+
before.uid !== BigInt(currentUid()) ||
|
|
631
|
+
before.nlink !== 1n
|
|
632
|
+
) {
|
|
633
|
+
trustResetFailure("the Codex configuration changed to an unsafe file");
|
|
634
|
+
}
|
|
635
|
+
if (before.size > BigInt(MAX_CODEX_CONFIG_BYTES)) {
|
|
636
|
+
trustResetFailure("the Codex configuration is too large for a bounded trust reset");
|
|
637
|
+
}
|
|
638
|
+
const contents = await handle.readFile();
|
|
639
|
+
const after = await handle.stat({ bigint: true });
|
|
640
|
+
if (!sameSnapshot(before, after) || BigInt(contents.length) !== after.size) {
|
|
641
|
+
trustResetFailure("the Codex configuration changed while it was being inspected");
|
|
642
|
+
}
|
|
643
|
+
return { info: after, contents };
|
|
644
|
+
} catch (error) {
|
|
645
|
+
operationFailed = true;
|
|
646
|
+
if (error instanceof InstallerError) throw error;
|
|
647
|
+
trustResetFailure("the Codex configuration could not be inspected safely");
|
|
648
|
+
} finally {
|
|
649
|
+
try {
|
|
650
|
+
await handle?.close();
|
|
651
|
+
} catch {
|
|
652
|
+
if (!operationFailed) {
|
|
653
|
+
trustResetFailure("the Codex configuration handle could not be closed safely");
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function writeSyncedOwnerOnlyFile(target, contents) {
|
|
660
|
+
let handle;
|
|
661
|
+
let operationFailed = false;
|
|
662
|
+
try {
|
|
663
|
+
handle = await open(target, "wx", 0o600);
|
|
664
|
+
await handle.writeFile(contents);
|
|
665
|
+
await handle.chmod(0o600);
|
|
666
|
+
await handle.sync();
|
|
667
|
+
} catch (error) {
|
|
668
|
+
operationFailed = true;
|
|
669
|
+
if (error instanceof InstallerError) throw error;
|
|
670
|
+
trustResetFailure("an owner-only Codex transaction file could not be written safely");
|
|
671
|
+
} finally {
|
|
672
|
+
try {
|
|
673
|
+
await handle?.close();
|
|
674
|
+
} catch {
|
|
675
|
+
if (!operationFailed) {
|
|
676
|
+
trustResetFailure("an owner-only Codex transaction file could not be closed safely");
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function fsyncFile(target) {
|
|
683
|
+
let handle;
|
|
684
|
+
let operationFailed = false;
|
|
685
|
+
try {
|
|
686
|
+
handle = await open(target, "r");
|
|
687
|
+
await handle.sync();
|
|
688
|
+
} catch (error) {
|
|
689
|
+
operationFailed = true;
|
|
690
|
+
if (error instanceof InstallerError) throw error;
|
|
691
|
+
trustResetFailure("the Codex configuration could not be synchronized safely");
|
|
692
|
+
} finally {
|
|
693
|
+
try {
|
|
694
|
+
await handle?.close();
|
|
695
|
+
} catch {
|
|
696
|
+
if (!operationFailed) {
|
|
697
|
+
trustResetFailure("the Codex configuration handle could not be closed safely");
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function fsyncDirectory(target) {
|
|
704
|
+
let handle;
|
|
705
|
+
let operationFailed = false;
|
|
706
|
+
try {
|
|
707
|
+
handle = await open(target, "r");
|
|
708
|
+
await handle.sync();
|
|
709
|
+
} catch (error) {
|
|
710
|
+
operationFailed = true;
|
|
711
|
+
if (error instanceof InstallerError) throw error;
|
|
712
|
+
trustResetFailure("the Codex configuration directory could not be synchronized safely");
|
|
713
|
+
} finally {
|
|
714
|
+
try {
|
|
715
|
+
await handle?.close();
|
|
716
|
+
} catch {
|
|
717
|
+
if (!operationFailed) {
|
|
718
|
+
trustResetFailure("the Codex configuration directory handle could not be closed safely");
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function exactCodexInitializeResponse(message, requestId, codexHome) {
|
|
725
|
+
if (message === null || typeof message !== "object" || Array.isArray(message)) return false;
|
|
726
|
+
const responseKeys = Object.keys(message).sort();
|
|
727
|
+
if (responseKeys.length !== 2 || responseKeys[0] !== "id" || responseKeys[1] !== "result") {
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
if (message.id !== requestId) return false;
|
|
731
|
+
const result = message.result;
|
|
732
|
+
if (result === null || typeof result !== "object" || Array.isArray(result)) return false;
|
|
733
|
+
const resultKeys = Object.keys(result).sort();
|
|
734
|
+
if (
|
|
735
|
+
resultKeys.length !== 4 ||
|
|
736
|
+
resultKeys[0] !== "codexHome" ||
|
|
737
|
+
resultKeys[1] !== "platformFamily" ||
|
|
738
|
+
resultKeys[2] !== "platformOs" ||
|
|
739
|
+
resultKeys[3] !== "userAgent"
|
|
740
|
+
) {
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
return (
|
|
744
|
+
result.codexHome === codexHome &&
|
|
745
|
+
typeof result.platformFamily === "string" &&
|
|
746
|
+
result.platformFamily.length > 0 &&
|
|
747
|
+
typeof result.platformOs === "string" &&
|
|
748
|
+
result.platformOs.length > 0 &&
|
|
749
|
+
typeof result.userAgent === "string" &&
|
|
750
|
+
result.userAgent.length > 0
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function exactCodexAppServerNotification(message) {
|
|
755
|
+
if (message === null || typeof message !== "object" || Array.isArray(message)) return false;
|
|
756
|
+
const keys = Object.keys(message).sort();
|
|
757
|
+
const withoutTimestamp = keys.length === 2 && keys[0] === "method" && keys[1] === "params";
|
|
758
|
+
const withTimestamp =
|
|
759
|
+
keys.length === 3 &&
|
|
760
|
+
keys[0] === "emittedAtMs" &&
|
|
761
|
+
keys[1] === "method" &&
|
|
762
|
+
keys[2] === "params";
|
|
763
|
+
if (!withoutTimestamp && !withTimestamp) return false;
|
|
764
|
+
if (typeof message.method !== "string" || message.method.length === 0) return false;
|
|
765
|
+
if (message.params === null || typeof message.params !== "object" || Array.isArray(message.params)) {
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
return !withTimestamp || (typeof message.emittedAtMs === "number" && Number.isFinite(message.emittedAtMs));
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function codexAppServerValidationError() {
|
|
772
|
+
return new InstallerError(
|
|
773
|
+
"Codex OpenSocrates hook trust was not reset: the installed Codex app server did not accept the isolated configuration",
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function isolatedCodexValidationEnvironment(codexHome) {
|
|
778
|
+
const environment = {
|
|
779
|
+
PATH: process.env.PATH ?? "",
|
|
780
|
+
HOME: codexHome,
|
|
781
|
+
CODEX_HOME: codexHome,
|
|
782
|
+
XDG_CACHE_HOME: join(codexHome, "xdg-cache"),
|
|
783
|
+
XDG_CONFIG_HOME: join(codexHome, "xdg-config"),
|
|
784
|
+
XDG_DATA_HOME: join(codexHome, "xdg-data"),
|
|
785
|
+
XDG_STATE_HOME: join(codexHome, "xdg-state"),
|
|
786
|
+
TMPDIR: join(codexHome, "tmp"),
|
|
787
|
+
NO_COLOR: "1",
|
|
788
|
+
};
|
|
789
|
+
if (process.platform === "win32") {
|
|
790
|
+
for (const key of ["ComSpec", "PATHEXT", "SystemRoot", "WINDIR"]) {
|
|
791
|
+
if (typeof process.env[key] === "string") environment[key] = process.env[key];
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return environment;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function runCodexAppServerConfigCheck(
|
|
798
|
+
codexBin,
|
|
799
|
+
codexHome,
|
|
800
|
+
{
|
|
801
|
+
spawnAppServer = spawn,
|
|
802
|
+
timeoutMilliseconds = CODEX_APP_SERVER_TIMEOUT_MILLISECONDS,
|
|
803
|
+
terminationMilliseconds = CODEX_APP_SERVER_TERMINATION_MILLISECONDS,
|
|
804
|
+
} = {},
|
|
805
|
+
) {
|
|
806
|
+
const requestId = 1;
|
|
807
|
+
const request = Buffer.from(
|
|
808
|
+
`${JSON.stringify({
|
|
809
|
+
id: requestId,
|
|
810
|
+
method: "initialize",
|
|
811
|
+
params: {
|
|
812
|
+
clientInfo: {
|
|
813
|
+
name: "opensocrates_installer",
|
|
814
|
+
title: "OpenSocrates Installer",
|
|
815
|
+
version: PRODUCT_VERSION,
|
|
816
|
+
},
|
|
817
|
+
capabilities: { experimentalApi: false },
|
|
818
|
+
},
|
|
819
|
+
})}\n`,
|
|
820
|
+
"utf8",
|
|
821
|
+
);
|
|
822
|
+
let child;
|
|
823
|
+
try {
|
|
824
|
+
child = spawnAppServer(codexBin, CODEX_APP_SERVER_ARGS, {
|
|
825
|
+
cwd: codexHome,
|
|
826
|
+
env: isolatedCodexValidationEnvironment(codexHome),
|
|
827
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
828
|
+
windowsHide: true,
|
|
829
|
+
});
|
|
830
|
+
} catch {
|
|
831
|
+
throw codexAppServerValidationError();
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
835
|
+
const timeoutDelay =
|
|
836
|
+
Number.isFinite(timeoutMilliseconds) && timeoutMilliseconds > 0
|
|
837
|
+
? timeoutMilliseconds
|
|
838
|
+
: CODEX_APP_SERVER_TIMEOUT_MILLISECONDS;
|
|
839
|
+
const terminationDelay =
|
|
840
|
+
Number.isFinite(terminationMilliseconds) && terminationMilliseconds > 0
|
|
841
|
+
? terminationMilliseconds
|
|
842
|
+
: CODEX_APP_SERVER_TERMINATION_MILLISECONDS;
|
|
843
|
+
let closed = false;
|
|
844
|
+
let failed = false;
|
|
845
|
+
let settled = false;
|
|
846
|
+
let responseCount = 0;
|
|
847
|
+
let notificationCount = 0;
|
|
848
|
+
let responseAccepted = false;
|
|
849
|
+
let requestWriteCompleted = false;
|
|
850
|
+
let outputBytes = 0;
|
|
851
|
+
let lineBuffer = Buffer.alloc(0);
|
|
852
|
+
let inputEndRequested = false;
|
|
853
|
+
let terminationTimer;
|
|
854
|
+
let forcedFinishTimer;
|
|
855
|
+
|
|
856
|
+
const clearTimers = () => {
|
|
857
|
+
clearTimeout(deadlineTimer);
|
|
858
|
+
if (terminationTimer !== undefined) clearTimeout(terminationTimer);
|
|
859
|
+
if (forcedFinishTimer !== undefined) clearTimeout(forcedFinishTimer);
|
|
860
|
+
};
|
|
861
|
+
const finish = (accepted) => {
|
|
862
|
+
if (settled) return;
|
|
863
|
+
settled = true;
|
|
864
|
+
clearTimers();
|
|
865
|
+
if (accepted) resolvePromise();
|
|
866
|
+
else rejectPromise(codexAppServerValidationError());
|
|
867
|
+
};
|
|
868
|
+
const endInput = () => {
|
|
869
|
+
if (inputEndRequested) return true;
|
|
870
|
+
inputEndRequested = true;
|
|
871
|
+
try {
|
|
872
|
+
child.stdin.end();
|
|
873
|
+
return true;
|
|
874
|
+
} catch {
|
|
875
|
+
return false;
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
const terminate = () => {
|
|
879
|
+
if (failed || settled) return;
|
|
880
|
+
failed = true;
|
|
881
|
+
// The stream may already be closed; process termination below remains bounded.
|
|
882
|
+
endInput();
|
|
883
|
+
if (closed) {
|
|
884
|
+
finish(false);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
try {
|
|
888
|
+
child.kill("SIGTERM");
|
|
889
|
+
} catch {
|
|
890
|
+
// A failed signal is followed by the bounded SIGKILL/final failure timers.
|
|
891
|
+
}
|
|
892
|
+
terminationTimer = setTimeout(() => {
|
|
893
|
+
if (closed || settled) return;
|
|
894
|
+
try {
|
|
895
|
+
child.kill("SIGKILL");
|
|
896
|
+
} catch {
|
|
897
|
+
// The final timer still prevents an unbounded validator wait.
|
|
898
|
+
}
|
|
899
|
+
forcedFinishTimer = setTimeout(() => finish(false), terminationDelay);
|
|
900
|
+
}, terminationDelay);
|
|
901
|
+
};
|
|
902
|
+
const consumeLine = (line) => {
|
|
903
|
+
if (failed || settled) return;
|
|
904
|
+
if (line.length > MAX_CODEX_APP_SERVER_LINE_BYTES || line.length === 0) {
|
|
905
|
+
terminate();
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const normalized = line.at(-1) === 0x0d ? line.subarray(0, -1) : line;
|
|
909
|
+
let message;
|
|
910
|
+
try {
|
|
911
|
+
message = JSON.parse(normalized.toString("utf8"));
|
|
912
|
+
} catch {
|
|
913
|
+
terminate();
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
if (exactCodexInitializeResponse(message, requestId, codexHome)) {
|
|
917
|
+
responseCount += 1;
|
|
918
|
+
if (responseCount !== 1) {
|
|
919
|
+
terminate();
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
responseAccepted = true;
|
|
923
|
+
if (!endInput()) terminate();
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if (!exactCodexAppServerNotification(message)) {
|
|
927
|
+
terminate();
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
notificationCount += 1;
|
|
931
|
+
if (notificationCount > MAX_CODEX_APP_SERVER_NOTIFICATIONS) terminate();
|
|
932
|
+
};
|
|
933
|
+
const deadlineTimer = setTimeout(terminate, timeoutDelay);
|
|
934
|
+
|
|
935
|
+
child.stdout.on("data", (chunk) => {
|
|
936
|
+
if (failed || settled) return;
|
|
937
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
938
|
+
outputBytes += bytes.length;
|
|
939
|
+
if (outputBytes > MAX_CODEX_APP_SERVER_OUTPUT_BYTES) {
|
|
940
|
+
terminate();
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
lineBuffer = Buffer.concat([lineBuffer, bytes]);
|
|
944
|
+
for (;;) {
|
|
945
|
+
const newline = lineBuffer.indexOf(0x0a);
|
|
946
|
+
if (newline < 0) break;
|
|
947
|
+
const line = lineBuffer.subarray(0, newline);
|
|
948
|
+
lineBuffer = lineBuffer.subarray(newline + 1);
|
|
949
|
+
consumeLine(line);
|
|
950
|
+
if (failed || settled) return;
|
|
951
|
+
}
|
|
952
|
+
if (lineBuffer.length > MAX_CODEX_APP_SERVER_LINE_BYTES) terminate();
|
|
953
|
+
});
|
|
954
|
+
child.stderr.on("data", (chunk) => {
|
|
955
|
+
if (failed || settled) return;
|
|
956
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
957
|
+
outputBytes += bytes.length;
|
|
958
|
+
if (outputBytes > MAX_CODEX_APP_SERVER_OUTPUT_BYTES) terminate();
|
|
959
|
+
});
|
|
960
|
+
child.once("error", terminate);
|
|
961
|
+
child.stdin.once("error", terminate);
|
|
962
|
+
child.once("close", (code, signal) => {
|
|
963
|
+
closed = true;
|
|
964
|
+
finish(
|
|
965
|
+
!failed &&
|
|
966
|
+
code === 0 &&
|
|
967
|
+
signal === null &&
|
|
968
|
+
responseCount === 1 &&
|
|
969
|
+
responseAccepted &&
|
|
970
|
+
requestWriteCompleted &&
|
|
971
|
+
lineBuffer.length === 0,
|
|
972
|
+
);
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
try {
|
|
976
|
+
const accepted = child.stdin.write(request, (error) => {
|
|
977
|
+
if (error !== undefined && error !== null) {
|
|
978
|
+
terminate();
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
requestWriteCompleted = true;
|
|
982
|
+
});
|
|
983
|
+
if (!accepted) {
|
|
984
|
+
// This is the only bounded write. A false return means it is queued;
|
|
985
|
+
// the response/deadline path bounds a child that never drains it.
|
|
986
|
+
child.stdin.once("drain", () => {});
|
|
987
|
+
}
|
|
988
|
+
} catch {
|
|
989
|
+
terminate();
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
async function validateCodexConfigBytes(
|
|
995
|
+
codexBin,
|
|
996
|
+
contents,
|
|
997
|
+
{
|
|
998
|
+
removeValidationHome = rm,
|
|
999
|
+
spawnAppServer = spawn,
|
|
1000
|
+
validationTimeoutMilliseconds = CODEX_APP_SERVER_TIMEOUT_MILLISECONDS,
|
|
1001
|
+
validationTerminationMilliseconds = CODEX_APP_SERVER_TERMINATION_MILLISECONDS,
|
|
1002
|
+
} = {},
|
|
1003
|
+
) {
|
|
1004
|
+
let validationHome;
|
|
1005
|
+
let operationFailed = false;
|
|
1006
|
+
try {
|
|
1007
|
+
validationHome = await mkdtemp(join(tmpdir(), "opensocrates-codex-config-check-"));
|
|
1008
|
+
await chmod(validationHome, 0o700);
|
|
1009
|
+
// macOS commonly exposes the temporary directory through /var while
|
|
1010
|
+
// Codex reports its canonical /private/var spelling. Use one canonical
|
|
1011
|
+
// identity for the environment, cwd, and initialize-response comparison.
|
|
1012
|
+
validationHome = await realpath(validationHome);
|
|
1013
|
+
for (const directory of ["tmp", "xdg-cache", "xdg-config", "xdg-data", "xdg-state"]) {
|
|
1014
|
+
await mkdir(join(validationHome, directory), { mode: 0o700 });
|
|
1015
|
+
}
|
|
1016
|
+
const target = join(validationHome, "config.toml");
|
|
1017
|
+
await writeSyncedOwnerOnlyFile(target, contents);
|
|
1018
|
+
await runCodexAppServerConfigCheck(codexBin, validationHome, {
|
|
1019
|
+
spawnAppServer,
|
|
1020
|
+
timeoutMilliseconds: validationTimeoutMilliseconds,
|
|
1021
|
+
terminationMilliseconds: validationTerminationMilliseconds,
|
|
1022
|
+
});
|
|
1023
|
+
} catch (error) {
|
|
1024
|
+
operationFailed = true;
|
|
1025
|
+
if (error instanceof InstallerError) throw error;
|
|
1026
|
+
trustResetFailure("the Codex configuration could not be validated in isolation");
|
|
1027
|
+
} finally {
|
|
1028
|
+
if (validationHome !== undefined) {
|
|
1029
|
+
try {
|
|
1030
|
+
await removeValidationHome(validationHome, { recursive: true, force: true });
|
|
1031
|
+
} catch {
|
|
1032
|
+
if (!operationFailed) {
|
|
1033
|
+
trustResetFailure("the isolated Codex validation environment could not be removed safely");
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
async function restoreTrustConfig({
|
|
1041
|
+
rollback,
|
|
1042
|
+
target,
|
|
1043
|
+
parent,
|
|
1044
|
+
original,
|
|
1045
|
+
originalMode,
|
|
1046
|
+
originalUid,
|
|
1047
|
+
originalGid,
|
|
1048
|
+
}) {
|
|
1049
|
+
try {
|
|
1050
|
+
if (!(await entryExists(rollback))) trustResetFailure("the rollback copy is unavailable");
|
|
1051
|
+
await rename(rollback, target);
|
|
1052
|
+
await chown(target, originalUid, originalGid);
|
|
1053
|
+
await chmod(target, originalMode);
|
|
1054
|
+
await fsyncFile(target);
|
|
1055
|
+
await fsyncDirectory(parent);
|
|
1056
|
+
const restored = await readConfigSnapshot(target);
|
|
1057
|
+
if (
|
|
1058
|
+
!restored.contents.equals(original) ||
|
|
1059
|
+
Number(restored.info.mode & 0o7777n) !== originalMode ||
|
|
1060
|
+
restored.info.uid !== BigInt(originalUid) ||
|
|
1061
|
+
restored.info.gid !== BigInt(originalGid)
|
|
1062
|
+
) {
|
|
1063
|
+
trustResetFailure("the original configuration was not restored");
|
|
1064
|
+
}
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
if (error instanceof InstallerError) throw error;
|
|
1067
|
+
trustResetFailure("the original configuration could not be restored automatically");
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Reset only OpenSocrates Codex hook trust. Optional hooks are dependency
|
|
1073
|
+
* injection seams for isolated failure/race tests; production callers pass no
|
|
1074
|
+
* hooks and always use the installed Codex CLI for validation.
|
|
1075
|
+
*/
|
|
1076
|
+
export async function resetCodexOpenSocratesHookTrust({
|
|
1077
|
+
codexHome = process.env.CODEX_HOME || join(homedir(), ".codex"),
|
|
1078
|
+
codexBin = codexBinary(),
|
|
1079
|
+
hooks = {},
|
|
1080
|
+
} = {}) {
|
|
1081
|
+
const lexicalHome = resolve(codexHome);
|
|
1082
|
+
if (!(await entryExists(lexicalHome))) return { status: "absent", removedEvents: [] };
|
|
1083
|
+
const lexicalInfo = await lstat(lexicalHome);
|
|
1084
|
+
assertOwnedDirectory(lexicalInfo);
|
|
1085
|
+
let parent;
|
|
1086
|
+
try {
|
|
1087
|
+
parent = await realpath(lexicalHome);
|
|
1088
|
+
} catch {
|
|
1089
|
+
trustResetFailure("the Codex configuration directory could not be canonicalized");
|
|
1090
|
+
}
|
|
1091
|
+
const parentInfo = await lstat(parent);
|
|
1092
|
+
assertOwnedDirectory(parentInfo);
|
|
1093
|
+
const target = join(parent, "config.toml");
|
|
1094
|
+
if (!(await entryExists(target))) return { status: "absent", removedEvents: [] };
|
|
1095
|
+
const targetInfo = await lstat(target);
|
|
1096
|
+
assertOwnedConfigFile(targetInfo);
|
|
1097
|
+
const snapshot = await readConfigSnapshot(target);
|
|
1098
|
+
const originalMode = Number(snapshot.info.mode & 0o7777n);
|
|
1099
|
+
const originalUid = Number(snapshot.info.uid);
|
|
1100
|
+
const originalGid = Number(snapshot.info.gid);
|
|
1101
|
+
const validationOptions = {
|
|
1102
|
+
removeValidationHome: hooks.removeValidationHome,
|
|
1103
|
+
spawnAppServer: hooks.spawnAppServer,
|
|
1104
|
+
validationTimeoutMilliseconds: hooks.validationTimeoutMilliseconds,
|
|
1105
|
+
validationTerminationMilliseconds: hooks.validationTerminationMilliseconds,
|
|
1106
|
+
};
|
|
1107
|
+
await validateCodexConfigBytes(codexBin, snapshot.contents, validationOptions);
|
|
1108
|
+
const stripped = stripCodexOpenSocratesTrustSections(snapshot.contents);
|
|
1109
|
+
if (stripped.removedEvents.length === 0) {
|
|
1110
|
+
const current = await readConfigSnapshot(target);
|
|
1111
|
+
if (!sameSnapshot(snapshot.info, current.info) || !current.contents.equals(snapshot.contents)) {
|
|
1112
|
+
trustResetFailure("the Codex configuration changed during isolated validation");
|
|
1113
|
+
}
|
|
1114
|
+
return { status: "absent", removedEvents: [] };
|
|
1115
|
+
}
|
|
1116
|
+
await validateCodexConfigBytes(codexBin, stripped.contents, validationOptions);
|
|
1117
|
+
|
|
1118
|
+
const token = randomUUID();
|
|
1119
|
+
const temporary = join(parent, `.config.toml.opensocrates-trust-reset-${token}.tmp`);
|
|
1120
|
+
const rollback = join(parent, `.config.toml.opensocrates-trust-reset-${token}.rollback`);
|
|
1121
|
+
let replaced = false;
|
|
1122
|
+
let preserveRollback = false;
|
|
1123
|
+
let transactionFailed = false;
|
|
1124
|
+
let transactionCommitted = false;
|
|
1125
|
+
let cleanupRequired = true;
|
|
1126
|
+
try {
|
|
1127
|
+
await hooks.beforeWrite?.({ target });
|
|
1128
|
+
await writeSyncedOwnerOnlyFile(temporary, stripped.contents);
|
|
1129
|
+
await writeSyncedOwnerOnlyFile(rollback, snapshot.contents);
|
|
1130
|
+
await fsyncDirectory(parent);
|
|
1131
|
+
await hooks.beforeRename?.({ target, temporary, rollback });
|
|
1132
|
+
const current = await readConfigSnapshot(target);
|
|
1133
|
+
if (!sameSnapshot(snapshot.info, current.info) || !current.contents.equals(snapshot.contents)) {
|
|
1134
|
+
trustResetFailure("the Codex configuration changed before the atomic update");
|
|
1135
|
+
}
|
|
1136
|
+
await rename(temporary, target);
|
|
1137
|
+
replaced = true;
|
|
1138
|
+
await chown(target, originalUid, originalGid);
|
|
1139
|
+
await chmod(target, originalMode);
|
|
1140
|
+
await fsyncFile(target);
|
|
1141
|
+
await fsyncDirectory(parent);
|
|
1142
|
+
await hooks.afterRename?.({ target, rollback });
|
|
1143
|
+
const committed = await readConfigSnapshot(target);
|
|
1144
|
+
if (
|
|
1145
|
+
!committed.contents.equals(stripped.contents) ||
|
|
1146
|
+
Number(committed.info.mode & 0o7777n) !== originalMode ||
|
|
1147
|
+
committed.info.uid !== BigInt(originalUid) ||
|
|
1148
|
+
committed.info.gid !== BigInt(originalGid)
|
|
1149
|
+
) {
|
|
1150
|
+
trustResetFailure("the committed Codex configuration did not match the validated candidate");
|
|
1151
|
+
}
|
|
1152
|
+
await validateCodexConfigBytes(codexBin, committed.contents, validationOptions);
|
|
1153
|
+
await (hooks.removeRollback ?? rm)(rollback);
|
|
1154
|
+
// The candidate and parent rename are already fsynced and post-validated.
|
|
1155
|
+
// Keep rollback unlink as the final fallible commit step; another directory
|
|
1156
|
+
// fsync would only create a failure point after the recovery source is gone.
|
|
1157
|
+
transactionCommitted = true;
|
|
1158
|
+
cleanupRequired = false;
|
|
1159
|
+
return { status: "reset", removedEvents: stripped.removedEvents };
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
transactionFailed = true;
|
|
1162
|
+
if (replaced) {
|
|
1163
|
+
let canRollback = false;
|
|
1164
|
+
try {
|
|
1165
|
+
const current = await readConfigSnapshot(target);
|
|
1166
|
+
canRollback = current.contents.equals(stripped.contents);
|
|
1167
|
+
} catch {
|
|
1168
|
+
canRollback = false;
|
|
1169
|
+
}
|
|
1170
|
+
if (!canRollback) {
|
|
1171
|
+
preserveRollback = true;
|
|
1172
|
+
trustResetFailure(
|
|
1173
|
+
"the Codex configuration changed after replacement; automatic rollback was withheld and the owner-only recovery copy was preserved",
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
await restoreTrustConfig({
|
|
1177
|
+
rollback,
|
|
1178
|
+
target,
|
|
1179
|
+
parent,
|
|
1180
|
+
original: snapshot.contents,
|
|
1181
|
+
originalMode,
|
|
1182
|
+
originalUid,
|
|
1183
|
+
originalGid,
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
if (error instanceof InstallerError) throw error;
|
|
1187
|
+
trustResetFailure("the atomic configuration update failed; the original configuration was preserved");
|
|
1188
|
+
} finally {
|
|
1189
|
+
let cleanupFailed = false;
|
|
1190
|
+
if (cleanupRequired) {
|
|
1191
|
+
for (const residue of [temporary, rollback]) {
|
|
1192
|
+
try {
|
|
1193
|
+
if (residue === rollback && preserveRollback) continue;
|
|
1194
|
+
await hooks.beforeResidueCleanup?.({ residue });
|
|
1195
|
+
if (await entryExists(residue)) await rm(residue);
|
|
1196
|
+
} catch {
|
|
1197
|
+
cleanupFailed = true;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
if (cleanupFailed && !transactionFailed && !transactionCommitted) {
|
|
1202
|
+
trustResetFailure("owner-only Codex transaction residue could not be removed safely");
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
79
1206
|
|
|
80
1207
|
export class InstallerError extends Error {}
|
|
81
1208
|
|
|
@@ -96,10 +1223,29 @@ async function exists(target) {
|
|
|
96
1223
|
}
|
|
97
1224
|
}
|
|
98
1225
|
|
|
1226
|
+
// Destructive paths must distinguish a missing entry from a dangling symlink.
|
|
1227
|
+
// access() follows links, so it is intentionally insufficient at deletion
|
|
1228
|
+
// boundaries where an unresolvable link must be refused rather than reported
|
|
1229
|
+
// as already absent.
|
|
1230
|
+
async function entryExists(target) {
|
|
1231
|
+
try {
|
|
1232
|
+
await lstat(target);
|
|
1233
|
+
return true;
|
|
1234
|
+
} catch (error) {
|
|
1235
|
+
if (error?.code === "ENOENT") return false;
|
|
1236
|
+
throw error;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
async function requireRegularFileEntry(target, label) {
|
|
1241
|
+
const info = await lstat(target);
|
|
1242
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
1243
|
+
fail(`refusing an unsafe ${label}: ${target}`);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
99
1247
|
export function statePaths() {
|
|
100
|
-
const stateDirectory = resolve(
|
|
101
|
-
process.env.OPENSOCRATES_STATE_DIR || join(homedir(), ".opensocrates"),
|
|
102
|
-
);
|
|
1248
|
+
const stateDirectory = resolve(process.env.OPENSOCRATES_STATE_DIR || join(homedir(), ".opensocrates"));
|
|
103
1249
|
const launchAgentsDirectory = resolve(
|
|
104
1250
|
process.env.OPENSOCRATES_LAUNCH_AGENTS_DIR || join(homedir(), "Library", "LaunchAgents"),
|
|
105
1251
|
);
|
|
@@ -135,12 +1281,7 @@ function defaultDesiredState() {
|
|
|
135
1281
|
}
|
|
136
1282
|
|
|
137
1283
|
function normalizeDesiredState(value) {
|
|
138
|
-
if (
|
|
139
|
-
value === null ||
|
|
140
|
-
typeof value !== "object" ||
|
|
141
|
-
Array.isArray(value) ||
|
|
142
|
-
value.schema !== DESIRED_STATE_SCHEMA
|
|
143
|
-
) {
|
|
1284
|
+
if (value === null || typeof value !== "object" || Array.isArray(value) || value.schema !== DESIRED_STATE_SCHEMA) {
|
|
144
1285
|
fail("the OpenSocrates desired-state manifest has an unsupported schema");
|
|
145
1286
|
}
|
|
146
1287
|
const installedHosts = value.installedHosts;
|
|
@@ -166,16 +1307,10 @@ function normalizeDesiredState(value) {
|
|
|
166
1307
|
fail("the OpenSocrates desired-state manifest has an invalid update policy");
|
|
167
1308
|
}
|
|
168
1309
|
const autoUpdateHosts =
|
|
169
|
-
value.autoUpdate.hosts === undefined
|
|
170
|
-
? value.autoUpdate.enabled
|
|
171
|
-
? installedHosts
|
|
172
|
-
: []
|
|
173
|
-
: value.autoUpdate.hosts;
|
|
1310
|
+
value.autoUpdate.hosts === undefined ? (value.autoUpdate.enabled ? installedHosts : []) : value.autoUpdate.hosts;
|
|
174
1311
|
if (
|
|
175
1312
|
!Array.isArray(autoUpdateHosts) ||
|
|
176
|
-
autoUpdateHosts.some(
|
|
177
|
-
(host) => !SUPPORTED_HOSTS.includes(host) || !installedHosts.includes(host),
|
|
178
|
-
) ||
|
|
1313
|
+
autoUpdateHosts.some((host) => !SUPPORTED_HOSTS.includes(host) || !installedHosts.includes(host)) ||
|
|
179
1314
|
new Set(autoUpdateHosts).size !== autoUpdateHosts.length ||
|
|
180
1315
|
(value.autoUpdate.enabled && autoUpdateHosts.length === 0) ||
|
|
181
1316
|
(!value.autoUpdate.enabled && autoUpdateHosts.length > 0)
|
|
@@ -200,9 +1335,10 @@ function normalizeDesiredState(value) {
|
|
|
200
1335
|
|
|
201
1336
|
export async function readDesiredState() {
|
|
202
1337
|
const { desiredState } = statePaths();
|
|
203
|
-
if (!(await
|
|
1338
|
+
if (!(await entryExists(desiredState))) {
|
|
204
1339
|
return defaultDesiredState();
|
|
205
1340
|
}
|
|
1341
|
+
await requireRegularFileEntry(desiredState, "OpenSocrates desired-state file");
|
|
206
1342
|
let value;
|
|
207
1343
|
try {
|
|
208
1344
|
value = JSON.parse(await readFile(desiredState, "utf8"));
|
|
@@ -234,7 +1370,11 @@ async function atomicWritePrivateFile(target, contents) {
|
|
|
234
1370
|
await ensurePrivateDirectory(parent);
|
|
235
1371
|
const temporary = join(parent, `.${basename(target)}.${randomUUID()}.tmp`);
|
|
236
1372
|
try {
|
|
237
|
-
await writeFile(temporary, contents, {
|
|
1373
|
+
await writeFile(temporary, contents, {
|
|
1374
|
+
encoding: "utf8",
|
|
1375
|
+
mode: 0o600,
|
|
1376
|
+
flag: "wx",
|
|
1377
|
+
});
|
|
238
1378
|
await chmod(temporary, 0o600);
|
|
239
1379
|
await rename(temporary, target);
|
|
240
1380
|
} finally {
|
|
@@ -246,10 +1386,7 @@ async function atomicWritePrivateFile(target, contents) {
|
|
|
246
1386
|
|
|
247
1387
|
export async function writeDesiredState(value) {
|
|
248
1388
|
const normalized = normalizeDesiredState(value);
|
|
249
|
-
await atomicWritePrivateFile(
|
|
250
|
-
statePaths().desiredState,
|
|
251
|
-
`${JSON.stringify(normalized, null, 2)}\n`,
|
|
252
|
-
);
|
|
1389
|
+
await atomicWritePrivateFile(statePaths().desiredState, `${JSON.stringify(normalized, null, 2)}\n`);
|
|
253
1390
|
return normalized;
|
|
254
1391
|
}
|
|
255
1392
|
|
|
@@ -260,14 +1397,14 @@ async function writeAutoUpdateReceipt({ version, checkedAt, hosts, result, error
|
|
|
260
1397
|
checkedAt,
|
|
261
1398
|
hosts: [...hosts]
|
|
262
1399
|
.sort((left, right) => left.host.localeCompare(right.host))
|
|
263
|
-
.map(({ host, result: hostResult }) => ({
|
|
1400
|
+
.map(({ host, result: hostResult }) => ({
|
|
1401
|
+
host,
|
|
1402
|
+
result: hostResult,
|
|
1403
|
+
})),
|
|
264
1404
|
result,
|
|
265
1405
|
errorCategory: errorCategory ?? null,
|
|
266
1406
|
};
|
|
267
|
-
await atomicWritePrivateFile(
|
|
268
|
-
statePaths().receipt,
|
|
269
|
-
`${JSON.stringify(receipt, null, 2)}\n`,
|
|
270
|
-
);
|
|
1407
|
+
await atomicWritePrivateFile(statePaths().receipt, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
271
1408
|
}
|
|
272
1409
|
|
|
273
1410
|
function nowIso() {
|
|
@@ -303,10 +1440,7 @@ async function acquireOperationLock() {
|
|
|
303
1440
|
try {
|
|
304
1441
|
const handle = await open(paths.lock, "wx", 0o600);
|
|
305
1442
|
try {
|
|
306
|
-
await handle.writeFile(
|
|
307
|
-
`${JSON.stringify({ pid: process.pid, startedAt: nowIso() })}\n`,
|
|
308
|
-
"utf8",
|
|
309
|
-
);
|
|
1443
|
+
await handle.writeFile(`${JSON.stringify({ pid: process.pid, startedAt: nowIso() })}\n`, "utf8");
|
|
310
1444
|
} catch (writeError) {
|
|
311
1445
|
await handle.close().catch(() => undefined);
|
|
312
1446
|
await rm(paths.lock, { force: true }).catch(() => undefined);
|
|
@@ -338,9 +1472,7 @@ async function acquireOperationLock() {
|
|
|
338
1472
|
}
|
|
339
1473
|
stale =
|
|
340
1474
|
ownerAlive === false ||
|
|
341
|
-
(ownerAlive === null &&
|
|
342
|
-
Number.isFinite(started) &&
|
|
343
|
-
Date.now() - started > LOCK_STALE_MILLISECONDS);
|
|
1475
|
+
(ownerAlive === null && Number.isFinite(started) && Date.now() - started > LOCK_STALE_MILLISECONDS);
|
|
344
1476
|
} catch {
|
|
345
1477
|
const info = await lstat(paths.lock);
|
|
346
1478
|
stale = Date.now() - info.mtimeMs > LOCK_STALE_MILLISECONDS;
|
|
@@ -372,17 +1504,44 @@ function jsonEqual(left, right) {
|
|
|
372
1504
|
);
|
|
373
1505
|
}
|
|
374
1506
|
|
|
1507
|
+
function jsonDeepEqual(left, right) {
|
|
1508
|
+
if (left === right) return true;
|
|
1509
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
1510
|
+
return (
|
|
1511
|
+
Array.isArray(left) &&
|
|
1512
|
+
Array.isArray(right) &&
|
|
1513
|
+
left.length === right.length &&
|
|
1514
|
+
left.every((item, index) => jsonDeepEqual(item, right[index]))
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
if (
|
|
1518
|
+
left === null ||
|
|
1519
|
+
right === null ||
|
|
1520
|
+
typeof left !== "object" ||
|
|
1521
|
+
typeof right !== "object"
|
|
1522
|
+
) {
|
|
1523
|
+
return false;
|
|
1524
|
+
}
|
|
1525
|
+
const leftKeys = Object.keys(left).sort();
|
|
1526
|
+
const rightKeys = Object.keys(right).sort();
|
|
1527
|
+
return (
|
|
1528
|
+
leftKeys.length === rightKeys.length &&
|
|
1529
|
+
leftKeys.every(
|
|
1530
|
+
(key, index) => key === rightKeys[index] && jsonDeepEqual(left[key], right[key]),
|
|
1531
|
+
)
|
|
1532
|
+
);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
375
1535
|
function markerFor(host) {
|
|
1536
|
+
if (host === "cursor") return CURSOR_MARKER;
|
|
1537
|
+
if (host === "antigravity") return ANTIGRAVITY_MARKER;
|
|
1538
|
+
if (host === "grok") return GROK_MARKER;
|
|
1539
|
+
if (host === "opencode") return OPENCODE_MARKER;
|
|
376
1540
|
return host === "claude" ? CLAUDE_MARKER : CODEX_MARKER;
|
|
377
1541
|
}
|
|
378
1542
|
|
|
379
1543
|
export function markerMatches(value, host = DEFAULT_HOST) {
|
|
380
|
-
return (
|
|
381
|
-
value !== null &&
|
|
382
|
-
typeof value === "object" &&
|
|
383
|
-
!Array.isArray(value) &&
|
|
384
|
-
jsonEqual(value, markerFor(host))
|
|
385
|
-
);
|
|
1544
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && jsonEqual(value, markerFor(host));
|
|
386
1545
|
}
|
|
387
1546
|
|
|
388
1547
|
export function isSafeArchivePath(value) {
|
|
@@ -422,15 +1581,7 @@ export function parseChecksumText(text, expectedName = ASSET_NAME) {
|
|
|
422
1581
|
}
|
|
423
1582
|
|
|
424
1583
|
export function parseCli(argv) {
|
|
425
|
-
const actions = new Set([
|
|
426
|
-
"install",
|
|
427
|
-
"status",
|
|
428
|
-
"update",
|
|
429
|
-
"remove",
|
|
430
|
-
"verify",
|
|
431
|
-
"auto-update",
|
|
432
|
-
"help",
|
|
433
|
-
]);
|
|
1584
|
+
const actions = new Set(["install", "status", "update", "remove", "verify", "auto-update", "help"]);
|
|
434
1585
|
const args = [...argv];
|
|
435
1586
|
let action = "install";
|
|
436
1587
|
if (args[0] && !args[0].startsWith("-")) {
|
|
@@ -457,14 +1608,13 @@ export function parseCli(argv) {
|
|
|
457
1608
|
host: action === "auto-update" ? ALL_HOST : DEFAULT_HOST,
|
|
458
1609
|
asset: null,
|
|
459
1610
|
checksum: null,
|
|
460
|
-
hostAssets: {
|
|
461
|
-
claude: { asset: null, checksum: null },
|
|
462
|
-
codex: { asset: null, checksum: null },
|
|
463
|
-
},
|
|
1611
|
+
hostAssets: Object.fromEntries(SUPPORTED_HOSTS.map((host) => [host, { asset: null, checksum: null }])),
|
|
464
1612
|
channel: "stable",
|
|
465
1613
|
intervalHours: AUTO_UPDATE_DEFAULT_INTERVAL_HOURS,
|
|
466
1614
|
allowMajor: false,
|
|
467
1615
|
force: false,
|
|
1616
|
+
purge: false,
|
|
1617
|
+
resetTrust: false,
|
|
468
1618
|
};
|
|
469
1619
|
const seenOptions = new Set();
|
|
470
1620
|
while (args.length > 0) {
|
|
@@ -492,6 +1642,16 @@ export function parseCli(argv) {
|
|
|
492
1642
|
options.force = true;
|
|
493
1643
|
continue;
|
|
494
1644
|
}
|
|
1645
|
+
if (flag === "--purge") {
|
|
1646
|
+
seenOptions.add("purge");
|
|
1647
|
+
options.purge = true;
|
|
1648
|
+
continue;
|
|
1649
|
+
}
|
|
1650
|
+
if (flag === "--reset-trust") {
|
|
1651
|
+
seenOptions.add("reset-trust");
|
|
1652
|
+
options.resetTrust = true;
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
495
1655
|
if (flag === "--channel") {
|
|
496
1656
|
seenOptions.add("channel");
|
|
497
1657
|
const channel = args.shift();
|
|
@@ -518,7 +1678,7 @@ export function parseCli(argv) {
|
|
|
518
1678
|
options.intervalHours = value;
|
|
519
1679
|
continue;
|
|
520
1680
|
}
|
|
521
|
-
const qualified = flag?.match(
|
|
1681
|
+
const qualified = flag?.match(new RegExp(`^--(asset|checksum)-(${SUPPORTED_HOSTS.join("|")})$`, "u"));
|
|
522
1682
|
if (qualified) {
|
|
523
1683
|
seenOptions.add(qualified[1]);
|
|
524
1684
|
const value = args.shift();
|
|
@@ -550,17 +1710,7 @@ export function parseCli(argv) {
|
|
|
550
1710
|
if (options.host === ALL_HOST && options.asset !== null) {
|
|
551
1711
|
fail("--host all requires host-qualified --asset-<host> and --checksum-<host> options");
|
|
552
1712
|
}
|
|
553
|
-
if (
|
|
554
|
-
options.host === ALL_HOST &&
|
|
555
|
-
SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset !== null) &&
|
|
556
|
-
SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset === null)
|
|
557
|
-
) {
|
|
558
|
-
fail("local --host all verification requires asset and checksum pairs for both hosts");
|
|
559
|
-
}
|
|
560
|
-
if (
|
|
561
|
-
options.host !== ALL_HOST &&
|
|
562
|
-
SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset !== null)
|
|
563
|
-
) {
|
|
1713
|
+
if (options.host !== ALL_HOST && SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset !== null)) {
|
|
564
1714
|
fail("host-qualified asset options are only valid with --host all");
|
|
565
1715
|
}
|
|
566
1716
|
if (["status", "remove", "help"].includes(options.action) && options.asset !== null) {
|
|
@@ -575,13 +1725,11 @@ export function parseCli(argv) {
|
|
|
575
1725
|
if (
|
|
576
1726
|
options.action === "auto-update" &&
|
|
577
1727
|
options.autoUpdateAction !== "run" &&
|
|
578
|
-
(options.asset !== null ||
|
|
579
|
-
SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset !== null))
|
|
1728
|
+
(options.asset !== null || SUPPORTED_HOSTS.some((host) => options.hostAssets[host].asset !== null))
|
|
580
1729
|
) {
|
|
581
1730
|
fail(`auto-update ${options.autoUpdateAction} does not accept local asset options`);
|
|
582
1731
|
}
|
|
583
|
-
const autoUpdateEnable =
|
|
584
|
-
options.action === "auto-update" && options.autoUpdateAction === "enable";
|
|
1732
|
+
const autoUpdateEnable = options.action === "auto-update" && options.autoUpdateAction === "enable";
|
|
585
1733
|
const autoUpdateRun = options.action === "auto-update" && options.autoUpdateAction === "run";
|
|
586
1734
|
for (const policyOption of ["allow-major", "channel", "interval-hours"]) {
|
|
587
1735
|
if (seenOptions.has(policyOption) && !autoUpdateEnable) {
|
|
@@ -591,6 +1739,15 @@ export function parseCli(argv) {
|
|
|
591
1739
|
if (seenOptions.has("force") && !autoUpdateRun) {
|
|
592
1740
|
fail("--force is only valid with auto-update run");
|
|
593
1741
|
}
|
|
1742
|
+
if (seenOptions.has("purge") && options.action !== "remove") {
|
|
1743
|
+
fail("--purge is only valid with remove");
|
|
1744
|
+
}
|
|
1745
|
+
if (seenOptions.has("reset-trust") && (options.action !== "remove" || !options.purge)) {
|
|
1746
|
+
fail("--reset-trust requires remove --purge");
|
|
1747
|
+
}
|
|
1748
|
+
if (seenOptions.has("reset-trust") && !new Set([ALL_HOST, "codex"]).has(options.host)) {
|
|
1749
|
+
fail("--reset-trust is only valid with --host codex or --host all");
|
|
1750
|
+
}
|
|
594
1751
|
if (
|
|
595
1752
|
seenOptions.has("host") &&
|
|
596
1753
|
options.action === "auto-update" &&
|
|
@@ -608,12 +1765,12 @@ function showHelp() {
|
|
|
608
1765
|
console.log(`OpenSocrates ${PRODUCT_VERSION}
|
|
609
1766
|
|
|
610
1767
|
Usage:
|
|
611
|
-
opensocrates install [--host all|codex|
|
|
612
|
-
opensocrates status [--host all|codex|
|
|
613
|
-
opensocrates update [--host all|codex|
|
|
614
|
-
opensocrates remove [--host all|codex|
|
|
615
|
-
opensocrates verify [--host all|codex|
|
|
616
|
-
opensocrates auto-update enable [--host all|codex|
|
|
1768
|
+
opensocrates install [--host all|antigravity|claude|codex|cursor|grok|opencode] [--asset ZIP --checksum FILE]
|
|
1769
|
+
opensocrates status [--host all|antigravity|claude|codex|cursor|grok|opencode]
|
|
1770
|
+
opensocrates update [--host all|antigravity|claude|codex|cursor|grok|opencode] [--asset ZIP --checksum FILE]
|
|
1771
|
+
opensocrates remove [--host all|antigravity|claude|codex|cursor|grok|opencode] [--purge [--reset-trust]]
|
|
1772
|
+
opensocrates verify [--host all|antigravity|claude|codex|cursor|grok|opencode] [--asset ZIP --checksum FILE]
|
|
1773
|
+
opensocrates auto-update enable [--host all|antigravity|claude|codex|cursor|grok|opencode]
|
|
617
1774
|
[--channel stable|next] [--interval-hours ${AUTO_UPDATE_DEFAULT_INTERVAL_HOURS}]
|
|
618
1775
|
[--allow-major]
|
|
619
1776
|
opensocrates auto-update status
|
|
@@ -621,17 +1778,45 @@ Usage:
|
|
|
621
1778
|
|
|
622
1779
|
Without --asset, install, update, and verify download the v${PRODUCT_VERSION}
|
|
623
1780
|
package and checksum from GitHub Releases. The default lifecycle host is codex.
|
|
624
|
-
|
|
625
|
-
and
|
|
1781
|
+
With --host all, supplied host-qualified asset/checksum pairs define the exact
|
|
1782
|
+
transaction set and are never mixed with downloads for other hosts. Without
|
|
1783
|
+
qualified assets, every ready host participates. Automatic updates are opt-in.
|
|
1784
|
+
|
|
1785
|
+
Ordinary remove unregisters the selected host and removes installer-managed
|
|
1786
|
+
files, but may leave host-owned caches, installer state, and Codex hook trust.
|
|
1787
|
+
Add --purge for an explicit payload/data cleanup attempt. Purge preserves Codex
|
|
1788
|
+
hook trust unless --reset-trust is also supplied for codex or all; that explicit
|
|
1789
|
+
option removes only the seven canonical OpenSocrates trust entries. Registration,
|
|
1790
|
+
payload cleanup, host security trust, and user history are reported separately.
|
|
1791
|
+
User history is always preserved. Any unverified, unsafe, or in-use component is
|
|
1792
|
+
reported as pending.
|
|
626
1793
|
`);
|
|
627
1794
|
}
|
|
628
1795
|
|
|
629
1796
|
function managedPaths(host) {
|
|
630
1797
|
const configured =
|
|
631
|
-
host === "
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
1798
|
+
host === "antigravity"
|
|
1799
|
+
? process.env.ANTIGRAVITY_CONFIG_DIR
|
|
1800
|
+
: host === "cursor"
|
|
1801
|
+
? process.env.CURSOR_CONFIG_DIR
|
|
1802
|
+
: host === "grok"
|
|
1803
|
+
? process.env.GROK_HOME
|
|
1804
|
+
: host === "opencode"
|
|
1805
|
+
? process.env.OPENCODE_CONFIG_DIR
|
|
1806
|
+
: host === "claude"
|
|
1807
|
+
? process.env.CLAUDE_CONFIG_DIR
|
|
1808
|
+
: process.env.CODEX_HOME;
|
|
1809
|
+
const defaultHome =
|
|
1810
|
+
host === "antigravity"
|
|
1811
|
+
? join(homedir(), ".gemini", "config")
|
|
1812
|
+
: host === "cursor"
|
|
1813
|
+
? join(homedir(), ".cursor")
|
|
1814
|
+
: host === "grok"
|
|
1815
|
+
? join(homedir(), ".grok")
|
|
1816
|
+
: host === "opencode"
|
|
1817
|
+
? join(homedir(), ".config", "opencode")
|
|
1818
|
+
: join(homedir(), host === "claude" ? ".claude" : ".codex");
|
|
1819
|
+
const configuredHome = resolve(configured ? configured : defaultHome);
|
|
635
1820
|
let hostHome = configuredHome;
|
|
636
1821
|
try {
|
|
637
1822
|
hostHome = realpathSync(configuredHome);
|
|
@@ -640,18 +1825,68 @@ function managedPaths(host) {
|
|
|
640
1825
|
// explicit, non-recursive target. Lexical resolution is the only
|
|
641
1826
|
// available normalization until then.
|
|
642
1827
|
}
|
|
643
|
-
const root =
|
|
1828
|
+
const root =
|
|
1829
|
+
host === "antigravity"
|
|
1830
|
+
? join(hostHome, "plugins", PLUGIN_NAME)
|
|
1831
|
+
: host === "cursor"
|
|
1832
|
+
? join(hostHome, "plugins", "local", PLUGIN_NAME)
|
|
1833
|
+
: host === "grok"
|
|
1834
|
+
? join(hostHome, "plugins", PLUGIN_NAME)
|
|
1835
|
+
: host === "opencode"
|
|
1836
|
+
? join(hostHome, "skills", PLUGIN_NAME)
|
|
1837
|
+
: join(hostHome, "managed-marketplaces", MARKETPLACE_NAME);
|
|
644
1838
|
const layout = HOST_LAYOUTS[host];
|
|
645
1839
|
return {
|
|
646
1840
|
hostHome,
|
|
647
1841
|
root,
|
|
648
1842
|
parent: dirname(root),
|
|
649
1843
|
marker: join(root, MARKER_NAME),
|
|
650
|
-
marketplace: join(root, layout.marketplaceRelative),
|
|
1844
|
+
marketplace: typeof layout.marketplaceRelative === "string" ? join(root, layout.marketplaceRelative) : null,
|
|
651
1845
|
plugin: join(root, layout.pluginRelative),
|
|
1846
|
+
bridge: host === "opencode" ? join(hostHome, "plugins", "opensocrates.js") : null,
|
|
1847
|
+
bridgeMarker: host === "opencode" ? join(hostHome, "plugins", OPENCODE_BRIDGE_MARKER) : null,
|
|
1848
|
+
bridgeParent: host === "opencode" ? join(hostHome, "plugins") : null,
|
|
652
1849
|
};
|
|
653
1850
|
}
|
|
654
1851
|
|
|
1852
|
+
export function purgePathsFor(host) {
|
|
1853
|
+
if (!SUPPORTED_HOSTS.includes(host)) {
|
|
1854
|
+
fail(`unsupported host ${JSON.stringify(host)}`);
|
|
1855
|
+
}
|
|
1856
|
+
const paths = managedPaths(host);
|
|
1857
|
+
const cacheRoot = ["claude", "codex"].includes(host)
|
|
1858
|
+
? join(paths.hostHome, "plugins", "cache", MARKETPLACE_NAME, PLUGIN_NAME)
|
|
1859
|
+
: null;
|
|
1860
|
+
return {
|
|
1861
|
+
...paths,
|
|
1862
|
+
cacheRoot,
|
|
1863
|
+
cacheMarketplaceRoot: cacheRoot === null ? null : dirname(cacheRoot),
|
|
1864
|
+
pluginData:
|
|
1865
|
+
host === "claude"
|
|
1866
|
+
? [
|
|
1867
|
+
join(paths.hostHome, "plugins", "data", "opensocrates-inline"),
|
|
1868
|
+
join(paths.hostHome, "plugins", "data", "opensocrates-opensocrates"),
|
|
1869
|
+
]
|
|
1870
|
+
: [],
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
// Directory that holds staging trees and rollback backups for one host.
|
|
1875
|
+
//
|
|
1876
|
+
// Grok scans every directory below ~/.grok/plugins, including dot-prefixed
|
|
1877
|
+
// ones, so a staging tree or rollback backup left there would be discovered as
|
|
1878
|
+
// a second OpenSocrates plugin. An interrupted install must not be able to
|
|
1879
|
+
// leave a shadow copy inside Grok's plugin discovery root, so both transient
|
|
1880
|
+
// directories live in the Grok home instead, next to the scanned directory.
|
|
1881
|
+
function transientParent(host, paths) {
|
|
1882
|
+
return host === "grok" ? paths.hostHome : paths.parent;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
export function transientPathsFor(host) {
|
|
1886
|
+
const paths = managedPaths(host);
|
|
1887
|
+
return { root: paths.root, parent: paths.parent, transient: transientParent(host, paths) };
|
|
1888
|
+
}
|
|
1889
|
+
|
|
655
1890
|
function codexBinary() {
|
|
656
1891
|
return process.env.CODEX_BIN || "codex";
|
|
657
1892
|
}
|
|
@@ -660,6 +1895,26 @@ function claudeBinary() {
|
|
|
660
1895
|
return process.env.CLAUDE_BIN || "claude";
|
|
661
1896
|
}
|
|
662
1897
|
|
|
1898
|
+
function cursorBinary() {
|
|
1899
|
+
return process.env.CURSOR_BIN || "cursor";
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
function cursorAppPaths() {
|
|
1903
|
+
return ["/Applications/Cursor.app", join(homedir(), "Applications", "Cursor.app")];
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function antigravityBinary() {
|
|
1907
|
+
return process.env.AGY_BIN || "agy";
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
function grokBinary() {
|
|
1911
|
+
return process.env.GROK_BIN || "grok";
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
function opencodeBinary() {
|
|
1915
|
+
return process.env.OPENCODE_BIN || "opencode";
|
|
1916
|
+
}
|
|
1917
|
+
|
|
663
1918
|
function run(command, args, { allowFailure = false } = {}) {
|
|
664
1919
|
const result = spawnSync(command, args, {
|
|
665
1920
|
encoding: "utf8",
|
|
@@ -707,6 +1962,10 @@ function runClaudeJson(args) {
|
|
|
707
1962
|
return runJson(claudeBinary(), args, "Claude Code");
|
|
708
1963
|
}
|
|
709
1964
|
|
|
1965
|
+
function runGrokJson(args) {
|
|
1966
|
+
return runJson(grokBinary(), args, "Grok Build");
|
|
1967
|
+
}
|
|
1968
|
+
|
|
710
1969
|
function claudeListEntries(payload, wrapper, label) {
|
|
711
1970
|
if (Array.isArray(payload)) {
|
|
712
1971
|
return payload;
|
|
@@ -752,11 +2011,7 @@ function claudeMarketplaceEntries(payload) {
|
|
|
752
2011
|
}
|
|
753
2012
|
|
|
754
2013
|
function claudePluginEntries(payload) {
|
|
755
|
-
return requireUniqueClaudeEntries(
|
|
756
|
-
claudeListEntries(payload, "plugins", "plugin list"),
|
|
757
|
-
"id",
|
|
758
|
-
"plugin list",
|
|
759
|
-
);
|
|
2014
|
+
return requireUniqueClaudeEntries(claudeListEntries(payload, "plugins", "plugin list"), "id", "plugin list");
|
|
760
2015
|
}
|
|
761
2016
|
|
|
762
2017
|
function versionAtLeast(value, minimum) {
|
|
@@ -773,12 +2028,63 @@ function versionAtLeast(value, minimum) {
|
|
|
773
2028
|
}
|
|
774
2029
|
|
|
775
2030
|
function requireHostCli(host, { authenticated = false } = {}) {
|
|
2031
|
+
if (host === "grok") {
|
|
2032
|
+
const result = run(grokBinary(), ["--version"]);
|
|
2033
|
+
if (!versionAtLeast(result.stdout, [1, 0, 3])) {
|
|
2034
|
+
fail(`Grok Build 1.0.3 or later is required; got ${result.stdout.trim()}`);
|
|
2035
|
+
}
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
if (host === "cursor") {
|
|
2039
|
+
if (process.env.CURSOR_CONFIG_DIR) return;
|
|
2040
|
+
if (process.env.CURSOR_BIN) {
|
|
2041
|
+
const result = run(cursorBinary(), ["--version"]);
|
|
2042
|
+
if (!versionAtLeast(result.stdout, [2, 5, 0])) {
|
|
2043
|
+
fail(`Cursor 2.5.0 or later is required; got ${result.stdout.trim()}`);
|
|
2044
|
+
}
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
const app = cursorAppPaths().find((candidate) => existsSync(candidate));
|
|
2048
|
+
if (app) {
|
|
2049
|
+
const result = run(
|
|
2050
|
+
"/usr/bin/plutil",
|
|
2051
|
+
["-extract", "CFBundleShortVersionString", "raw", join(app, "Contents", "Info.plist")],
|
|
2052
|
+
{ allowFailure: true },
|
|
2053
|
+
);
|
|
2054
|
+
if (result.status !== 0 || !versionAtLeast(result.stdout, [2, 5, 0])) {
|
|
2055
|
+
fail("Cursor 2.5.0 or later is required for Agent Plugin support");
|
|
2056
|
+
}
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
const result = run(cursorBinary(), ["--version"]);
|
|
2060
|
+
if (!versionAtLeast(result.stdout, [2, 5, 0])) {
|
|
2061
|
+
fail(`Cursor 2.5.0 or later is required; got ${result.stdout.trim()}`);
|
|
2062
|
+
}
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
if (host === "antigravity") {
|
|
2066
|
+
if (process.env.ANTIGRAVITY_CONFIG_DIR) return;
|
|
2067
|
+
run(antigravityBinary(), ["--version"]);
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
if (host === "opencode") {
|
|
2071
|
+
// OPENCODE_CONFIG_DIR only redirects where the managed files are written;
|
|
2072
|
+
// it does not waive the supported-host gate. Unlike the content-only
|
|
2073
|
+
// file-drop hosts above, OpenCode receives an executing bridge bound to the
|
|
2074
|
+
// stable chat.message contract, and the shipped package declares
|
|
2075
|
+
// minimum_opencode_version 1.18.18 with a fail-closed <2.0.0 ceiling.
|
|
2076
|
+
// Installing that bridge without confirming the host version would ship
|
|
2077
|
+
// executable code against an unverified plugin API.
|
|
2078
|
+
const result = run(opencodeBinary(), ["--version"]);
|
|
2079
|
+
if (!versionAtLeast(result.stdout, [1, 18, 18]) || versionAtLeast(result.stdout, [2, 0, 0])) {
|
|
2080
|
+
fail(`OpenCode >=1.18.18 and <2.0.0 is required; got ${result.stdout.trim()}`);
|
|
2081
|
+
}
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
776
2084
|
if (host === "claude") {
|
|
777
2085
|
const result = run(claudeBinary(), ["--version"]);
|
|
778
2086
|
if (!versionAtLeast(result.stdout, [2, 1, 205])) {
|
|
779
|
-
fail(
|
|
780
|
-
`Claude Code 2.1.205 or later is required for structured selector output; got ${result.stdout.trim()}`,
|
|
781
|
-
);
|
|
2087
|
+
fail(`Claude Code 2.1.205 or later is required for structured selector output; got ${result.stdout.trim()}`);
|
|
782
2088
|
}
|
|
783
2089
|
if (authenticated) {
|
|
784
2090
|
run(claudeBinary(), ["auth", "status"]);
|
|
@@ -792,6 +2098,12 @@ function requireHostCli(host, { authenticated = false } = {}) {
|
|
|
792
2098
|
}
|
|
793
2099
|
|
|
794
2100
|
function marketplaceEntries(host) {
|
|
2101
|
+
if (FILE_DROP_HOSTS.includes(host) || host === "opencode") {
|
|
2102
|
+
const paths = managedPaths(host);
|
|
2103
|
+
const present =
|
|
2104
|
+
existsSync(paths.root) || (host === "opencode" && (existsSync(paths.bridge) || existsSync(paths.bridgeMarker)));
|
|
2105
|
+
return present ? [{ name: MARKETPLACE_NAME, root: paths.root }] : [];
|
|
2106
|
+
}
|
|
795
2107
|
const payload =
|
|
796
2108
|
host === "claude"
|
|
797
2109
|
? runClaudeJson(["plugin", "marketplace", "list", "--json"])
|
|
@@ -812,7 +2124,124 @@ function marketplaceEntry(host) {
|
|
|
812
2124
|
return matches[0] ?? null;
|
|
813
2125
|
}
|
|
814
2126
|
|
|
815
|
-
|
|
2127
|
+
// Read Grok Build's machine-readable plugin state.
|
|
2128
|
+
//
|
|
2129
|
+
// The managed Grok files are host-independent content, so inspecting or
|
|
2130
|
+
// removing what the installer owns must not depend on a runnable `grok`
|
|
2131
|
+
// command: a user who uninstalls Grok Build still has to be able to clean up.
|
|
2132
|
+
// Transactional callers pass requireHostState so that install and update keep
|
|
2133
|
+
// confirming activation against the host itself.
|
|
2134
|
+
function grokInspection(requireHostState) {
|
|
2135
|
+
if (requireHostState) return runGrokJson(["inspect", "--json"]);
|
|
2136
|
+
const result = run(grokBinary(), ["inspect", "--json"], { allowFailure: true });
|
|
2137
|
+
if (result.error || result.status !== 0) {
|
|
2138
|
+
const detail = result.error?.message || result.stderr?.trim() || `exit ${result.status}`;
|
|
2139
|
+
console.warn(
|
|
2140
|
+
`warning: could not read Grok Build plugin state (${detail}); ` +
|
|
2141
|
+
"reporting the managed files only",
|
|
2142
|
+
);
|
|
2143
|
+
return null;
|
|
2144
|
+
}
|
|
2145
|
+
try {
|
|
2146
|
+
const payload = JSON.parse(result.stdout);
|
|
2147
|
+
if (payload === null || typeof payload !== "object") {
|
|
2148
|
+
fail("Grok Build returned a non-container JSON value");
|
|
2149
|
+
}
|
|
2150
|
+
return payload;
|
|
2151
|
+
} catch (error) {
|
|
2152
|
+
if (error instanceof InstallerError) throw error;
|
|
2153
|
+
fail(`Grok Build returned invalid JSON for inspect --json: ${error.message}`);
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
function pluginState(host, { requireHostState = false } = {}) {
|
|
2158
|
+
if (FILE_DROP_HOSTS.includes(host)) {
|
|
2159
|
+
const paths = managedPaths(host);
|
|
2160
|
+
if (!existsSync(paths.root)) return { kind: "missing", version: null };
|
|
2161
|
+
let manifest;
|
|
2162
|
+
try {
|
|
2163
|
+
manifest = JSON.parse(readFileSync(join(paths.root, "plugin.json"), "utf8"));
|
|
2164
|
+
} catch (error) {
|
|
2165
|
+
fail(`${host} plugin manifest is unreadable: ${error.message}`);
|
|
2166
|
+
}
|
|
2167
|
+
if (
|
|
2168
|
+
manifest === null ||
|
|
2169
|
+
typeof manifest !== "object" ||
|
|
2170
|
+
Array.isArray(manifest) ||
|
|
2171
|
+
(host === "cursor" &&
|
|
2172
|
+
manifest.$schema !== "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json") ||
|
|
2173
|
+
(host === "grok" && manifest.skills !== "./skills") ||
|
|
2174
|
+
manifest.name !== PLUGIN_NAME ||
|
|
2175
|
+
typeof manifest.version !== "string" ||
|
|
2176
|
+
manifest.version.trim().length === 0
|
|
2177
|
+
) {
|
|
2178
|
+
fail(`${host} reported an invalid OpenSocrates plugin manifest`);
|
|
2179
|
+
}
|
|
2180
|
+
if (host !== "grok") return { kind: "installed", version: manifest.version };
|
|
2181
|
+
const payload = grokInspection(requireHostState);
|
|
2182
|
+
if (payload === null) return { kind: "installed", version: manifest.version };
|
|
2183
|
+
if (!Array.isArray(payload.plugins)) {
|
|
2184
|
+
fail("Grok Build inspect returned an unexpected plugin schema");
|
|
2185
|
+
}
|
|
2186
|
+
const matches = payload.plugins.filter((entry) => entry?.name === PLUGIN_NAME);
|
|
2187
|
+
if (matches.length !== 1) {
|
|
2188
|
+
fail(
|
|
2189
|
+
matches.length === 0
|
|
2190
|
+
? "Grok Build did not discover the managed OpenSocrates plugin"
|
|
2191
|
+
: "Grok Build reported duplicate OpenSocrates plugins",
|
|
2192
|
+
);
|
|
2193
|
+
}
|
|
2194
|
+
const entry = matches[0];
|
|
2195
|
+
if (
|
|
2196
|
+
typeof entry.enabled !== "boolean" ||
|
|
2197
|
+
typeof entry.path !== "string" ||
|
|
2198
|
+
entry.provides?.skills !== 1 ||
|
|
2199
|
+
entry.provides?.agents !== 0 ||
|
|
2200
|
+
entry.provides?.hooks !== false ||
|
|
2201
|
+
entry.provides?.mcpServers !== 0
|
|
2202
|
+
) {
|
|
2203
|
+
fail("Grok Build reported an invalid OpenSocrates plugin state");
|
|
2204
|
+
}
|
|
2205
|
+
let observedPath = resolve(entry.path);
|
|
2206
|
+
try {
|
|
2207
|
+
observedPath = realpathSync(observedPath);
|
|
2208
|
+
} catch {
|
|
2209
|
+
// The managed root exists here, so a missing reported path is invalid below.
|
|
2210
|
+
}
|
|
2211
|
+
let expectedPath = resolve(paths.root);
|
|
2212
|
+
try {
|
|
2213
|
+
expectedPath = realpathSync(expectedPath);
|
|
2214
|
+
} catch {
|
|
2215
|
+
// Ownership checks report a more specific error for a missing root.
|
|
2216
|
+
}
|
|
2217
|
+
if (observedPath !== expectedPath) {
|
|
2218
|
+
fail(`Grok Build resolved OpenSocrates from an unmanaged location: ${observedPath}`);
|
|
2219
|
+
}
|
|
2220
|
+
return { kind: entry.enabled ? "installed" : "disabled", version: manifest.version };
|
|
2221
|
+
}
|
|
2222
|
+
if (host === "opencode") {
|
|
2223
|
+
const paths = managedPaths(host);
|
|
2224
|
+
if (!existsSync(paths.root) && !existsSync(paths.bridge) && !existsSync(paths.bridgeMarker)) {
|
|
2225
|
+
return { kind: "missing", version: null };
|
|
2226
|
+
}
|
|
2227
|
+
let manifest;
|
|
2228
|
+
try {
|
|
2229
|
+
manifest = JSON.parse(readFileSync(join(paths.root, ".opensocrates-package", "opencode-plugin.json"), "utf8"));
|
|
2230
|
+
} catch (error) {
|
|
2231
|
+
fail(`opencode installed manifest is unreadable: ${error.message}`);
|
|
2232
|
+
}
|
|
2233
|
+
if (
|
|
2234
|
+
manifest === null ||
|
|
2235
|
+
typeof manifest !== "object" ||
|
|
2236
|
+
Array.isArray(manifest) ||
|
|
2237
|
+
manifest.name !== PLUGIN_NAME ||
|
|
2238
|
+
typeof manifest.version !== "string" ||
|
|
2239
|
+
manifest.version.trim().length === 0
|
|
2240
|
+
) {
|
|
2241
|
+
fail("opencode reported an invalid OpenSocrates installation manifest");
|
|
2242
|
+
}
|
|
2243
|
+
return { kind: "installed", version: manifest.version };
|
|
2244
|
+
}
|
|
816
2245
|
if (host === "claude") {
|
|
817
2246
|
const installed = claudePluginEntries(runClaudeJson(["plugin", "list", "--json"]));
|
|
818
2247
|
const matches = installed.filter((entry) => entry?.id === PLUGIN_ID);
|
|
@@ -831,9 +2260,7 @@ function pluginState(host) {
|
|
|
831
2260
|
version: entry.version ?? null,
|
|
832
2261
|
};
|
|
833
2262
|
}
|
|
834
|
-
const payload = runCodexJson([
|
|
835
|
-
"plugin", "list", "--marketplace", MARKETPLACE_NAME, "--available", "--json",
|
|
836
|
-
]);
|
|
2263
|
+
const payload = runCodexJson(["plugin", "list", "--marketplace", MARKETPLACE_NAME, "--available", "--json"]);
|
|
837
2264
|
const installed = Array.isArray(payload.installed) ? payload.installed : null;
|
|
838
2265
|
const available = Array.isArray(payload.available) ? payload.available : null;
|
|
839
2266
|
if (installed === null || available === null) {
|
|
@@ -845,10 +2272,16 @@ function pluginState(host) {
|
|
|
845
2272
|
fail(`Codex reported duplicate entries for ${PLUGIN_ID}`);
|
|
846
2273
|
}
|
|
847
2274
|
if (installedMatches.length === 1) {
|
|
848
|
-
return {
|
|
2275
|
+
return {
|
|
2276
|
+
kind: "installed",
|
|
2277
|
+
version: installedMatches[0].version ?? null,
|
|
2278
|
+
};
|
|
849
2279
|
}
|
|
850
2280
|
if (availableMatches.length === 1) {
|
|
851
|
-
return {
|
|
2281
|
+
return {
|
|
2282
|
+
kind: "available",
|
|
2283
|
+
version: availableMatches[0].version ?? null,
|
|
2284
|
+
};
|
|
852
2285
|
}
|
|
853
2286
|
return { kind: "missing", version: null };
|
|
854
2287
|
}
|
|
@@ -861,13 +2294,12 @@ function detectLegacyClaudeInstallation() {
|
|
|
861
2294
|
entry.name.toLowerCase() === MARKETPLACE_NAME,
|
|
862
2295
|
);
|
|
863
2296
|
const plugins = claudePluginEntries(runClaudeJson(["plugin", "list", "--json"]));
|
|
864
|
-
const legacyPlugins = plugins.filter(
|
|
865
|
-
(entry) =>
|
|
866
|
-
entry.id !== PLUGIN_ID &&
|
|
867
|
-
entry.id.toLowerCase() === PLUGIN_ID,
|
|
868
|
-
);
|
|
2297
|
+
const legacyPlugins = plugins.filter((entry) => entry.id !== PLUGIN_ID && entry.id.toLowerCase() === PLUGIN_ID);
|
|
869
2298
|
const names = marketplaces.map((entry) => entry.name).join(", ") || "OpenSocrates";
|
|
870
|
-
return {
|
|
2299
|
+
return {
|
|
2300
|
+
found: marketplaces.length > 0 || legacyPlugins.length > 0,
|
|
2301
|
+
names,
|
|
2302
|
+
};
|
|
871
2303
|
}
|
|
872
2304
|
|
|
873
2305
|
// Install and update write into the managed root, so a case-variant pre-1.0
|
|
@@ -918,18 +2350,99 @@ async function requireOwnedRoot(root, host) {
|
|
|
918
2350
|
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
919
2351
|
fail(`managed marketplace path is not an owned directory: ${root}`);
|
|
920
2352
|
}
|
|
921
|
-
const
|
|
2353
|
+
const markerPath = join(root, MARKER_NAME);
|
|
2354
|
+
await requireRegularFileEntry(markerPath, `${host} ownership marker`);
|
|
2355
|
+
const marker = await readJsonObject(markerPath);
|
|
922
2356
|
if (!markerMatches(marker, host)) {
|
|
923
2357
|
fail(`managed marketplace path has no valid ownership marker: ${root}`);
|
|
924
2358
|
}
|
|
925
2359
|
}
|
|
926
2360
|
|
|
927
|
-
async function sha256File(target) {
|
|
928
|
-
const hash = createHash("sha256");
|
|
929
|
-
for await (const chunk of createReadStream(target)) {
|
|
930
|
-
hash.update(chunk);
|
|
2361
|
+
async function sha256File(target) {
|
|
2362
|
+
const hash = createHash("sha256");
|
|
2363
|
+
for await (const chunk of createReadStream(target)) {
|
|
2364
|
+
hash.update(chunk);
|
|
2365
|
+
}
|
|
2366
|
+
return hash.digest("hex");
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
function openCodeSidecar(bridgeSha256) {
|
|
2370
|
+
return {
|
|
2371
|
+
...OPENCODE_MARKER,
|
|
2372
|
+
version: PRODUCT_VERSION,
|
|
2373
|
+
bridgeSha256,
|
|
2374
|
+
};
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
async function ensureSafeDirectory(target, label) {
|
|
2378
|
+
if (await exists(target)) {
|
|
2379
|
+
const info = await lstat(target);
|
|
2380
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
2381
|
+
fail(`refusing to use an unsafe ${label} directory: ${target}`);
|
|
2382
|
+
}
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
await mkdir(target, { recursive: true, mode: 0o700 });
|
|
2386
|
+
const info = await lstat(target);
|
|
2387
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
2388
|
+
fail(`refusing to use an unsafe ${label} directory: ${target}`);
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
async function ensureOpenCodeDirectories(paths) {
|
|
2393
|
+
await ensureSafeDirectory(paths.hostHome, "OpenCode configuration");
|
|
2394
|
+
await ensureSafeDirectory(paths.parent, "OpenCode skills");
|
|
2395
|
+
await ensureSafeDirectory(paths.bridgeParent, "OpenCode plugins");
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
async function verifyOpenCodeInstallation(paths) {
|
|
2399
|
+
await requireOwnedRoot(paths.root, "opencode");
|
|
2400
|
+
for (const [target, label] of [
|
|
2401
|
+
[paths.bridge, "plugin bridge"],
|
|
2402
|
+
[paths.bridgeMarker, "plugin ownership sidecar"],
|
|
2403
|
+
]) {
|
|
2404
|
+
if (!(await exists(target))) fail(`OpenCode ${label} is missing: ${target}`);
|
|
2405
|
+
const info = await lstat(target);
|
|
2406
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
2407
|
+
fail(`OpenCode ${label} is not an owned regular file: ${target}`);
|
|
2408
|
+
}
|
|
931
2409
|
}
|
|
932
|
-
|
|
2410
|
+
const sidecar = await readJsonObject(paths.bridgeMarker);
|
|
2411
|
+
const sidecarMarker = Object.fromEntries(Object.keys(OPENCODE_MARKER).map((key) => [key, sidecar[key]]));
|
|
2412
|
+
if (
|
|
2413
|
+
!markerMatches(sidecarMarker, "opencode") ||
|
|
2414
|
+
sidecar.version !== PRODUCT_VERSION ||
|
|
2415
|
+
typeof sidecar.bridgeSha256 !== "string" ||
|
|
2416
|
+
sidecar.bridgeSha256 !== (await sha256File(paths.bridge))
|
|
2417
|
+
) {
|
|
2418
|
+
fail("OpenCode plugin bridge has an invalid ownership sidecar or checksum");
|
|
2419
|
+
}
|
|
2420
|
+
const manifest = await readJsonObject(join(paths.root, OPENCODE_INSTALL_MANIFEST));
|
|
2421
|
+
if (
|
|
2422
|
+
manifest.schema !== "opensocrates.opencode-installation/1.0.0" ||
|
|
2423
|
+
manifest.version !== PRODUCT_VERSION ||
|
|
2424
|
+
manifest.bridgeSha256 !== sidecar.bridgeSha256 ||
|
|
2425
|
+
manifest.files === null ||
|
|
2426
|
+
typeof manifest.files !== "object" ||
|
|
2427
|
+
Array.isArray(manifest.files)
|
|
2428
|
+
) {
|
|
2429
|
+
fail("OpenCode installation inventory is invalid");
|
|
2430
|
+
}
|
|
2431
|
+
const actualFiles = new Set(await walkFiles(paths.root));
|
|
2432
|
+
actualFiles.delete(OPENCODE_INSTALL_MANIFEST);
|
|
2433
|
+
const declaredFiles = Object.keys(manifest.files);
|
|
2434
|
+
if (
|
|
2435
|
+
actualFiles.size !== declaredFiles.length ||
|
|
2436
|
+
declaredFiles.some((item) => !isSafeArchivePath(item) || !actualFiles.has(item))
|
|
2437
|
+
) {
|
|
2438
|
+
fail("OpenCode installation inventory does not cover the complete managed skill");
|
|
2439
|
+
}
|
|
2440
|
+
for (const item of declaredFiles) {
|
|
2441
|
+
if (manifest.files[item] !== (await sha256File(join(paths.root, ...item.split("/"))))) {
|
|
2442
|
+
fail(`OpenCode installed file checksum mismatch for ${item}`);
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
return manifest;
|
|
933
2446
|
}
|
|
934
2447
|
|
|
935
2448
|
async function downloadFile(url, destination) {
|
|
@@ -962,6 +2475,11 @@ function localAssetInputs(options, host) {
|
|
|
962
2475
|
return { asset: options.asset, checksum: options.checksum };
|
|
963
2476
|
}
|
|
964
2477
|
|
|
2478
|
+
function qualifiedAssetHosts(options) {
|
|
2479
|
+
if (options.host !== ALL_HOST) return [];
|
|
2480
|
+
return SUPPORTED_HOSTS.filter((host) => options.hostAssets[host].asset !== null);
|
|
2481
|
+
}
|
|
2482
|
+
|
|
965
2483
|
async function resolveAssetInputs(options, scratch, host) {
|
|
966
2484
|
const local = localAssetInputs(options, host);
|
|
967
2485
|
if (local.asset !== null) {
|
|
@@ -1063,10 +2581,7 @@ async function verifyPackageChecksums(pluginRoot) {
|
|
|
1063
2581
|
}
|
|
1064
2582
|
}
|
|
1065
2583
|
const actualFiles = new Set((await walkFiles(pluginRoot)).filter((item) => item !== "checksums.sha256"));
|
|
1066
|
-
if (
|
|
1067
|
-
actualFiles.size !== declared.size ||
|
|
1068
|
-
[...actualFiles].some((item) => !declared.has(item))
|
|
1069
|
-
) {
|
|
2584
|
+
if (actualFiles.size !== declared.size || [...actualFiles].some((item) => !declared.has(item))) {
|
|
1070
2585
|
fail("package checksum manifest does not cover the complete archive");
|
|
1071
2586
|
}
|
|
1072
2587
|
return declared.size;
|
|
@@ -1088,20 +2603,508 @@ async function verifyExtractedPackage(pluginRoot, host) {
|
|
|
1088
2603
|
) {
|
|
1089
2604
|
fail("package release manifest does not match this installer");
|
|
1090
2605
|
}
|
|
1091
|
-
|
|
1092
|
-
pluginRoot,
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
2606
|
+
if (HOST_LAYOUTS[host].requiresRuntime) {
|
|
2607
|
+
const runtime = join(pluginRoot, "runtime", "darwin-arm64", "opensocrates-runtime", "opensocrates-runtime");
|
|
2608
|
+
const runtimeInfo = await stat(runtime);
|
|
2609
|
+
if (!runtimeInfo.isFile() || (runtimeInfo.mode & 0o111) === 0) {
|
|
2610
|
+
fail("package is missing the executable darwin-arm64 runtime");
|
|
2611
|
+
}
|
|
2612
|
+
} else if (
|
|
2613
|
+
release.launchers?.length !== 0 ||
|
|
2614
|
+
release.runtime_targets?.length !== 0 ||
|
|
2615
|
+
(await exists(join(pluginRoot, "runtime"))) ||
|
|
2616
|
+
(await exists(join(pluginRoot, "hooks"))) ||
|
|
2617
|
+
(await exists(join(pluginRoot, "bin"))) ||
|
|
2618
|
+
((host === "cursor" || host === "grok") && (await exists(join(pluginRoot, "mcp.json")))) ||
|
|
2619
|
+
(host === "grok" &&
|
|
2620
|
+
((await exists(join(pluginRoot, "commands"))) ||
|
|
2621
|
+
(await exists(join(pluginRoot, "agents"))) ||
|
|
2622
|
+
(await exists(join(pluginRoot, ".mcp.json")))))
|
|
2623
|
+
) {
|
|
2624
|
+
fail(`${host} package contains an unexpected native runtime or launcher surface`);
|
|
2625
|
+
}
|
|
2626
|
+
if (host === "opencode") {
|
|
2627
|
+
if (
|
|
2628
|
+
manifest.minimum_opencode_version !== "1.18.18" ||
|
|
2629
|
+
manifest.stable_plugin_hook !== "chat.message" ||
|
|
2630
|
+
manifest.beta_v2_api !== false ||
|
|
2631
|
+
!(await exists(join(pluginRoot, "plugins", "opensocrates.js"))) ||
|
|
2632
|
+
!(await exists(join(pluginRoot, "skills", "opensocrates", "SKILL.md")))
|
|
2633
|
+
) {
|
|
2634
|
+
fail("OpenCode package does not match the verified stable bridge contract");
|
|
2635
|
+
}
|
|
1101
2636
|
}
|
|
1102
2637
|
return verifyPackageChecksums(pluginRoot);
|
|
1103
2638
|
}
|
|
1104
2639
|
|
|
2640
|
+
async function requireSafePathBelow(root, target, label, { allowRoot = false } = {}) {
|
|
2641
|
+
const resolvedRoot = resolve(root);
|
|
2642
|
+
const resolvedTarget = resolve(target);
|
|
2643
|
+
if (await entryExists(resolvedRoot)) {
|
|
2644
|
+
const rootInfo = await lstat(resolvedRoot);
|
|
2645
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
|
|
2646
|
+
fail(`refusing an unsafe ${label} root: ${root}`);
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
const suffix = relative(resolvedRoot, resolvedTarget);
|
|
2650
|
+
if (
|
|
2651
|
+
(!allowRoot && !suffix) ||
|
|
2652
|
+
suffix === ".." ||
|
|
2653
|
+
suffix.startsWith(`..${sep}`) ||
|
|
2654
|
+
resolve(resolvedRoot, suffix) !== resolvedTarget
|
|
2655
|
+
) {
|
|
2656
|
+
fail(`refusing an unsafe ${label} path: ${target}`);
|
|
2657
|
+
}
|
|
2658
|
+
let current = resolvedRoot;
|
|
2659
|
+
for (const component of suffix.split(sep)) {
|
|
2660
|
+
current = join(current, component);
|
|
2661
|
+
if (!(await entryExists(current))) continue;
|
|
2662
|
+
const info = await lstat(current);
|
|
2663
|
+
if (info.isSymbolicLink()) {
|
|
2664
|
+
fail(`refusing a symbolic-link ${label} path: ${current}`);
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
if (await entryExists(resolvedTarget)) {
|
|
2668
|
+
let canonical;
|
|
2669
|
+
let canonicalRoot = resolvedRoot;
|
|
2670
|
+
try {
|
|
2671
|
+
canonical = realpathSync(resolvedTarget);
|
|
2672
|
+
if (await entryExists(resolvedRoot)) canonicalRoot = realpathSync(resolvedRoot);
|
|
2673
|
+
} catch (error) {
|
|
2674
|
+
fail(`cannot canonicalize ${label}: ${error.message}`);
|
|
2675
|
+
}
|
|
2676
|
+
if (canonical !== resolve(canonicalRoot, suffix)) {
|
|
2677
|
+
fail(`refusing a non-canonical ${label} path: ${target}`);
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
async function packageIdentityForPurge(pluginRoot, host, { allowedExtra = () => false } = {}) {
|
|
2683
|
+
const rootInfo = await lstat(pluginRoot);
|
|
2684
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
|
|
2685
|
+
fail(`OpenSocrates ${host} payload is not a real directory: ${pluginRoot}`);
|
|
2686
|
+
}
|
|
2687
|
+
const releasePath = join(pluginRoot, "release-manifest.json");
|
|
2688
|
+
const manifestPath = join(pluginRoot, HOST_LAYOUTS[host].manifestRelative);
|
|
2689
|
+
const checksumPath = join(pluginRoot, "checksums.sha256");
|
|
2690
|
+
for (const [target, label] of [
|
|
2691
|
+
[releasePath, `${host} release manifest`],
|
|
2692
|
+
[manifestPath, `${host} plugin manifest`],
|
|
2693
|
+
[checksumPath, `${host} checksum inventory`],
|
|
2694
|
+
]) {
|
|
2695
|
+
await requireRegularFileEntry(target, label);
|
|
2696
|
+
}
|
|
2697
|
+
const release = await readJsonObject(releasePath);
|
|
2698
|
+
const version = release.product_version;
|
|
2699
|
+
if (
|
|
2700
|
+
release.schema !== "opensocrates.plugin-release-manifest/1.0.0" ||
|
|
2701
|
+
release.host !== host ||
|
|
2702
|
+
typeof version !== "string" ||
|
|
2703
|
+
version.trim().length === 0 ||
|
|
2704
|
+
!Number.isInteger(release.content_revision) ||
|
|
2705
|
+
release.content_revision < 1
|
|
2706
|
+
) {
|
|
2707
|
+
fail(`OpenSocrates ${host} payload has an invalid release identity`);
|
|
2708
|
+
}
|
|
2709
|
+
const manifest = await readJsonObject(manifestPath);
|
|
2710
|
+
if (manifest.name !== PLUGIN_NAME || manifest.version !== version) {
|
|
2711
|
+
fail(`OpenSocrates ${host} payload has a mismatched plugin identity`);
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
const lines = (await readFile(checksumPath, "utf8"))
|
|
2715
|
+
.split(/\r?\n/u)
|
|
2716
|
+
.map((line) => line.trim())
|
|
2717
|
+
.filter(Boolean);
|
|
2718
|
+
if (lines.length === 0) fail(`OpenSocrates ${host} payload has an empty checksum inventory`);
|
|
2719
|
+
const declared = new Set();
|
|
2720
|
+
for (const line of lines) {
|
|
2721
|
+
const match = line.match(/^([a-fA-F0-9]{64})\s+[*]?(.+)$/u);
|
|
2722
|
+
if (!match) fail(`OpenSocrates ${host} payload has an invalid checksum inventory`);
|
|
2723
|
+
const item = match[2].trim();
|
|
2724
|
+
if (!isSafeArchivePath(item) || item === "checksums.sha256" || declared.has(item)) {
|
|
2725
|
+
fail(`OpenSocrates ${host} payload has an unsafe checksum path`);
|
|
2726
|
+
}
|
|
2727
|
+
const target = join(pluginRoot, ...item.split("/"));
|
|
2728
|
+
if (!(await entryExists(target))) fail(`OpenSocrates ${host} payload is missing a declared file`);
|
|
2729
|
+
const info = await lstat(target);
|
|
2730
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
2731
|
+
fail(`OpenSocrates ${host} payload contains an unsafe declared file`);
|
|
2732
|
+
}
|
|
2733
|
+
if ((await sha256File(target)) !== match[1].toLowerCase()) {
|
|
2734
|
+
fail(`OpenSocrates ${host} payload checksum verification failed`);
|
|
2735
|
+
}
|
|
2736
|
+
declared.add(item);
|
|
2737
|
+
}
|
|
2738
|
+
const actual = await walkFiles(pluginRoot);
|
|
2739
|
+
for (const item of actual) {
|
|
2740
|
+
if (item === "checksums.sha256" || declared.has(item) || allowedExtra(item)) continue;
|
|
2741
|
+
fail(`OpenSocrates ${host} payload contains an unowned file: ${item}`);
|
|
2742
|
+
}
|
|
2743
|
+
return { version, files: actual };
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
async function cacheHasLiveUser(versionRoot) {
|
|
2747
|
+
const marker = join(versionRoot, ".in_use");
|
|
2748
|
+
if (!(await entryExists(marker))) return false;
|
|
2749
|
+
const markerInfo = await lstat(marker);
|
|
2750
|
+
if (!markerInfo.isDirectory() || markerInfo.isSymbolicLink()) {
|
|
2751
|
+
fail("OpenSocrates cache has an unsafe .in_use marker");
|
|
2752
|
+
}
|
|
2753
|
+
const entries = await readdir(marker, { withFileTypes: true });
|
|
2754
|
+
for (const entry of entries) {
|
|
2755
|
+
if (entry.isSymbolicLink() || !entry.isFile() || !/^[1-9]\d*$/u.test(entry.name)) {
|
|
2756
|
+
fail("OpenSocrates cache has an unrecognized .in_use entry");
|
|
2757
|
+
}
|
|
2758
|
+
const pid = Number(entry.name);
|
|
2759
|
+
if (!Number.isSafeInteger(pid)) fail("OpenSocrates cache has an invalid .in_use process ID");
|
|
2760
|
+
try {
|
|
2761
|
+
process.kill(pid, 0);
|
|
2762
|
+
return true;
|
|
2763
|
+
} catch (error) {
|
|
2764
|
+
if (error?.code === "EPERM") return true;
|
|
2765
|
+
if (error?.code !== "ESRCH") {
|
|
2766
|
+
fail("OpenSocrates cache use could not be determined safely");
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
return false;
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
async function removeDirectoryIfEmpty(target) {
|
|
2774
|
+
if (!(await entryExists(target))) return false;
|
|
2775
|
+
try {
|
|
2776
|
+
await rmdir(target);
|
|
2777
|
+
return true;
|
|
2778
|
+
} catch (error) {
|
|
2779
|
+
if (error?.code === "ENOTEMPTY" || error?.code === "EEXIST") return false;
|
|
2780
|
+
throw error;
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
async function purgeHostCache(host, paths) {
|
|
2785
|
+
if (paths.cacheRoot === null) {
|
|
2786
|
+
return { component: "plugin-cache", status: "absent", path: paths.cacheRoot };
|
|
2787
|
+
}
|
|
2788
|
+
if (!(await entryExists(paths.cacheRoot))) {
|
|
2789
|
+
let removedEmptyMarketplace = false;
|
|
2790
|
+
if (await entryExists(paths.cacheMarketplaceRoot)) {
|
|
2791
|
+
await requireSafePathBelow(paths.hostHome, paths.cacheMarketplaceRoot, `${host} cache marketplace`);
|
|
2792
|
+
const info = await lstat(paths.cacheMarketplaceRoot);
|
|
2793
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
2794
|
+
fail(`refusing an unsafe ${host} OpenSocrates cache marketplace`);
|
|
2795
|
+
}
|
|
2796
|
+
if ((await readdir(paths.cacheMarketplaceRoot)).length !== 0) {
|
|
2797
|
+
fail(`refusing an unrecognized entry in the ${host} OpenSocrates cache marketplace`);
|
|
2798
|
+
}
|
|
2799
|
+
await rmdir(paths.cacheMarketplaceRoot);
|
|
2800
|
+
removedEmptyMarketplace = true;
|
|
2801
|
+
}
|
|
2802
|
+
return {
|
|
2803
|
+
component: "plugin-cache",
|
|
2804
|
+
status: removedEmptyMarketplace ? "removed" : "absent",
|
|
2805
|
+
path: paths.cacheMarketplaceRoot,
|
|
2806
|
+
};
|
|
2807
|
+
}
|
|
2808
|
+
await requireSafePathBelow(paths.hostHome, paths.cacheRoot, `${host} plugin cache`);
|
|
2809
|
+
const rootInfo = await lstat(paths.cacheRoot);
|
|
2810
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
|
|
2811
|
+
fail(`refusing an unsafe ${host} OpenSocrates plugin-cache root`);
|
|
2812
|
+
}
|
|
2813
|
+
const versions = await readdir(paths.cacheRoot, { withFileTypes: true });
|
|
2814
|
+
let removed = 0;
|
|
2815
|
+
let pending = 0;
|
|
2816
|
+
for (const version of versions) {
|
|
2817
|
+
if (!version.isDirectory() || version.isSymbolicLink()) {
|
|
2818
|
+
fail(`refusing an unrecognized entry in the ${host} OpenSocrates plugin cache`);
|
|
2819
|
+
}
|
|
2820
|
+
const versionRoot = join(paths.cacheRoot, version.name);
|
|
2821
|
+
await requireSafePathBelow(paths.cacheRoot, versionRoot, `${host} cached plugin version`);
|
|
2822
|
+
const identity = await packageIdentityForPurge(versionRoot, host, {
|
|
2823
|
+
allowedExtra: (item) => item === ".orphaned_at" || item.startsWith(".in_use/"),
|
|
2824
|
+
});
|
|
2825
|
+
if (identity.version !== version.name) {
|
|
2826
|
+
fail(`refusing a ${host} cache directory whose version does not match its manifests`);
|
|
2827
|
+
}
|
|
2828
|
+
if (await cacheHasLiveUser(versionRoot)) {
|
|
2829
|
+
pending += 1;
|
|
2830
|
+
continue;
|
|
2831
|
+
}
|
|
2832
|
+
await rm(versionRoot, { recursive: true });
|
|
2833
|
+
removed += 1;
|
|
2834
|
+
}
|
|
2835
|
+
if (pending === 0) {
|
|
2836
|
+
const cacheRootRemoved = await removeDirectoryIfEmpty(paths.cacheRoot);
|
|
2837
|
+
if (!cacheRootRemoved && (await entryExists(paths.cacheRoot))) {
|
|
2838
|
+
return {
|
|
2839
|
+
component: "plugin-cache",
|
|
2840
|
+
status: "pending",
|
|
2841
|
+
path: paths.cacheRoot,
|
|
2842
|
+
detail: "cache-changed-during-purge",
|
|
2843
|
+
};
|
|
2844
|
+
}
|
|
2845
|
+
const marketplaceRemoved = await removeDirectoryIfEmpty(paths.cacheMarketplaceRoot);
|
|
2846
|
+
if (!marketplaceRemoved && (await entryExists(paths.cacheMarketplaceRoot))) {
|
|
2847
|
+
return {
|
|
2848
|
+
component: "plugin-cache",
|
|
2849
|
+
status: "pending",
|
|
2850
|
+
path: paths.cacheMarketplaceRoot,
|
|
2851
|
+
detail: "unrecognized-cache-marketplace-content",
|
|
2852
|
+
};
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
return {
|
|
2856
|
+
component: "plugin-cache",
|
|
2857
|
+
status: pending > 0 ? "pending" : removed > 0 ? "removed" : "absent",
|
|
2858
|
+
path: paths.cacheRoot,
|
|
2859
|
+
detail: pending > 0 ? "host-in-use" : null,
|
|
2860
|
+
};
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
async function purgeClaudePluginData(paths) {
|
|
2864
|
+
const results = [];
|
|
2865
|
+
for (const target of paths.pluginData) {
|
|
2866
|
+
if (!(await entryExists(target))) {
|
|
2867
|
+
results.push({ component: "plugin-data", status: "absent", path: target });
|
|
2868
|
+
continue;
|
|
2869
|
+
}
|
|
2870
|
+
await requireSafePathBelow(paths.hostHome, target, "Claude OpenSocrates plugin data");
|
|
2871
|
+
const info = await lstat(target);
|
|
2872
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
2873
|
+
fail(`refusing an unsafe Claude OpenSocrates plugin-data path: ${target}`);
|
|
2874
|
+
}
|
|
2875
|
+
if ((await readdir(target)).length !== 0) {
|
|
2876
|
+
results.push({
|
|
2877
|
+
component: "plugin-data",
|
|
2878
|
+
status: "pending",
|
|
2879
|
+
path: target,
|
|
2880
|
+
detail: "nonempty-unverified-data",
|
|
2881
|
+
});
|
|
2882
|
+
continue;
|
|
2883
|
+
}
|
|
2884
|
+
await rmdir(target);
|
|
2885
|
+
results.push({ component: "plugin-data", status: "removed", path: target });
|
|
2886
|
+
}
|
|
2887
|
+
return results;
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
async function verifyOpenCodePurgeRoot(root) {
|
|
2891
|
+
await requireOwnedRoot(root, "opencode");
|
|
2892
|
+
const manifestPath = join(root, OPENCODE_INSTALL_MANIFEST);
|
|
2893
|
+
await requireRegularFileEntry(manifestPath, "OpenCode installation inventory");
|
|
2894
|
+
const manifest = await readJsonObject(manifestPath);
|
|
2895
|
+
const manifestKeys = Object.keys(manifest).sort();
|
|
2896
|
+
if (
|
|
2897
|
+
!jsonDeepEqual(manifestKeys, ["bridgeSha256", "files", "schema", "version"]) ||
|
|
2898
|
+
manifest.schema !== "opensocrates.opencode-installation/1.0.0" ||
|
|
2899
|
+
typeof manifest.version !== "string" ||
|
|
2900
|
+
manifest.version.trim().length === 0 ||
|
|
2901
|
+
!/^[a-f0-9]{64}$/u.test(manifest.bridgeSha256) ||
|
|
2902
|
+
manifest.files === null ||
|
|
2903
|
+
typeof manifest.files !== "object" ||
|
|
2904
|
+
Array.isArray(manifest.files)
|
|
2905
|
+
) {
|
|
2906
|
+
fail("OpenCode installation inventory is invalid");
|
|
2907
|
+
}
|
|
2908
|
+
const declared = Object.keys(manifest.files);
|
|
2909
|
+
const actual = new Set(await walkFiles(root));
|
|
2910
|
+
actual.delete(OPENCODE_INSTALL_MANIFEST);
|
|
2911
|
+
if (
|
|
2912
|
+
actual.size !== declared.length ||
|
|
2913
|
+
declared.some((item) => !isSafeArchivePath(item) || !actual.has(item) || !/^[a-f0-9]{64}$/u.test(manifest.files[item]))
|
|
2914
|
+
) {
|
|
2915
|
+
fail("OpenCode installation inventory does not cover the complete managed skill");
|
|
2916
|
+
}
|
|
2917
|
+
for (const item of declared) {
|
|
2918
|
+
if ((await sha256File(join(root, ...item.split("/")))) !== manifest.files[item]) {
|
|
2919
|
+
fail(`OpenCode installed file checksum mismatch for ${item}`);
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
return manifest;
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
async function verifyManagedTreeForPurge(host, root) {
|
|
2926
|
+
if (host === "opencode") return verifyOpenCodePurgeRoot(root);
|
|
2927
|
+
await requireOwnedRoot(root, host);
|
|
2928
|
+
const layout = HOST_LAYOUTS[host];
|
|
2929
|
+
const pluginRoot = join(root, layout.pluginRelative);
|
|
2930
|
+
const identity = await packageIdentityForPurge(pluginRoot, host, {
|
|
2931
|
+
allowedExtra: (item) => FILE_DROP_HOSTS.includes(host) && item === MARKER_NAME,
|
|
2932
|
+
});
|
|
2933
|
+
if (FILE_DROP_HOSTS.includes(host)) return identity;
|
|
2934
|
+
|
|
2935
|
+
const marketplacePath = join(root, layout.marketplaceRelative);
|
|
2936
|
+
await requireRegularFileEntry(marketplacePath, `${host} marketplace manifest`);
|
|
2937
|
+
const marketplace = await readJsonObject(marketplacePath);
|
|
2938
|
+
const expected = expectedMarketplace(host);
|
|
2939
|
+
if (host === "claude") expected.metadata.version = identity.version;
|
|
2940
|
+
if (!jsonDeepEqual(marketplace, expected)) {
|
|
2941
|
+
fail(`${host} managed marketplace does not match the exact owned plugin inventory`);
|
|
2942
|
+
}
|
|
2943
|
+
const allowed = new Set([
|
|
2944
|
+
MARKER_NAME,
|
|
2945
|
+
layout.marketplaceRelative.split(sep).join("/"),
|
|
2946
|
+
...identity.files.map((item) => join(layout.pluginRelative, ...item.split("/")).split(sep).join("/")),
|
|
2947
|
+
]);
|
|
2948
|
+
for (const item of await walkFiles(root)) {
|
|
2949
|
+
if (!allowed.has(item)) fail(`${host} managed marketplace contains an unowned file: ${item}`);
|
|
2950
|
+
}
|
|
2951
|
+
return identity;
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
async function removeOwnedManagedRoot(host, paths) {
|
|
2955
|
+
if (!(await entryExists(paths.root))) {
|
|
2956
|
+
return { component: "managed-root", status: "absent", path: paths.root };
|
|
2957
|
+
}
|
|
2958
|
+
await requireSafePathBelow(paths.hostHome, paths.root, `${host} managed root`);
|
|
2959
|
+
await verifyManagedTreeForPurge(host, paths.root);
|
|
2960
|
+
const backup = join(transientParent(host, paths), `.opensocrates.removed-${randomUUID()}`);
|
|
2961
|
+
await requireSafePathBelow(paths.hostHome, backup, `${host} removal backup`);
|
|
2962
|
+
await rename(paths.root, backup);
|
|
2963
|
+
try {
|
|
2964
|
+
await verifyManagedTreeForPurge(host, backup);
|
|
2965
|
+
await rm(backup, { recursive: true });
|
|
2966
|
+
} catch (error) {
|
|
2967
|
+
if ((await entryExists(backup)) && !(await entryExists(paths.root))) {
|
|
2968
|
+
const restored = await recoveryStep(`restore the ${host} managed root after purge cleanup failed`, async () => {
|
|
2969
|
+
await verifyManagedTreeForPurge(host, backup);
|
|
2970
|
+
await rename(backup, paths.root);
|
|
2971
|
+
});
|
|
2972
|
+
if (!restored) {
|
|
2973
|
+
console.error(`error: recoverable ${host} files remain at: ${backup}`);
|
|
2974
|
+
console.error(`error: retry command: opensocrates remove --host ${host} --purge`);
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
throw error;
|
|
2978
|
+
}
|
|
2979
|
+
return { component: "managed-root", status: "removed", path: paths.root };
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
async function cleanupTransientRootResidue(host, paths) {
|
|
2983
|
+
const parent = transientParent(host, paths);
|
|
2984
|
+
if (!(await entryExists(parent))) {
|
|
2985
|
+
return { component: "transaction-residue", status: "absent", path: parent };
|
|
2986
|
+
}
|
|
2987
|
+
await requireSafePathBelow(paths.hostHome, parent, `${host} transient parent`, { allowRoot: true });
|
|
2988
|
+
const names = (await readdir(parent)).filter((name) =>
|
|
2989
|
+
/^\.opensocrates\.(?:staging|backup|removed)-[A-Za-z0-9-]+$/u.test(name),
|
|
2990
|
+
);
|
|
2991
|
+
let removed = 0;
|
|
2992
|
+
for (const name of names) {
|
|
2993
|
+
const target = join(parent, name);
|
|
2994
|
+
await requireSafePathBelow(parent, target, `${host} transaction residue`);
|
|
2995
|
+
const info = await lstat(target);
|
|
2996
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
2997
|
+
fail(`refusing unsafe ${host} transaction residue: ${target}`);
|
|
2998
|
+
}
|
|
2999
|
+
if ((await readdir(target)).length !== 0) {
|
|
3000
|
+
try {
|
|
3001
|
+
await verifyManagedTreeForPurge(host, target);
|
|
3002
|
+
} catch (error) {
|
|
3003
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
3004
|
+
fail(`refusing unverifiable ${host} transaction residue at ${target}: ${detail}`);
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
await rm(target, { recursive: true });
|
|
3008
|
+
removed += 1;
|
|
3009
|
+
}
|
|
3010
|
+
return {
|
|
3011
|
+
component: "transaction-residue",
|
|
3012
|
+
status: removed > 0 ? "removed" : "absent",
|
|
3013
|
+
path: parent,
|
|
3014
|
+
};
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
async function readOpenCodeBridgeSidecar(target) {
|
|
3018
|
+
const info = await lstat(target);
|
|
3019
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
3020
|
+
fail(`OpenCode bridge ownership sidecar is unsafe: ${target}`);
|
|
3021
|
+
}
|
|
3022
|
+
const sidecar = await readJsonObject(target);
|
|
3023
|
+
const expectedKeys = [...Object.keys(OPENCODE_MARKER), "version", "bridgeSha256"].sort();
|
|
3024
|
+
const observedKeys = Object.keys(sidecar).sort();
|
|
3025
|
+
const marker = Object.fromEntries(Object.keys(OPENCODE_MARKER).map((key) => [key, sidecar[key]]));
|
|
3026
|
+
if (
|
|
3027
|
+
expectedKeys.length !== observedKeys.length ||
|
|
3028
|
+
expectedKeys.some((key, index) => key !== observedKeys[index]) ||
|
|
3029
|
+
!markerMatches(marker, "opencode") ||
|
|
3030
|
+
typeof sidecar.version !== "string" ||
|
|
3031
|
+
sidecar.version.trim().length === 0 ||
|
|
3032
|
+
!/^[a-f0-9]{64}$/u.test(sidecar.bridgeSha256)
|
|
3033
|
+
) {
|
|
3034
|
+
fail(`OpenCode bridge ownership sidecar is invalid: ${target}`);
|
|
3035
|
+
}
|
|
3036
|
+
return sidecar;
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
async function purgeOpenCodeBridge(paths) {
|
|
3040
|
+
const bridgePresent = await entryExists(paths.bridge);
|
|
3041
|
+
const markerPresent = await entryExists(paths.bridgeMarker);
|
|
3042
|
+
if (!bridgePresent && !markerPresent) {
|
|
3043
|
+
return { component: "opencode-bridge", status: "absent", path: paths.bridge };
|
|
3044
|
+
}
|
|
3045
|
+
await requireSafePathBelow(paths.hostHome, paths.bridge, "OpenCode bridge");
|
|
3046
|
+
await requireSafePathBelow(paths.hostHome, paths.bridgeMarker, "OpenCode bridge sidecar");
|
|
3047
|
+
if (bridgePresent && !markerPresent) {
|
|
3048
|
+
fail("refusing to remove an OpenCode bridge without its ownership sidecar");
|
|
3049
|
+
}
|
|
3050
|
+
const sidecar = await readOpenCodeBridgeSidecar(paths.bridgeMarker);
|
|
3051
|
+
if (bridgePresent) {
|
|
3052
|
+
const bridgeInfo = await lstat(paths.bridge);
|
|
3053
|
+
if (!bridgeInfo.isFile() || bridgeInfo.isSymbolicLink() || (await sha256File(paths.bridge)) !== sidecar.bridgeSha256) {
|
|
3054
|
+
fail("refusing to remove an OpenCode bridge whose ownership checksum does not match");
|
|
3055
|
+
}
|
|
3056
|
+
await rm(paths.bridge);
|
|
3057
|
+
}
|
|
3058
|
+
await rm(paths.bridgeMarker);
|
|
3059
|
+
return { component: "opencode-bridge", status: "removed", path: paths.bridge };
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
async function purgeOpenCodeBridgeResidue(paths) {
|
|
3063
|
+
if (!(await entryExists(paths.bridgeParent))) {
|
|
3064
|
+
return { component: "opencode-bridge-residue", status: "absent", path: paths.bridgeParent };
|
|
3065
|
+
}
|
|
3066
|
+
await requireSafePathBelow(paths.hostHome, paths.bridgeParent, "OpenCode bridge parent");
|
|
3067
|
+
const entries = await readdir(paths.bridgeParent, { withFileTypes: true });
|
|
3068
|
+
const bridgeNames = entries
|
|
3069
|
+
.filter((entry) => /^\.opensocrates\.js\.(?:staging|backup|removed)-[A-Za-z0-9-]+$/u.test(entry.name))
|
|
3070
|
+
.map((entry) => entry.name);
|
|
3071
|
+
const markerNames = entries
|
|
3072
|
+
.filter((entry) =>
|
|
3073
|
+
/^\.opensocrates-managed\.json\.(?:staging|backup|removed)-[A-Za-z0-9-]+$/u.test(entry.name),
|
|
3074
|
+
)
|
|
3075
|
+
.map((entry) => entry.name);
|
|
3076
|
+
const bridges = new Map();
|
|
3077
|
+
for (const name of bridgeNames) {
|
|
3078
|
+
const target = join(paths.bridgeParent, name);
|
|
3079
|
+
await requireSafePathBelow(paths.bridgeParent, target, "OpenCode bridge residue");
|
|
3080
|
+
const info = await lstat(target);
|
|
3081
|
+
if (!info.isFile() || info.isSymbolicLink()) fail(`unsafe OpenCode bridge residue: ${target}`);
|
|
3082
|
+
bridges.set(target, await sha256File(target));
|
|
3083
|
+
}
|
|
3084
|
+
let removed = 0;
|
|
3085
|
+
for (const name of markerNames) {
|
|
3086
|
+
const marker = join(paths.bridgeParent, name);
|
|
3087
|
+
await requireSafePathBelow(paths.bridgeParent, marker, "OpenCode bridge sidecar residue");
|
|
3088
|
+
const sidecar = await readOpenCodeBridgeSidecar(marker);
|
|
3089
|
+
const matches = [...bridges].filter(([, digest]) => digest === sidecar.bridgeSha256);
|
|
3090
|
+
if (matches.length > 1) fail("ambiguous OpenCode bridge transaction residue");
|
|
3091
|
+
if (matches.length === 1) {
|
|
3092
|
+
const [[bridge]] = matches;
|
|
3093
|
+
await rm(bridge);
|
|
3094
|
+
bridges.delete(bridge);
|
|
3095
|
+
removed += 1;
|
|
3096
|
+
}
|
|
3097
|
+
await rm(marker);
|
|
3098
|
+
removed += 1;
|
|
3099
|
+
}
|
|
3100
|
+
if (bridges.size > 0) fail("refusing OpenCode bridge residue without an ownership sidecar");
|
|
3101
|
+
return {
|
|
3102
|
+
component: "opencode-bridge-residue",
|
|
3103
|
+
status: removed > 0 ? "removed" : "absent",
|
|
3104
|
+
path: paths.bridgeParent,
|
|
3105
|
+
};
|
|
3106
|
+
}
|
|
3107
|
+
|
|
1105
3108
|
async function prepareVerifiedPackage(options, host = options.host) {
|
|
1106
3109
|
if (!SUPPORTED_HOSTS.includes(host)) {
|
|
1107
3110
|
fail(`cannot prepare a package for unsupported host ${JSON.stringify(host)}`);
|
|
@@ -1121,17 +3124,11 @@ async function prepareVerifiedPackage(options, host = options.host) {
|
|
|
1121
3124
|
}
|
|
1122
3125
|
|
|
1123
3126
|
async function prepareVerifiedPackages(options, hosts) {
|
|
1124
|
-
const settled = await Promise.allSettled(
|
|
1125
|
-
|
|
1126
|
-
);
|
|
1127
|
-
const prepared = settled
|
|
1128
|
-
.filter((result) => result.status === "fulfilled")
|
|
1129
|
-
.map((result) => result.value);
|
|
3127
|
+
const settled = await Promise.allSettled(hosts.map((host) => prepareVerifiedPackage(options, host)));
|
|
3128
|
+
const prepared = settled.filter((result) => result.status === "fulfilled").map((result) => result.value);
|
|
1130
3129
|
const failure = settled.find((result) => result.status === "rejected");
|
|
1131
3130
|
if (failure) {
|
|
1132
|
-
await Promise.all(
|
|
1133
|
-
prepared.map((item) => rm(item.scratch, { recursive: true, force: true })),
|
|
1134
|
-
);
|
|
3131
|
+
await Promise.all(prepared.map((item) => rm(item.scratch, { recursive: true, force: true })));
|
|
1135
3132
|
throw failure.reason;
|
|
1136
3133
|
}
|
|
1137
3134
|
return prepared;
|
|
@@ -1150,8 +3147,7 @@ function expectedMarketplace(host) {
|
|
|
1150
3147
|
{
|
|
1151
3148
|
name: PLUGIN_NAME,
|
|
1152
3149
|
source: `./plugins/${PLUGIN_NAME}`,
|
|
1153
|
-
description:
|
|
1154
|
-
"Local reasoning-system selection for Claude Code and Cowork, plus one /opensocrates entry.",
|
|
3150
|
+
description: "Local reasoning-system selection for Claude Code and Cowork, plus one /opensocrates entry.",
|
|
1155
3151
|
category: "workflow",
|
|
1156
3152
|
},
|
|
1157
3153
|
],
|
|
@@ -1181,11 +3177,23 @@ async function buildStagingTree(parent, pluginSource, host) {
|
|
|
1181
3177
|
const layout = HOST_LAYOUTS[host];
|
|
1182
3178
|
const staging = await mkdtemp(join(parent, ".opensocrates.staging-"));
|
|
1183
3179
|
try {
|
|
3180
|
+
if (FILE_DROP_HOSTS.includes(host)) {
|
|
3181
|
+
await cp(pluginSource, staging, { recursive: true, preserveTimestamps: true });
|
|
3182
|
+
await verifyExtractedPackage(staging, host);
|
|
3183
|
+
await writeFile(join(staging, MARKER_NAME), `${JSON.stringify(markerFor(host), null, 2)}\n`, {
|
|
3184
|
+
encoding: "utf8",
|
|
3185
|
+
mode: 0o600,
|
|
3186
|
+
});
|
|
3187
|
+
return staging;
|
|
3188
|
+
}
|
|
1184
3189
|
const marketplace = join(staging, layout.marketplaceRelative);
|
|
1185
3190
|
const plugin = join(staging, layout.pluginRelative);
|
|
1186
3191
|
await mkdir(dirname(marketplace), { recursive: true, mode: 0o700 });
|
|
1187
3192
|
await mkdir(dirname(plugin), { recursive: true, mode: 0o700 });
|
|
1188
|
-
await cp(pluginSource, plugin, {
|
|
3193
|
+
await cp(pluginSource, plugin, {
|
|
3194
|
+
recursive: true,
|
|
3195
|
+
preserveTimestamps: true,
|
|
3196
|
+
});
|
|
1189
3197
|
await writeFile(marketplace, `${JSON.stringify(expectedMarketplace(host), null, 2)}\n`, {
|
|
1190
3198
|
encoding: "utf8",
|
|
1191
3199
|
mode: 0o600,
|
|
@@ -1202,20 +3210,72 @@ async function buildStagingTree(parent, pluginSource, host) {
|
|
|
1202
3210
|
}
|
|
1203
3211
|
}
|
|
1204
3212
|
|
|
3213
|
+
async function buildOpenCodeStaging(paths, pluginSource) {
|
|
3214
|
+
await ensureOpenCodeDirectories(paths);
|
|
3215
|
+
const root = await mkdtemp(join(paths.parent, ".opensocrates.staging-"));
|
|
3216
|
+
const bridge = join(paths.bridgeParent, `.opensocrates.js.staging-${randomUUID()}`);
|
|
3217
|
+
const bridgeMarker = join(paths.bridgeParent, `.opensocrates-managed.json.staging-${randomUUID()}`);
|
|
3218
|
+
try {
|
|
3219
|
+
await cp(join(pluginSource, "skills", "opensocrates"), root, {
|
|
3220
|
+
recursive: true,
|
|
3221
|
+
preserveTimestamps: true,
|
|
3222
|
+
});
|
|
3223
|
+
const metadata = join(root, ".opensocrates-package");
|
|
3224
|
+
await mkdir(metadata, { mode: 0o700 });
|
|
3225
|
+
for (const name of ["opencode-plugin.json", "release-manifest.json", "checksums.sha256"]) {
|
|
3226
|
+
await cp(join(pluginSource, name), join(metadata, name), {
|
|
3227
|
+
preserveTimestamps: true,
|
|
3228
|
+
});
|
|
3229
|
+
}
|
|
3230
|
+
await writeFile(join(root, MARKER_NAME), `${JSON.stringify(markerFor("opencode"), null, 2)}\n`, {
|
|
3231
|
+
encoding: "utf8",
|
|
3232
|
+
mode: 0o600,
|
|
3233
|
+
});
|
|
3234
|
+
await cp(join(pluginSource, "plugins", "opensocrates.js"), bridge, {
|
|
3235
|
+
preserveTimestamps: true,
|
|
3236
|
+
});
|
|
3237
|
+
const bridgeSha256 = await sha256File(bridge);
|
|
3238
|
+
const files = {};
|
|
3239
|
+
for (const item of (await walkFiles(root)).sort()) {
|
|
3240
|
+
files[item] = await sha256File(join(root, ...item.split("/")));
|
|
3241
|
+
}
|
|
3242
|
+
await writeFile(
|
|
3243
|
+
join(root, OPENCODE_INSTALL_MANIFEST),
|
|
3244
|
+
`${JSON.stringify(
|
|
3245
|
+
{
|
|
3246
|
+
schema: "opensocrates.opencode-installation/1.0.0",
|
|
3247
|
+
version: PRODUCT_VERSION,
|
|
3248
|
+
bridgeSha256,
|
|
3249
|
+
files,
|
|
3250
|
+
},
|
|
3251
|
+
null,
|
|
3252
|
+
2,
|
|
3253
|
+
)}\n`,
|
|
3254
|
+
{ encoding: "utf8", mode: 0o600 },
|
|
3255
|
+
);
|
|
3256
|
+
await writeFile(bridgeMarker, `${JSON.stringify(openCodeSidecar(bridgeSha256), null, 2)}\n`, {
|
|
3257
|
+
encoding: "utf8",
|
|
3258
|
+
mode: 0o600,
|
|
3259
|
+
});
|
|
3260
|
+
return { root, bridge, bridgeMarker };
|
|
3261
|
+
} catch (error) {
|
|
3262
|
+
await rm(root, { recursive: true, force: true });
|
|
3263
|
+
await rm(bridge, { force: true });
|
|
3264
|
+
await rm(bridgeMarker, { force: true });
|
|
3265
|
+
throw error;
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
|
|
1205
3269
|
function entryRoot(entry, host) {
|
|
1206
3270
|
if (entry === null) {
|
|
1207
3271
|
return null;
|
|
1208
3272
|
}
|
|
1209
3273
|
let value;
|
|
1210
3274
|
if (host === "claude") {
|
|
1211
|
-
const fields = ["path", "installLocation"].filter(
|
|
1212
|
-
(field) => entry[field] !== undefined && entry[field] !== null,
|
|
1213
|
-
);
|
|
3275
|
+
const fields = ["path", "installLocation"].filter((field) => entry[field] !== undefined && entry[field] !== null);
|
|
1214
3276
|
if (
|
|
1215
3277
|
fields.length === 0 ||
|
|
1216
|
-
fields.some(
|
|
1217
|
-
(field) => typeof entry[field] !== "string" || entry[field].trim().length === 0,
|
|
1218
|
-
)
|
|
3278
|
+
fields.some((field) => typeof entry[field] !== "string" || entry[field].trim().length === 0)
|
|
1219
3279
|
) {
|
|
1220
3280
|
fail(`${host} marketplace ${MARKETPLACE_NAME} has no usable root`);
|
|
1221
3281
|
}
|
|
@@ -1250,14 +3310,13 @@ function entryRoot(entry, host) {
|
|
|
1250
3310
|
}
|
|
1251
3311
|
|
|
1252
3312
|
function removeRegistration(host, entry, state) {
|
|
3313
|
+
if (FILE_DROP_HOSTS.includes(host) || host === "opencode") return;
|
|
1253
3314
|
if (host === "claude") {
|
|
1254
3315
|
if (["installed", "disabled"].includes(state.kind)) {
|
|
1255
3316
|
run(claudeBinary(), ["plugin", "uninstall", PLUGIN_ID, "--scope", "user"]);
|
|
1256
3317
|
}
|
|
1257
3318
|
if (entry !== null) {
|
|
1258
|
-
run(claudeBinary(), [
|
|
1259
|
-
"plugin", "marketplace", "remove", MARKETPLACE_NAME, "--scope", "user",
|
|
1260
|
-
]);
|
|
3319
|
+
run(claudeBinary(), ["plugin", "marketplace", "remove", MARKETPLACE_NAME, "--scope", "user"]);
|
|
1261
3320
|
}
|
|
1262
3321
|
return;
|
|
1263
3322
|
}
|
|
@@ -1270,6 +3329,7 @@ function removeRegistration(host, entry, state) {
|
|
|
1270
3329
|
}
|
|
1271
3330
|
|
|
1272
3331
|
function addRegistration(host, root, installPlugin, { enabled = true } = {}) {
|
|
3332
|
+
if (FILE_DROP_HOSTS.includes(host) || host === "opencode") return null;
|
|
1273
3333
|
if (host === "claude") {
|
|
1274
3334
|
run(claudeBinary(), ["plugin", "marketplace", "add", root, "--scope", "user"]);
|
|
1275
3335
|
if (installPlugin) {
|
|
@@ -1288,6 +3348,7 @@ function addRegistration(host, root, installPlugin, { enabled = true } = {}) {
|
|
|
1288
3348
|
}
|
|
1289
3349
|
|
|
1290
3350
|
function removeRegistrationBestEffort(host) {
|
|
3351
|
+
if (FILE_DROP_HOSTS.includes(host) || host === "opencode") return;
|
|
1291
3352
|
const entry = (() => {
|
|
1292
3353
|
try {
|
|
1293
3354
|
return marketplaceEntry(host);
|
|
@@ -1324,15 +3385,19 @@ async function recoveryStep(label, action) {
|
|
|
1324
3385
|
await action();
|
|
1325
3386
|
return true;
|
|
1326
3387
|
} catch (recoveryError) {
|
|
1327
|
-
const detail =
|
|
1328
|
-
recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
|
|
3388
|
+
const detail = recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
|
|
1329
3389
|
console.error(`warning: rollback step failed (${label}): ${detail}`);
|
|
1330
3390
|
return false;
|
|
1331
3391
|
}
|
|
1332
3392
|
}
|
|
1333
3393
|
|
|
1334
3394
|
async function preflightHost(host, action) {
|
|
1335
|
-
|
|
3395
|
+
if (
|
|
3396
|
+
(!FILE_DROP_HOSTS.includes(host) && host !== "opencode") ||
|
|
3397
|
+
["install", "update"].includes(action)
|
|
3398
|
+
) {
|
|
3399
|
+
requireHostCli(host, { authenticated: ["install", "update"].includes(action) });
|
|
3400
|
+
}
|
|
1336
3401
|
if (host === "claude") {
|
|
1337
3402
|
if (["install", "update"].includes(action)) {
|
|
1338
3403
|
requireNoLegacyClaudeInstallation();
|
|
@@ -1341,6 +3406,24 @@ async function preflightHost(host, action) {
|
|
|
1341
3406
|
}
|
|
1342
3407
|
}
|
|
1343
3408
|
const paths = managedPaths(host);
|
|
3409
|
+
if (host === "opencode") {
|
|
3410
|
+
const presence = await Promise.all(
|
|
3411
|
+
[paths.root, paths.bridge, paths.bridgeMarker].map((target) => entryExists(target)),
|
|
3412
|
+
);
|
|
3413
|
+
if (presence.some(Boolean) && !presence.every(Boolean)) {
|
|
3414
|
+
fail("OpenCode has a partial or unowned OpenSocrates installation; refusing to replace it");
|
|
3415
|
+
}
|
|
3416
|
+
if (presence.every(Boolean)) await verifyOpenCodeInstallation(paths);
|
|
3417
|
+
const previousEntry = presence.every(Boolean) ? { name: MARKETPLACE_NAME, root: paths.root } : null;
|
|
3418
|
+
const previousState = previousEntry === null ? { kind: "missing", version: null } : pluginState(host);
|
|
3419
|
+
return {
|
|
3420
|
+
host,
|
|
3421
|
+
paths,
|
|
3422
|
+
previousEntry,
|
|
3423
|
+
previousState,
|
|
3424
|
+
rootExists: presence[0],
|
|
3425
|
+
};
|
|
3426
|
+
}
|
|
1344
3427
|
const previousEntry = marketplaceEntry(host);
|
|
1345
3428
|
if (previousEntry !== null && entryRoot(previousEntry, host) !== paths.root) {
|
|
1346
3429
|
fail(
|
|
@@ -1348,7 +3431,7 @@ async function preflightHost(host, action) {
|
|
|
1348
3431
|
"refusing to overwrite an unmanaged location",
|
|
1349
3432
|
);
|
|
1350
3433
|
}
|
|
1351
|
-
const rootExists = await
|
|
3434
|
+
const rootExists = await entryExists(paths.root);
|
|
1352
3435
|
if (rootExists) {
|
|
1353
3436
|
await requireOwnedRoot(paths.root, host);
|
|
1354
3437
|
}
|
|
@@ -1356,7 +3439,9 @@ async function preflightHost(host, action) {
|
|
|
1356
3439
|
fail(`${host} has a managed registration whose root is missing: ${paths.root}`);
|
|
1357
3440
|
}
|
|
1358
3441
|
const previousState =
|
|
1359
|
-
previousEntry === null
|
|
3442
|
+
previousEntry === null
|
|
3443
|
+
? { kind: "missing", version: null }
|
|
3444
|
+
: pluginState(host, { requireHostState: ["install", "update"].includes(action) });
|
|
1360
3445
|
return { host, paths, previousEntry, previousState, rootExists };
|
|
1361
3446
|
}
|
|
1362
3447
|
|
|
@@ -1364,32 +3449,39 @@ async function preflightSelectedHosts(options, action, desiredState) {
|
|
|
1364
3449
|
if (options.host !== ALL_HOST) {
|
|
1365
3450
|
return [await preflightHost(options.host, action)];
|
|
1366
3451
|
}
|
|
3452
|
+
// A pre-release or offline caller may supply only the exact host archives it
|
|
3453
|
+
// intends to transact. Never mix those candidate packages with downloads
|
|
3454
|
+
// for other locally available hosts: the qualified asset set is the exact
|
|
3455
|
+
// transaction boundary. With no local assets, --host all retains discovery
|
|
3456
|
+
// of every ready host.
|
|
3457
|
+
const assetHosts = qualifiedAssetHosts(options);
|
|
3458
|
+
const candidates = assetHosts.length > 0 ? assetHosts : SUPPORTED_HOSTS;
|
|
1367
3459
|
const desiredHosts = new Set(desiredState.installedHosts);
|
|
1368
3460
|
const rootPresence = Object.fromEntries(
|
|
1369
|
-
await Promise.all(
|
|
1370
|
-
SUPPORTED_HOSTS.map(async (host) => [host, await exists(managedPaths(host).root)]),
|
|
1371
|
-
),
|
|
1372
|
-
);
|
|
1373
|
-
const settled = await Promise.allSettled(
|
|
1374
|
-
SUPPORTED_HOSTS.map((host) => preflightHost(host, action)),
|
|
3461
|
+
await Promise.all(candidates.map(async (host) => [host, await entryExists(managedPaths(host).root)])),
|
|
1375
3462
|
);
|
|
3463
|
+
const settled = await Promise.allSettled(candidates.map((host) => preflightHost(host, action)));
|
|
1376
3464
|
const successes = new Map();
|
|
1377
3465
|
const failures = new Map();
|
|
1378
3466
|
settled.forEach((result, index) => {
|
|
1379
|
-
const host =
|
|
3467
|
+
const host = candidates[index];
|
|
1380
3468
|
if (result.status === "fulfilled") successes.set(host, result.value);
|
|
1381
3469
|
else failures.set(host, result.reason);
|
|
1382
3470
|
});
|
|
1383
3471
|
|
|
1384
3472
|
const required = new Set();
|
|
1385
|
-
if (
|
|
1386
|
-
for (const host of
|
|
3473
|
+
if (assetHosts.length > 0) {
|
|
3474
|
+
for (const host of assetHosts) required.add(host);
|
|
3475
|
+
} else if (action === "install") {
|
|
3476
|
+
for (const host of candidates) {
|
|
1387
3477
|
if (desiredHosts.has(host) || rootPresence[host]) required.add(host);
|
|
1388
3478
|
}
|
|
1389
3479
|
} else if (desiredHosts.size > 0) {
|
|
1390
|
-
for (const host of desiredHosts)
|
|
3480
|
+
for (const host of desiredHosts) {
|
|
3481
|
+
if (candidates.includes(host)) required.add(host);
|
|
3482
|
+
}
|
|
1391
3483
|
} else if (action === "remove") {
|
|
1392
|
-
for (const host of
|
|
3484
|
+
for (const host of candidates) {
|
|
1393
3485
|
if (rootPresence[host]) required.add(host);
|
|
1394
3486
|
}
|
|
1395
3487
|
}
|
|
@@ -1401,7 +3493,9 @@ async function preflightSelectedHosts(options, action, desiredState) {
|
|
|
1401
3493
|
}
|
|
1402
3494
|
|
|
1403
3495
|
let selected;
|
|
1404
|
-
if (
|
|
3496
|
+
if (assetHosts.length > 0) {
|
|
3497
|
+
selected = assetHosts.map((host) => successes.get(host)).filter(Boolean);
|
|
3498
|
+
} else if (action === "update" && desiredHosts.size > 0) {
|
|
1405
3499
|
selected = [...desiredHosts].map((host) => successes.get(host)).filter(Boolean);
|
|
1406
3500
|
} else if (action === "remove") {
|
|
1407
3501
|
selected = [...successes.values()].filter(
|
|
@@ -1427,14 +3521,26 @@ async function preflightSelectedHosts(options, action, desiredState) {
|
|
|
1427
3521
|
async function stageInstallation(preflight, pluginSource) {
|
|
1428
3522
|
const { host, paths } = preflight;
|
|
1429
3523
|
await mkdir(paths.parent, { recursive: true, mode: 0o700 });
|
|
1430
|
-
const
|
|
3524
|
+
const transient = transientParent(host, paths);
|
|
3525
|
+
await mkdir(transient, { recursive: true, mode: 0o700 });
|
|
3526
|
+
const staging =
|
|
3527
|
+
host === "opencode"
|
|
3528
|
+
? await buildOpenCodeStaging(paths, pluginSource)
|
|
3529
|
+
: await buildStagingTree(transient, pluginSource, host);
|
|
1431
3530
|
return {
|
|
1432
3531
|
...preflight,
|
|
1433
3532
|
staging,
|
|
1434
|
-
backup: join(
|
|
3533
|
+
backup: join(transient, `.opensocrates.backup-${randomUUID()}`),
|
|
3534
|
+
bridgeBackup: host === "opencode" ? join(paths.bridgeParent, `.opensocrates.js.backup-${randomUUID()}`) : null,
|
|
3535
|
+
bridgeMarkerBackup:
|
|
3536
|
+
host === "opencode" ? join(paths.bridgeParent, `.opensocrates-managed.json.backup-${randomUUID()}`) : null,
|
|
1435
3537
|
registrationRemoved: false,
|
|
1436
3538
|
backupCreated: false,
|
|
1437
3539
|
newRootActive: false,
|
|
3540
|
+
newBridgeActive: false,
|
|
3541
|
+
newBridgeMarkerActive: false,
|
|
3542
|
+
bridgeBackupCreated: false,
|
|
3543
|
+
bridgeMarkerBackupCreated: false,
|
|
1438
3544
|
activationStarted: false,
|
|
1439
3545
|
};
|
|
1440
3546
|
}
|
|
@@ -1446,17 +3552,31 @@ async function activateInstallation(transaction) {
|
|
|
1446
3552
|
transaction.registrationRemoved = true;
|
|
1447
3553
|
removeRegistration(host, previousEntry, previousState);
|
|
1448
3554
|
}
|
|
1449
|
-
if (await
|
|
3555
|
+
if (await entryExists(paths.root)) {
|
|
1450
3556
|
await rename(paths.root, transaction.backup);
|
|
1451
3557
|
transaction.backupCreated = true;
|
|
1452
3558
|
}
|
|
1453
|
-
await
|
|
3559
|
+
if (host === "opencode" && (await entryExists(paths.bridge))) {
|
|
3560
|
+
await rename(paths.bridge, transaction.bridgeBackup);
|
|
3561
|
+
transaction.bridgeBackupCreated = true;
|
|
3562
|
+
}
|
|
3563
|
+
if (host === "opencode" && (await entryExists(paths.bridgeMarker))) {
|
|
3564
|
+
await rename(paths.bridgeMarker, transaction.bridgeMarkerBackup);
|
|
3565
|
+
transaction.bridgeMarkerBackupCreated = true;
|
|
3566
|
+
}
|
|
3567
|
+
await rename(host === "opencode" ? transaction.staging.root : transaction.staging, paths.root);
|
|
1454
3568
|
transaction.newRootActive = true;
|
|
3569
|
+
if (host === "opencode") {
|
|
3570
|
+
await rename(transaction.staging.bridge, paths.bridge);
|
|
3571
|
+
transaction.newBridgeActive = true;
|
|
3572
|
+
await rename(transaction.staging.bridgeMarker, paths.bridgeMarker);
|
|
3573
|
+
transaction.newBridgeMarkerActive = true;
|
|
3574
|
+
await verifyOpenCodeInstallation(paths);
|
|
3575
|
+
}
|
|
1455
3576
|
const result = addRegistration(host, paths.root, true);
|
|
1456
|
-
const state = pluginState(host);
|
|
3577
|
+
const state = pluginState(host, { requireHostState: true });
|
|
1457
3578
|
if (
|
|
1458
|
-
(host === "codex" &&
|
|
1459
|
-
(result?.pluginId !== PLUGIN_ID || result?.version !== PRODUCT_VERSION)) ||
|
|
3579
|
+
(host === "codex" && (result?.pluginId !== PLUGIN_ID || result?.version !== PRODUCT_VERSION)) ||
|
|
1460
3580
|
state.kind !== "installed" ||
|
|
1461
3581
|
state.version !== PRODUCT_VERSION
|
|
1462
3582
|
) {
|
|
@@ -1479,41 +3599,56 @@ async function rollbackInstallation(transaction) {
|
|
|
1479
3599
|
await rm(paths.root, { recursive: true, force: true });
|
|
1480
3600
|
});
|
|
1481
3601
|
}
|
|
3602
|
+
if (host === "opencode") {
|
|
3603
|
+
if (transaction.newBridgeActive && (await exists(paths.bridge))) {
|
|
3604
|
+
await recoveryStep("remove the failed OpenCode bridge", async () => {
|
|
3605
|
+
await rm(paths.bridge, { force: true });
|
|
3606
|
+
});
|
|
3607
|
+
}
|
|
3608
|
+
if (transaction.newBridgeMarkerActive && (await exists(paths.bridgeMarker))) {
|
|
3609
|
+
await recoveryStep("remove the failed OpenCode bridge sidecar", async () => {
|
|
3610
|
+
await rm(paths.bridgeMarker, { force: true });
|
|
3611
|
+
});
|
|
3612
|
+
}
|
|
3613
|
+
if (transaction.bridgeBackupCreated && (await exists(transaction.bridgeBackup))) {
|
|
3614
|
+
await recoveryStep("restore the previous OpenCode bridge", async () => {
|
|
3615
|
+
await rename(transaction.bridgeBackup, paths.bridge);
|
|
3616
|
+
});
|
|
3617
|
+
}
|
|
3618
|
+
if (transaction.bridgeMarkerBackupCreated && (await exists(transaction.bridgeMarkerBackup))) {
|
|
3619
|
+
await recoveryStep("restore the previous OpenCode bridge sidecar", async () => {
|
|
3620
|
+
await rename(transaction.bridgeMarkerBackup, paths.bridgeMarker);
|
|
3621
|
+
});
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
1482
3624
|
if (transaction.backupCreated && (await exists(transaction.backup))) {
|
|
1483
3625
|
restored = await recoveryStep(`restore the previous ${host} installation`, async () => {
|
|
1484
3626
|
await requireOwnedRoot(transaction.backup, host);
|
|
1485
3627
|
await rename(transaction.backup, paths.root);
|
|
1486
3628
|
});
|
|
1487
3629
|
}
|
|
1488
|
-
if (
|
|
1489
|
-
transaction.registrationRemoved &&
|
|
1490
|
-
previousEntry !== null &&
|
|
1491
|
-
(restored || !transaction.backupCreated)
|
|
1492
|
-
) {
|
|
3630
|
+
if (transaction.registrationRemoved && previousEntry !== null && (restored || !transaction.backupCreated)) {
|
|
1493
3631
|
await recoveryStep(`re-register the previous ${host} installation`, async () => {
|
|
1494
|
-
addRegistration(
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
["installed", "disabled"].includes(previousState.kind),
|
|
1498
|
-
{ enabled: previousState.kind !== "disabled" },
|
|
1499
|
-
);
|
|
3632
|
+
addRegistration(host, paths.root, ["installed", "disabled"].includes(previousState.kind), {
|
|
3633
|
+
enabled: previousState.kind !== "disabled",
|
|
3634
|
+
});
|
|
1500
3635
|
});
|
|
1501
3636
|
}
|
|
1502
3637
|
if (transaction.backupCreated && !restored) {
|
|
1503
|
-
console.error(
|
|
1504
|
-
`error: the previous ${host} OpenSocrates installation could not be restored automatically.`,
|
|
1505
|
-
);
|
|
3638
|
+
console.error(`error: the previous ${host} OpenSocrates installation could not be restored automatically.`);
|
|
1506
3639
|
console.error(`error: your previous files are preserved at: ${transaction.backup}`);
|
|
1507
3640
|
console.error(`error: recovery command: /bin/rm -rf -- ${shellQuote(paths.root)}`);
|
|
1508
|
-
console.error(
|
|
1509
|
-
`error: recovery command: /bin/mv -- ${shellQuote(transaction.backup)} ${shellQuote(paths.root)}`,
|
|
1510
|
-
);
|
|
3641
|
+
console.error(`error: recovery command: /bin/mv -- ${shellQuote(transaction.backup)} ${shellQuote(paths.root)}`);
|
|
1511
3642
|
console.error(`error: recovery command: opensocrates install --host ${host}`);
|
|
1512
3643
|
}
|
|
1513
3644
|
}
|
|
1514
3645
|
|
|
1515
3646
|
async function cleanupInstallationTransaction(transaction) {
|
|
1516
|
-
if (
|
|
3647
|
+
if (transaction.host === "opencode") {
|
|
3648
|
+
await rm(transaction.staging.root, { recursive: true, force: true });
|
|
3649
|
+
await rm(transaction.staging.bridge, { force: true });
|
|
3650
|
+
await rm(transaction.staging.bridgeMarker, { force: true });
|
|
3651
|
+
} else if (await exists(transaction.staging)) {
|
|
1517
3652
|
await rm(transaction.staging, { recursive: true, force: true });
|
|
1518
3653
|
}
|
|
1519
3654
|
}
|
|
@@ -1524,6 +3659,14 @@ async function commitInstallation(transaction) {
|
|
|
1524
3659
|
await rm(transaction.backup, { recursive: true });
|
|
1525
3660
|
transaction.backupCreated = false;
|
|
1526
3661
|
}
|
|
3662
|
+
if (transaction.host === "opencode") {
|
|
3663
|
+
for (const [created, backup] of [
|
|
3664
|
+
[transaction.bridgeBackupCreated, transaction.bridgeBackup],
|
|
3665
|
+
[transaction.bridgeMarkerBackupCreated, transaction.bridgeMarkerBackup],
|
|
3666
|
+
]) {
|
|
3667
|
+
if (created && (await exists(backup))) await rm(backup);
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
1527
3670
|
}
|
|
1528
3671
|
|
|
1529
3672
|
function installedDesiredState(previous, hosts) {
|
|
@@ -1546,8 +3689,7 @@ async function runInstallOrUpdate(options, action) {
|
|
|
1546
3689
|
for (const preflight of preflights) {
|
|
1547
3690
|
const item = packages.get(preflight.host);
|
|
1548
3691
|
console.log(
|
|
1549
|
-
`${preflight.host}: verified OpenSocrates ${PRODUCT_VERSION} and ` +
|
|
1550
|
-
`${item.checkedFiles} package files`,
|
|
3692
|
+
`${preflight.host}: verified OpenSocrates ${PRODUCT_VERSION} and ` + `${item.checkedFiles} package files`,
|
|
1551
3693
|
);
|
|
1552
3694
|
transactions.push(await stageInstallation(preflight, item.pluginRoot));
|
|
1553
3695
|
}
|
|
@@ -1563,9 +3705,7 @@ async function runInstallOrUpdate(options, action) {
|
|
|
1563
3705
|
throw error;
|
|
1564
3706
|
} finally {
|
|
1565
3707
|
await Promise.all(transactions.map(cleanupInstallationTransaction));
|
|
1566
|
-
await Promise.all(
|
|
1567
|
-
prepared.map((item) => rm(item.scratch, { recursive: true, force: true })),
|
|
1568
|
-
);
|
|
3708
|
+
await Promise.all(prepared.map((item) => rm(item.scratch, { recursive: true, force: true })));
|
|
1569
3709
|
}
|
|
1570
3710
|
for (const transaction of transactions) {
|
|
1571
3711
|
const cleaned = await recoveryStep(`remove the committed ${transaction.host} backup`, async () => {
|
|
@@ -1576,9 +3716,7 @@ async function runInstallOrUpdate(options, action) {
|
|
|
1576
3716
|
}
|
|
1577
3717
|
}
|
|
1578
3718
|
const verb = action === "update" ? "updated" : "installed";
|
|
1579
|
-
console.log(
|
|
1580
|
-
`OpenSocrates ${PRODUCT_VERSION} ${verb} successfully for ${hosts.join(", ")}.`,
|
|
1581
|
-
);
|
|
3719
|
+
console.log(`OpenSocrates ${PRODUCT_VERSION} ${verb} successfully for ${hosts.join(", ")}.`);
|
|
1582
3720
|
if (hosts.includes("codex")) {
|
|
1583
3721
|
console.log(
|
|
1584
3722
|
"Codex approval required: open one interactive Codex session and approve the " +
|
|
@@ -1586,14 +3724,39 @@ async function runInstallOrUpdate(options, action) {
|
|
|
1586
3724
|
"codex exec silently skips hooks that have not been trusted.",
|
|
1587
3725
|
);
|
|
1588
3726
|
}
|
|
1589
|
-
|
|
3727
|
+
if (hosts.includes("cursor")) {
|
|
3728
|
+
console.log(
|
|
3729
|
+
"Cursor: run Developer: Reload Window, then invoke /opensocrates from Agent chat. " +
|
|
3730
|
+
"This experimental package adds no automatic OpenSocrates hook selector.",
|
|
3731
|
+
);
|
|
3732
|
+
}
|
|
3733
|
+
if (hosts.includes("grok")) {
|
|
3734
|
+
console.log(
|
|
3735
|
+
"Grok Build: start a new task for automatic native-skill selection, or invoke /opensocrates explicitly.",
|
|
3736
|
+
);
|
|
3737
|
+
}
|
|
3738
|
+
if (hosts.includes("opencode")) {
|
|
3739
|
+
console.log(
|
|
3740
|
+
"OpenCode: automatic same-turn selection uses the stable chat.message bridge; " +
|
|
3741
|
+
"the native opensocrates skill remains available as an explicit fallback.",
|
|
3742
|
+
);
|
|
3743
|
+
}
|
|
3744
|
+
console.log("Start new host tasks to load the updated OpenSocrates integration.");
|
|
1590
3745
|
return hosts;
|
|
1591
3746
|
}
|
|
1592
3747
|
|
|
1593
3748
|
async function inspectHostStatus(host) {
|
|
1594
|
-
requireHostCli(host);
|
|
3749
|
+
if (!FILE_DROP_HOSTS.includes(host) && host !== "opencode") requireHostCli(host);
|
|
1595
3750
|
if (host === "claude") warnLegacyClaudeInstallation();
|
|
1596
3751
|
const paths = managedPaths(host);
|
|
3752
|
+
if (host === "opencode") {
|
|
3753
|
+
const presence = await Promise.all([paths.root, paths.bridge, paths.bridgeMarker].map((target) => exists(target)));
|
|
3754
|
+
if (!presence.some(Boolean)) return { host, kind: "missing", version: null, paths };
|
|
3755
|
+
if (!presence.every(Boolean)) return { host, kind: "files-only", version: null, paths };
|
|
3756
|
+
await verifyOpenCodeInstallation(paths);
|
|
3757
|
+
const state = pluginState(host);
|
|
3758
|
+
return { host, kind: state.kind, version: state.version, paths };
|
|
3759
|
+
}
|
|
1597
3760
|
const entry = marketplaceEntry(host);
|
|
1598
3761
|
if (entry === null) {
|
|
1599
3762
|
if (await exists(paths.root)) {
|
|
@@ -1624,14 +3787,19 @@ async function showStatus(host) {
|
|
|
1624
3787
|
const status = await inspectHostStatus(candidate);
|
|
1625
3788
|
const expected = desired.installedHosts.includes(candidate);
|
|
1626
3789
|
const hostDrift = expected
|
|
1627
|
-
? status.kind !== "installed" ||
|
|
1628
|
-
desired.activeVersion === null ||
|
|
1629
|
-
status.version !== desired.activeVersion
|
|
3790
|
+
? status.kind !== "installed" || desired.activeVersion === null || status.version !== desired.activeVersion
|
|
1630
3791
|
: status.kind !== "missing";
|
|
1631
3792
|
drift ||= hostDrift;
|
|
1632
3793
|
if (status.kind === "installed") {
|
|
1633
3794
|
console.log(
|
|
1634
3795
|
`${candidate}: installed ${status.version ?? "unknown"}` +
|
|
3796
|
+
(["antigravity", "cursor"].includes(candidate)
|
|
3797
|
+
? " (experimental explicit-skill tier)"
|
|
3798
|
+
: candidate === "grok"
|
|
3799
|
+
? " (native skill; automatic and explicit invocation)"
|
|
3800
|
+
: candidate === "opencode"
|
|
3801
|
+
? " (stable same-turn bridge plus native skill fallback)"
|
|
3802
|
+
: "") +
|
|
1635
3803
|
(expected
|
|
1636
3804
|
? hostDrift
|
|
1637
3805
|
? ` (drift from ${desired.activeVersion ?? "desired state"})`
|
|
@@ -1651,9 +3819,7 @@ async function showStatus(host) {
|
|
|
1651
3819
|
} else if (status.kind === "files-only") {
|
|
1652
3820
|
console.log(`${candidate}: managed files present but not registered`);
|
|
1653
3821
|
} else {
|
|
1654
|
-
console.log(
|
|
1655
|
-
`${candidate}: not installed` + (expected ? " (drift: desired host is missing)" : ""),
|
|
1656
|
-
);
|
|
3822
|
+
console.log(`${candidate}: not installed` + (expected ? " (drift: desired host is missing)" : ""));
|
|
1657
3823
|
}
|
|
1658
3824
|
} catch (error) {
|
|
1659
3825
|
if (desired.installedHosts.includes(candidate)) drift = true;
|
|
@@ -1668,11 +3834,24 @@ async function showStatus(host) {
|
|
|
1668
3834
|
}
|
|
1669
3835
|
const status = await inspectHostStatus(host);
|
|
1670
3836
|
if (status.kind === "installed") {
|
|
1671
|
-
console.log(
|
|
3837
|
+
console.log(
|
|
3838
|
+
`OpenSocrates ${status.version ?? "unknown"} is installed.` +
|
|
3839
|
+
(host === "antigravity"
|
|
3840
|
+
? " Antigravity support is experimental and explicit-skill only."
|
|
3841
|
+
: host === "cursor"
|
|
3842
|
+
? " Cursor support is experimental and explicit-skill first."
|
|
3843
|
+
: host === "grok"
|
|
3844
|
+
? " Grok Build uses automatic native-skill selection and explicit /opensocrates invocation."
|
|
3845
|
+
: host === "opencode"
|
|
3846
|
+
? " OpenCode uses the stable same-turn bridge and native skill fallback."
|
|
3847
|
+
: ""),
|
|
3848
|
+
);
|
|
1672
3849
|
} else if (status.kind === "disabled") {
|
|
1673
3850
|
console.log(
|
|
1674
3851
|
`OpenSocrates ${status.version ?? "unknown"} is installed but disabled. ` +
|
|
1675
|
-
|
|
3852
|
+
(host === "grok"
|
|
3853
|
+
? "Remove the OpenSocrates disabled entry from Grok Build configuration, then run status again."
|
|
3854
|
+
: "Run install or update to re-enable it."),
|
|
1676
3855
|
);
|
|
1677
3856
|
} else if (status.kind === "available") {
|
|
1678
3857
|
console.log(`OpenSocrates ${status.version ?? "unknown"} is available but not installed.`);
|
|
@@ -1686,9 +3865,25 @@ async function showStatus(host) {
|
|
|
1686
3865
|
function removalTransaction(preflight) {
|
|
1687
3866
|
return {
|
|
1688
3867
|
...preflight,
|
|
1689
|
-
backup: join(
|
|
3868
|
+
backup: join(
|
|
3869
|
+
transientParent(preflight.host, preflight.paths),
|
|
3870
|
+
`.opensocrates.removed-${randomUUID()}`,
|
|
3871
|
+
),
|
|
3872
|
+
bridgeBackup:
|
|
3873
|
+
preflight.host === "opencode"
|
|
3874
|
+
? join(preflight.paths.bridgeParent, `.opensocrates.js.removed-${randomUUID()}`)
|
|
3875
|
+
: null,
|
|
3876
|
+
bridgeMarkerBackup:
|
|
3877
|
+
preflight.host === "opencode"
|
|
3878
|
+
? join(preflight.paths.bridgeParent, `.opensocrates-managed.json.removed-${randomUUID()}`)
|
|
3879
|
+
: null,
|
|
1690
3880
|
registrationRemoved: false,
|
|
1691
3881
|
backupCreated: false,
|
|
3882
|
+
rootCommitted: false,
|
|
3883
|
+
bridgeBackupCreated: false,
|
|
3884
|
+
bridgeCommitted: false,
|
|
3885
|
+
bridgeMarkerBackupCreated: false,
|
|
3886
|
+
bridgeMarkerCommitted: false,
|
|
1692
3887
|
};
|
|
1693
3888
|
}
|
|
1694
3889
|
|
|
@@ -1698,22 +3893,57 @@ async function activateRemoval(transaction) {
|
|
|
1698
3893
|
transaction.registrationRemoved = true;
|
|
1699
3894
|
removeRegistration(host, previousEntry, previousState);
|
|
1700
3895
|
}
|
|
1701
|
-
if (await
|
|
3896
|
+
if (await entryExists(paths.root)) {
|
|
1702
3897
|
await rename(paths.root, transaction.backup);
|
|
1703
3898
|
transaction.backupCreated = true;
|
|
1704
3899
|
}
|
|
3900
|
+
if (host === "opencode" && (await entryExists(paths.bridge))) {
|
|
3901
|
+
await rename(paths.bridge, transaction.bridgeBackup);
|
|
3902
|
+
transaction.bridgeBackupCreated = true;
|
|
3903
|
+
}
|
|
3904
|
+
if (host === "opencode" && (await entryExists(paths.bridgeMarker))) {
|
|
3905
|
+
await rename(paths.bridgeMarker, transaction.bridgeMarkerBackup);
|
|
3906
|
+
transaction.bridgeMarkerBackupCreated = true;
|
|
3907
|
+
}
|
|
1705
3908
|
}
|
|
1706
3909
|
|
|
1707
3910
|
async function rollbackRemoval(transaction) {
|
|
1708
|
-
let
|
|
1709
|
-
|
|
3911
|
+
let complete = true;
|
|
3912
|
+
let restored = !transaction.rootExists;
|
|
3913
|
+
if (transaction.rootExists && transaction.backupCreated && (await entryExists(transaction.backup))) {
|
|
1710
3914
|
restored = await recoveryStep(`restore the removed ${transaction.host} files`, async () => {
|
|
1711
|
-
await
|
|
3915
|
+
await verifyManagedTreeForPurge(transaction.host, transaction.backup);
|
|
1712
3916
|
await rename(transaction.backup, transaction.paths.root);
|
|
3917
|
+
try {
|
|
3918
|
+
await verifyManagedTreeForPurge(transaction.host, transaction.paths.root);
|
|
3919
|
+
} catch (error) {
|
|
3920
|
+
try {
|
|
3921
|
+
await rename(transaction.paths.root, transaction.backup);
|
|
3922
|
+
} catch (requarantineError) {
|
|
3923
|
+
const detail = requarantineError instanceof Error ? requarantineError.message : String(requarantineError);
|
|
3924
|
+
transaction.backupCreated = await entryExists(transaction.backup);
|
|
3925
|
+
fail(
|
|
3926
|
+
`the restored ${transaction.host} tree failed verification and could not be returned to ` +
|
|
3927
|
+
`${transaction.backup}: ${detail}`,
|
|
3928
|
+
);
|
|
3929
|
+
}
|
|
3930
|
+
throw error;
|
|
3931
|
+
}
|
|
3932
|
+
transaction.backupCreated = false;
|
|
3933
|
+
});
|
|
3934
|
+
} else if (transaction.rootExists && !transaction.rootCommitted && !transaction.backupCreated) {
|
|
3935
|
+
restored = await recoveryStep(`verify the existing ${transaction.host} files`, async () => {
|
|
3936
|
+
await verifyManagedTreeForPurge(transaction.host, transaction.paths.root);
|
|
1713
3937
|
});
|
|
1714
3938
|
}
|
|
1715
|
-
|
|
1716
|
-
|
|
3939
|
+
complete = restored && complete;
|
|
3940
|
+
if (
|
|
3941
|
+
transaction.registrationRemoved &&
|
|
3942
|
+
transaction.previousEntry !== null &&
|
|
3943
|
+
restored &&
|
|
3944
|
+
(await entryExists(transaction.paths.root))
|
|
3945
|
+
) {
|
|
3946
|
+
const registrationRestored = await recoveryStep(`restore the removed ${transaction.host} registration`, async () => {
|
|
1717
3947
|
addRegistration(
|
|
1718
3948
|
transaction.host,
|
|
1719
3949
|
transaction.paths.root,
|
|
@@ -1721,13 +3951,93 @@ async function rollbackRemoval(transaction) {
|
|
|
1721
3951
|
{ enabled: transaction.previousState.kind !== "disabled" },
|
|
1722
3952
|
);
|
|
1723
3953
|
});
|
|
3954
|
+
complete = registrationRestored && complete;
|
|
3955
|
+
} else if (transaction.registrationRemoved && transaction.previousEntry !== null) {
|
|
3956
|
+
complete = false;
|
|
3957
|
+
}
|
|
3958
|
+
if (transaction.host === "opencode") {
|
|
3959
|
+
let bridgeRestored = !transaction.rootExists;
|
|
3960
|
+
if (
|
|
3961
|
+
transaction.rootExists &&
|
|
3962
|
+
transaction.bridgeBackupCreated &&
|
|
3963
|
+
(await entryExists(transaction.bridgeBackup))
|
|
3964
|
+
) {
|
|
3965
|
+
bridgeRestored = await recoveryStep("restore the removed OpenCode bridge", async () => {
|
|
3966
|
+
await rename(transaction.bridgeBackup, transaction.paths.bridge);
|
|
3967
|
+
transaction.bridgeBackupCreated = false;
|
|
3968
|
+
});
|
|
3969
|
+
} else if (transaction.rootExists && !transaction.bridgeCommitted && !transaction.bridgeBackupCreated) {
|
|
3970
|
+
bridgeRestored = await entryExists(transaction.paths.bridge);
|
|
3971
|
+
}
|
|
3972
|
+
complete = bridgeRestored && complete;
|
|
3973
|
+
let markerRestored = !transaction.rootExists;
|
|
3974
|
+
if (
|
|
3975
|
+
transaction.rootExists &&
|
|
3976
|
+
transaction.bridgeMarkerBackupCreated &&
|
|
3977
|
+
(await entryExists(transaction.bridgeMarkerBackup))
|
|
3978
|
+
) {
|
|
3979
|
+
markerRestored = await recoveryStep("restore the removed OpenCode bridge sidecar", async () => {
|
|
3980
|
+
await rename(transaction.bridgeMarkerBackup, transaction.paths.bridgeMarker);
|
|
3981
|
+
transaction.bridgeMarkerBackupCreated = false;
|
|
3982
|
+
});
|
|
3983
|
+
} else if (
|
|
3984
|
+
transaction.rootExists &&
|
|
3985
|
+
!transaction.bridgeMarkerCommitted &&
|
|
3986
|
+
!transaction.bridgeMarkerBackupCreated
|
|
3987
|
+
) {
|
|
3988
|
+
markerRestored = await entryExists(transaction.paths.bridgeMarker);
|
|
3989
|
+
}
|
|
3990
|
+
complete = markerRestored && complete;
|
|
1724
3991
|
}
|
|
3992
|
+
transaction.originalStateRestored = complete;
|
|
3993
|
+
return complete;
|
|
1725
3994
|
}
|
|
1726
3995
|
|
|
1727
|
-
async function commitRemoval(transaction) {
|
|
1728
|
-
if (transaction.backupCreated && (await
|
|
1729
|
-
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
|
+
}
|
|
1730
4005
|
await rm(transaction.backup, { recursive: true });
|
|
4006
|
+
transaction.backupCreated = false;
|
|
4007
|
+
transaction.rootCommitted = true;
|
|
4008
|
+
}
|
|
4009
|
+
if (transaction.host === "opencode") {
|
|
4010
|
+
for (const [flag, backup, label] of [
|
|
4011
|
+
["bridgeBackupCreated", transaction.bridgeBackup, "bridge"],
|
|
4012
|
+
["bridgeMarkerBackupCreated", transaction.bridgeMarkerBackup, "bridge sidecar"],
|
|
4013
|
+
]) {
|
|
4014
|
+
if (!transaction[flag] || !(await entryExists(backup))) continue;
|
|
4015
|
+
const info = await lstat(backup);
|
|
4016
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
4017
|
+
fail(`refusing to remove an unsafe OpenCode ${label} backup: ${backup}`);
|
|
4018
|
+
}
|
|
4019
|
+
await rm(backup);
|
|
4020
|
+
transaction[flag] = false;
|
|
4021
|
+
if (flag === "bridgeBackupCreated") transaction.bridgeCommitted = true;
|
|
4022
|
+
else transaction.bridgeMarkerCommitted = true;
|
|
4023
|
+
}
|
|
4024
|
+
}
|
|
4025
|
+
}
|
|
4026
|
+
|
|
4027
|
+
async function validateRemovalCommit(transaction) {
|
|
4028
|
+
if (transaction.backupCreated && (await entryExists(transaction.backup))) {
|
|
4029
|
+
await verifyManagedTreeForPurge(transaction.host, transaction.backup);
|
|
4030
|
+
}
|
|
4031
|
+
if (transaction.host !== "opencode") return;
|
|
4032
|
+
for (const [created, backup, label] of [
|
|
4033
|
+
[transaction.bridgeBackupCreated, transaction.bridgeBackup, "bridge"],
|
|
4034
|
+
[transaction.bridgeMarkerBackupCreated, transaction.bridgeMarkerBackup, "bridge sidecar"],
|
|
4035
|
+
]) {
|
|
4036
|
+
if (!created || !(await entryExists(backup))) continue;
|
|
4037
|
+
const info = await lstat(backup);
|
|
4038
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
4039
|
+
fail(`refusing to remove an unsafe OpenCode ${label} backup: ${backup}`);
|
|
4040
|
+
}
|
|
1731
4041
|
}
|
|
1732
4042
|
}
|
|
1733
4043
|
|
|
@@ -1769,29 +4079,15 @@ function launchctlDomain() {
|
|
|
1769
4079
|
|
|
1770
4080
|
function launchAgentDocument(npx, channel, environment) {
|
|
1771
4081
|
const packageTag = channel === "next" ? "next" : "latest";
|
|
1772
|
-
const arguments_ = [
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
`opensocrates@${packageTag}`,
|
|
1776
|
-
"auto-update",
|
|
1777
|
-
"run",
|
|
1778
|
-
];
|
|
1779
|
-
const argumentsXml = arguments_
|
|
1780
|
-
.map((argument) => ` <string>${xmlEscape(argument)}</string>`)
|
|
1781
|
-
.join("\n");
|
|
1782
|
-
const environmentEntries = Object.entries(environment).sort(([left], [right]) =>
|
|
1783
|
-
left.localeCompare(right),
|
|
1784
|
-
);
|
|
4082
|
+
const arguments_ = [npx, "--yes", `opensocrates@${packageTag}`, "auto-update", "run"];
|
|
4083
|
+
const argumentsXml = arguments_.map((argument) => ` <string>${xmlEscape(argument)}</string>`).join("\n");
|
|
4084
|
+
const environmentEntries = Object.entries(environment).sort(([left], [right]) => left.localeCompare(right));
|
|
1785
4085
|
const environmentXml =
|
|
1786
4086
|
environmentEntries.length === 0
|
|
1787
4087
|
? ""
|
|
1788
4088
|
: `\n <key>EnvironmentVariables</key>\n <dict>\n` +
|
|
1789
4089
|
environmentEntries
|
|
1790
|
-
.map(
|
|
1791
|
-
([key, value]) =>
|
|
1792
|
-
` <key>${xmlEscape(key)}</key>\n` +
|
|
1793
|
-
` <string>${xmlEscape(value)}</string>`,
|
|
1794
|
-
)
|
|
4090
|
+
.map(([key, value]) => ` <key>${xmlEscape(key)}</key>\n` + ` <string>${xmlEscape(value)}</string>`)
|
|
1795
4091
|
.join("\n") +
|
|
1796
4092
|
"\n </dict>";
|
|
1797
4093
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -1824,7 +4120,11 @@ function launchAgentTarget() {
|
|
|
1824
4120
|
}
|
|
1825
4121
|
|
|
1826
4122
|
function launchAgentLoaded() {
|
|
1827
|
-
return
|
|
4123
|
+
return (
|
|
4124
|
+
run(launchctlBinary(), ["print", launchAgentTarget()], {
|
|
4125
|
+
allowFailure: true,
|
|
4126
|
+
}).status === 0
|
|
4127
|
+
);
|
|
1828
4128
|
}
|
|
1829
4129
|
|
|
1830
4130
|
function stopLoadedLaunchAgent() {
|
|
@@ -1841,17 +4141,69 @@ function stopLoadedLaunchAgent() {
|
|
|
1841
4141
|
async function updaterEnvironment(hosts, npx) {
|
|
1842
4142
|
const node = await executablePath("node", "OPENSOCRATES_NODE_BIN");
|
|
1843
4143
|
const environment = {
|
|
1844
|
-
PATH: [...new Set([dirname(npx), dirname(node), "/usr/bin", "/bin", "/usr/sbin", "/sbin"])].join(
|
|
1845
|
-
":",
|
|
1846
|
-
),
|
|
4144
|
+
PATH: [...new Set([dirname(npx), dirname(node), "/usr/bin", "/bin", "/usr/sbin", "/sbin"])].join(":"),
|
|
1847
4145
|
};
|
|
1848
4146
|
for (const host of hosts) {
|
|
4147
|
+
if (host === "cursor") {
|
|
4148
|
+
if (process.env.CURSOR_CONFIG_DIR) {
|
|
4149
|
+
environment.CURSOR_CONFIG_DIR = resolve(process.env.CURSOR_CONFIG_DIR);
|
|
4150
|
+
continue;
|
|
4151
|
+
}
|
|
4152
|
+
const app = cursorAppPaths().find((candidate) => existsSync(candidate));
|
|
4153
|
+
if (app) continue;
|
|
4154
|
+
const executable = await executablePath("cursor", "CURSOR_BIN");
|
|
4155
|
+
environment.CURSOR_BIN = executable;
|
|
4156
|
+
environment.PATH = [...new Set([dirname(executable), ...environment.PATH.split(":")])].join(":");
|
|
4157
|
+
continue;
|
|
4158
|
+
}
|
|
4159
|
+
if (host === "antigravity") {
|
|
4160
|
+
if (process.env.ANTIGRAVITY_CONFIG_DIR) {
|
|
4161
|
+
environment.ANTIGRAVITY_CONFIG_DIR = resolve(process.env.ANTIGRAVITY_CONFIG_DIR);
|
|
4162
|
+
continue;
|
|
4163
|
+
}
|
|
4164
|
+
const executable = await executablePath("agy", "AGY_BIN");
|
|
4165
|
+
environment.AGY_BIN = executable;
|
|
4166
|
+
environment.PATH = [...new Set([dirname(executable), ...environment.PATH.split(":")])].join(":");
|
|
4167
|
+
continue;
|
|
4168
|
+
}
|
|
4169
|
+
if (host === "opencode") {
|
|
4170
|
+
// OPENCODE_CONFIG_DIR redirects configuration only. Unlike the
|
|
4171
|
+
// content-only hosts above, a scheduled OpenCode update re-runs the same
|
|
4172
|
+
// requireHostCli version gate as an interactive install, so recording
|
|
4173
|
+
// the config directory and stopping would leave the LaunchAgent without
|
|
4174
|
+
// a resolvable opencode: enable would succeed in a shell that has it on
|
|
4175
|
+
// PATH, then every scheduled run would fail the gate whenever opencode
|
|
4176
|
+
// lives outside the launchd default PATH.
|
|
4177
|
+
if (process.env.OPENCODE_CONFIG_DIR) {
|
|
4178
|
+
environment.OPENCODE_CONFIG_DIR = resolve(process.env.OPENCODE_CONFIG_DIR);
|
|
4179
|
+
}
|
|
4180
|
+
const executable = await executablePath("opencode", "OPENCODE_BIN");
|
|
4181
|
+
environment.OPENCODE_BIN = executable;
|
|
4182
|
+
environment.PATH = [...new Set([dirname(executable), ...environment.PATH.split(":")])].join(":");
|
|
4183
|
+
continue;
|
|
4184
|
+
}
|
|
4185
|
+
if (host === "grok") {
|
|
4186
|
+
const executable = await executablePath("grok", "GROK_BIN");
|
|
4187
|
+
environment.GROK_BIN = executable;
|
|
4188
|
+
environment.PATH = [...new Set([dirname(executable), ...environment.PATH.split(":")])].join(
|
|
4189
|
+
":",
|
|
4190
|
+
);
|
|
4191
|
+
continue;
|
|
4192
|
+
}
|
|
1849
4193
|
const key = host === "claude" ? "CLAUDE_BIN" : "CODEX_BIN";
|
|
1850
4194
|
const executable = await executablePath(host, key);
|
|
1851
4195
|
environment[key] = executable;
|
|
1852
4196
|
environment.PATH = [...new Set([dirname(executable), ...environment.PATH.split(":")])].join(":");
|
|
1853
4197
|
}
|
|
1854
|
-
for (const key of [
|
|
4198
|
+
for (const key of [
|
|
4199
|
+
"ANTIGRAVITY_CONFIG_DIR",
|
|
4200
|
+
"CLAUDE_CONFIG_DIR",
|
|
4201
|
+
"CODEX_HOME",
|
|
4202
|
+
"CURSOR_CONFIG_DIR",
|
|
4203
|
+
"GROK_HOME",
|
|
4204
|
+
"OPENCODE_CONFIG_DIR",
|
|
4205
|
+
"OPENSOCRATES_STATE_DIR",
|
|
4206
|
+
]) {
|
|
1855
4207
|
if (process.env[key]) environment[key] = resolve(process.env[key]);
|
|
1856
4208
|
}
|
|
1857
4209
|
return environment;
|
|
@@ -1859,10 +4211,7 @@ async function updaterEnvironment(hosts, npx) {
|
|
|
1859
4211
|
|
|
1860
4212
|
async function installLaunchAgent(channel, hosts) {
|
|
1861
4213
|
if (process.platform !== "darwin" || process.arch !== "arm64") {
|
|
1862
|
-
fail(
|
|
1863
|
-
`automatic updates currently support darwin-arm64 only; detected ` +
|
|
1864
|
-
`${process.platform}-${process.arch}`,
|
|
1865
|
-
);
|
|
4214
|
+
fail(`automatic updates currently support darwin-arm64 only; detected ` + `${process.platform}-${process.arch}`);
|
|
1866
4215
|
}
|
|
1867
4216
|
const paths = statePaths();
|
|
1868
4217
|
const npx = await executablePath("npx", "OPENSOCRATES_NPX_BIN");
|
|
@@ -1883,22 +4232,16 @@ async function installLaunchAgent(channel, hosts) {
|
|
|
1883
4232
|
const wasLoaded = launchAgentLoaded();
|
|
1884
4233
|
if (wasLoaded) stopLoadedLaunchAgent();
|
|
1885
4234
|
await atomicWritePrivateFile(paths.launchAgent, document);
|
|
1886
|
-
const launched = run(
|
|
1887
|
-
launchctlBinary(),
|
|
1888
|
-
["bootstrap", launchctlDomain(), paths.launchAgent],
|
|
1889
|
-
{ allowFailure: true },
|
|
1890
|
-
);
|
|
4235
|
+
const launched = run(launchctlBinary(), ["bootstrap", launchctlDomain(), paths.launchAgent], { allowFailure: true });
|
|
1891
4236
|
if (launched.status !== 0) {
|
|
1892
4237
|
if (previousDocument === null) {
|
|
1893
4238
|
await rm(paths.launchAgent, { force: true });
|
|
1894
4239
|
} else {
|
|
1895
4240
|
await atomicWritePrivateFile(paths.launchAgent, previousDocument);
|
|
1896
4241
|
if (wasLoaded) {
|
|
1897
|
-
const restored = run(
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
{ allowFailure: true },
|
|
1901
|
-
);
|
|
4242
|
+
const restored = run(launchctlBinary(), ["bootstrap", launchctlDomain(), paths.launchAgent], {
|
|
4243
|
+
allowFailure: true,
|
|
4244
|
+
});
|
|
1902
4245
|
if (restored.status !== 0) {
|
|
1903
4246
|
console.error("warning: the previous OpenSocrates LaunchAgent could not be reloaded");
|
|
1904
4247
|
}
|
|
@@ -1909,14 +4252,42 @@ async function installLaunchAgent(channel, hosts) {
|
|
|
1909
4252
|
}
|
|
1910
4253
|
}
|
|
1911
4254
|
|
|
4255
|
+
async function requireOwnedLaunchAgent(target) {
|
|
4256
|
+
const info = await lstat(target);
|
|
4257
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
4258
|
+
fail(`refusing to remove an unsafe LaunchAgent path: ${target}`);
|
|
4259
|
+
}
|
|
4260
|
+
const document = await readFile(target, "utf8");
|
|
4261
|
+
const labelPattern = new RegExp(
|
|
4262
|
+
`<key>\\s*Label\\s*</key>\\s*<string>\\s*${AUTO_UPDATE_LABEL.replaceAll(".", "\\.")}\\s*</string>`,
|
|
4263
|
+
"gu",
|
|
4264
|
+
);
|
|
4265
|
+
const argumentBlocks = [
|
|
4266
|
+
...document.matchAll(/<key>\s*ProgramArguments\s*<\/key>\s*<array>([\s\S]*?)<\/array>/gu),
|
|
4267
|
+
];
|
|
4268
|
+
const arguments_ =
|
|
4269
|
+
argumentBlocks.length === 1
|
|
4270
|
+
? [...argumentBlocks[0][1].matchAll(/<string>([\s\S]*?)<\/string>/gu)].map((match) => match[1].trim())
|
|
4271
|
+
: [];
|
|
4272
|
+
if (
|
|
4273
|
+
[...document.matchAll(labelPattern)].length !== 1 ||
|
|
4274
|
+
arguments_.length !== 5 ||
|
|
4275
|
+
!arguments_[0].startsWith("/") ||
|
|
4276
|
+
arguments_[1] !== "--yes" ||
|
|
4277
|
+
!/^opensocrates@(?:latest|next)$/u.test(arguments_[2]) ||
|
|
4278
|
+
arguments_[3] !== "auto-update" ||
|
|
4279
|
+
arguments_[4] !== "run"
|
|
4280
|
+
) {
|
|
4281
|
+
fail(`refusing to remove a LaunchAgent without the OpenSocrates ownership identity: ${target}`);
|
|
4282
|
+
}
|
|
4283
|
+
}
|
|
4284
|
+
|
|
1912
4285
|
async function disableLaunchAgent() {
|
|
1913
4286
|
const paths = statePaths();
|
|
1914
|
-
const agentPresent = await
|
|
4287
|
+
const agentPresent = await entryExists(paths.launchAgent);
|
|
1915
4288
|
if (agentPresent) {
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
fail(`refusing to remove an unsafe LaunchAgent path: ${paths.launchAgent}`);
|
|
1919
|
-
}
|
|
4289
|
+
await requireSafePathBelow(paths.launchAgentsDirectory, paths.launchAgent, "OpenSocrates LaunchAgent");
|
|
4290
|
+
await requireOwnedLaunchAgent(paths.launchAgent);
|
|
1920
4291
|
}
|
|
1921
4292
|
if (process.env.OPENSOCRATES_SKIP_LAUNCHCTL !== "1") {
|
|
1922
4293
|
stopLoadedLaunchAgent();
|
|
@@ -1932,14 +4303,12 @@ async function disableAutoUpdateState(desired) {
|
|
|
1932
4303
|
};
|
|
1933
4304
|
}
|
|
1934
4305
|
|
|
1935
|
-
async function
|
|
4306
|
+
async function runStandardRemove(options, { report = true, beforeBackupDelete = null } = {}) {
|
|
1936
4307
|
const desired = await readDesiredState();
|
|
1937
4308
|
const preflights = await preflightSelectedHosts(options, "remove", desired);
|
|
1938
4309
|
const removedHosts = preflights.map((item) => item.host);
|
|
1939
4310
|
const remainingHosts = desired.installedHosts.filter((host) => !removedHosts.includes(host));
|
|
1940
|
-
const remainingAutoUpdateHosts = desired.autoUpdate.hosts.filter(
|
|
1941
|
-
(host) => !removedHosts.includes(host),
|
|
1942
|
-
);
|
|
4311
|
+
const remainingAutoUpdateHosts = desired.autoUpdate.hosts.filter((host) => !removedHosts.includes(host));
|
|
1943
4312
|
const keepAutoUpdate = desired.autoUpdate.enabled && remainingAutoUpdateHosts.length > 0;
|
|
1944
4313
|
const nextDesired = {
|
|
1945
4314
|
...desired,
|
|
@@ -1949,14 +4318,23 @@ async function runRemove(options) {
|
|
|
1949
4318
|
? { ...desired.autoUpdate, hosts: remainingAutoUpdateHosts }
|
|
1950
4319
|
: { enabled: false, hosts: [], nextCheckAt: null },
|
|
1951
4320
|
};
|
|
1952
|
-
const launchAgentPresent = await
|
|
4321
|
+
const launchAgentPresent = await entryExists(statePaths().launchAgent);
|
|
1953
4322
|
const schedulerNeedsChange =
|
|
1954
4323
|
options.host === ALL_HOST ||
|
|
1955
4324
|
remainingHosts.length === 0 ||
|
|
1956
4325
|
launchAgentPresent !== keepAutoUpdate ||
|
|
1957
4326
|
desired.autoUpdate.hosts.some((host) => removedHosts.includes(host));
|
|
1958
4327
|
const transactions = preflights.map(removalTransaction);
|
|
4328
|
+
// Validate every source tree before the first unregister/rename. The same
|
|
4329
|
+
// check runs again after activation and immediately before deletion to
|
|
4330
|
+
// close TOCTOU gaps without stranding pre-existing drift in a backup.
|
|
4331
|
+
for (const transaction of transactions) {
|
|
4332
|
+
if (transaction.rootExists) {
|
|
4333
|
+
await verifyManagedTreeForPurge(transaction.host, transaction.paths.root);
|
|
4334
|
+
}
|
|
4335
|
+
}
|
|
1959
4336
|
let schedulerTouched = false;
|
|
4337
|
+
let desiredWritten = false;
|
|
1960
4338
|
try {
|
|
1961
4339
|
for (const transaction of transactions) await activateRemoval(transaction);
|
|
1962
4340
|
if (schedulerNeedsChange) {
|
|
@@ -1968,42 +4346,539 @@ async function runRemove(options) {
|
|
|
1968
4346
|
}
|
|
1969
4347
|
}
|
|
1970
4348
|
await writeDesiredState(nextDesired);
|
|
4349
|
+
desiredWritten = true;
|
|
4350
|
+
for (const transaction of transactions) await validateRemovalCommit(transaction);
|
|
4351
|
+
for (const transaction of transactions) {
|
|
4352
|
+
await commitRemoval(transaction, { beforeBackupDelete });
|
|
4353
|
+
}
|
|
1971
4354
|
} catch (error) {
|
|
1972
|
-
|
|
4355
|
+
let rollbackComplete = true;
|
|
4356
|
+
for (const transaction of [...transactions].reverse()) {
|
|
4357
|
+
const restored = await rollbackRemoval(transaction);
|
|
4358
|
+
rollbackComplete = restored && rollbackComplete;
|
|
4359
|
+
}
|
|
4360
|
+
const unrestoredHosts = new Set(
|
|
4361
|
+
transactions.filter((transaction) => transaction.originalStateRestored !== true).map((item) => item.host),
|
|
4362
|
+
);
|
|
4363
|
+
const recoveredHosts = desired.installedHosts.filter((host) => !unrestoredHosts.has(host));
|
|
4364
|
+
const recoveredAutoUpdateHosts = desired.autoUpdate.hosts.filter((host) => recoveredHosts.includes(host));
|
|
4365
|
+
const keepRecoveredAutoUpdate = desired.autoUpdate.enabled && recoveredAutoUpdateHosts.length > 0;
|
|
4366
|
+
let recoveredDesired = {
|
|
4367
|
+
...desired,
|
|
4368
|
+
installedHosts: recoveredHosts,
|
|
4369
|
+
activeVersion: recoveredHosts.length === 0 ? null : desired.activeVersion,
|
|
4370
|
+
autoUpdate: keepRecoveredAutoUpdate
|
|
4371
|
+
? { ...desired.autoUpdate, hosts: recoveredAutoUpdateHosts }
|
|
4372
|
+
: { enabled: false, hosts: [], nextCheckAt: null },
|
|
4373
|
+
};
|
|
4374
|
+
let schedulerRestored = true;
|
|
1973
4375
|
if (schedulerTouched) {
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
});
|
|
4376
|
+
schedulerRestored = await recoveryStep("reconcile the automatic updater after removal failure", async () => {
|
|
4377
|
+
if (recoveredDesired.autoUpdate.enabled) {
|
|
4378
|
+
await installLaunchAgent(recoveredDesired.channel, recoveredDesired.autoUpdate.hosts);
|
|
4379
|
+
} else {
|
|
4380
|
+
await disableLaunchAgent();
|
|
4381
|
+
}
|
|
4382
|
+
});
|
|
4383
|
+
if (!schedulerRestored && recoveredDesired.autoUpdate.enabled) {
|
|
4384
|
+
recoveredDesired = {
|
|
4385
|
+
...recoveredDesired,
|
|
4386
|
+
autoUpdate: {
|
|
4387
|
+
enabled: false,
|
|
4388
|
+
hosts: [],
|
|
4389
|
+
nextCheckAt: null,
|
|
4390
|
+
},
|
|
4391
|
+
};
|
|
1991
4392
|
}
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
});
|
|
4393
|
+
rollbackComplete = schedulerRestored && rollbackComplete;
|
|
4394
|
+
}
|
|
4395
|
+
if (desiredWritten || !rollbackComplete || !schedulerRestored) {
|
|
4396
|
+
const stateRestored = await recoveryStep("record desired state after removal failure", async () => {
|
|
4397
|
+
await writeDesiredState(recoveredDesired);
|
|
1998
4398
|
});
|
|
4399
|
+
rollbackComplete = stateRestored && rollbackComplete;
|
|
4400
|
+
}
|
|
4401
|
+
if (!rollbackComplete) {
|
|
4402
|
+
console.error("error: removal cleanup is incomplete; no success was recorded.");
|
|
4403
|
+
for (const transaction of transactions) {
|
|
4404
|
+
if (!transaction.backupCreated || !(await entryExists(transaction.backup))) continue;
|
|
4405
|
+
console.error(`error: preserved ${transaction.host} removal residue: ${transaction.backup}`);
|
|
4406
|
+
console.error(
|
|
4407
|
+
`error: safe cleanup retry: opensocrates remove --host ${transaction.host} --purge`,
|
|
4408
|
+
);
|
|
4409
|
+
console.error(
|
|
4410
|
+
"error: if purge reports an integrity failure, inspect that exact UUID residue before manual cleanup: " +
|
|
4411
|
+
`/bin/rm -rf -- ${shellQuote(transaction.backup)}`,
|
|
4412
|
+
);
|
|
4413
|
+
}
|
|
1999
4414
|
}
|
|
2000
4415
|
throw error;
|
|
2001
4416
|
}
|
|
2002
|
-
|
|
4417
|
+
if (!report) return { removedHosts, desired: nextDesired };
|
|
2003
4418
|
for (const host of removedHosts) {
|
|
2004
|
-
|
|
4419
|
+
const label =
|
|
4420
|
+
host === "antigravity"
|
|
4421
|
+
? "Antigravity"
|
|
4422
|
+
: host === "claude"
|
|
4423
|
+
? "Claude"
|
|
4424
|
+
: host === "cursor"
|
|
4425
|
+
? "Cursor"
|
|
4426
|
+
: host === "grok"
|
|
4427
|
+
? "Grok Build"
|
|
4428
|
+
: host === "opencode"
|
|
4429
|
+
? "OpenCode"
|
|
4430
|
+
: "Codex";
|
|
4431
|
+
console.log(`OpenSocrates was removed from ${label}.`);
|
|
2005
4432
|
}
|
|
2006
4433
|
if (removedHosts.length === 0) console.log("OpenSocrates is not installed on any managed host.");
|
|
4434
|
+
console.log("Removal scope: registrations and installer-managed roots only; this is not a complete uninstall.");
|
|
4435
|
+
const residue = [];
|
|
4436
|
+
for (const host of removedHosts) {
|
|
4437
|
+
const paths = purgePathsFor(host);
|
|
4438
|
+
for (const target of [paths.cacheRoot, paths.cacheMarketplaceRoot, ...paths.pluginData]) {
|
|
4439
|
+
if (target !== null && (await entryExists(target))) residue.push(target);
|
|
4440
|
+
}
|
|
4441
|
+
}
|
|
4442
|
+
for (const target of [statePaths().desiredState, statePaths().receipt]) {
|
|
4443
|
+
if (await entryExists(target)) residue.push(target);
|
|
4444
|
+
}
|
|
4445
|
+
for (const target of [...new Set(residue)]) console.log(`Remaining OpenSocrates path: ${target}`);
|
|
4446
|
+
if (removedHosts.includes("codex")) {
|
|
4447
|
+
console.log("Codex OpenSocrates hook trust is preserved by this removal contract.");
|
|
4448
|
+
}
|
|
4449
|
+
console.log(`Next: close active hosts, then run opensocrates remove --host ${options.host} --purge.`);
|
|
4450
|
+
return { removedHosts, desired: nextDesired };
|
|
4451
|
+
}
|
|
4452
|
+
|
|
4453
|
+
async function purgeRegistration(host, paths) {
|
|
4454
|
+
if (FILE_DROP_HOSTS.includes(host) || host === "opencode") {
|
|
4455
|
+
return { status: "not-applicable", detail: "file-owned-host" };
|
|
4456
|
+
}
|
|
4457
|
+
try {
|
|
4458
|
+
requireHostCli(host);
|
|
4459
|
+
} catch (error) {
|
|
4460
|
+
return {
|
|
4461
|
+
status: "unverified",
|
|
4462
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
4463
|
+
};
|
|
4464
|
+
}
|
|
4465
|
+
if (host === "claude") warnLegacyClaudeInstallation();
|
|
4466
|
+
const entry = marketplaceEntry(host);
|
|
4467
|
+
if (entry !== null && entryRoot(entry, host) !== paths.root) {
|
|
4468
|
+
fail(`${host} OpenSocrates registration points to an unmanaged location`);
|
|
4469
|
+
}
|
|
4470
|
+
const state = pluginState(host);
|
|
4471
|
+
const present = entry !== null || ["installed", "disabled"].includes(state.kind);
|
|
4472
|
+
removeRegistration(host, entry, state);
|
|
4473
|
+
const remainingEntry = marketplaceEntry(host);
|
|
4474
|
+
const remainingState = pluginState(host);
|
|
4475
|
+
if (remainingEntry !== null || ["installed", "disabled"].includes(remainingState.kind)) {
|
|
4476
|
+
fail(`${host} did not confirm removal of the exact ${PLUGIN_ID} registration`);
|
|
4477
|
+
}
|
|
4478
|
+
return { status: present ? "removed" : "absent", detail: null };
|
|
4479
|
+
}
|
|
4480
|
+
|
|
4481
|
+
async function capturePurgeComponent(hostResult, component, action) {
|
|
4482
|
+
try {
|
|
4483
|
+
const value = await action();
|
|
4484
|
+
const items = Array.isArray(value) ? value : [value];
|
|
4485
|
+
hostResult.components.push(...items);
|
|
4486
|
+
return items;
|
|
4487
|
+
} catch (error) {
|
|
4488
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4489
|
+
hostResult.components.push({ component, status: "failed", path: null, detail: message });
|
|
4490
|
+
hostResult.errors.push(message);
|
|
4491
|
+
return [];
|
|
4492
|
+
}
|
|
4493
|
+
}
|
|
4494
|
+
|
|
4495
|
+
function purgeComponentBlocksCompletion(component) {
|
|
4496
|
+
return new Set(["pending", "failed"]).has(component.status);
|
|
4497
|
+
}
|
|
4498
|
+
|
|
4499
|
+
function finalizePurgeHostResult(hostResult) {
|
|
4500
|
+
const registrationComplete = new Set(["removed", "absent", "not-applicable"]).has(hostResult.registration);
|
|
4501
|
+
hostResult.status =
|
|
4502
|
+
registrationComplete &&
|
|
4503
|
+
!hostResult.components.some(purgeComponentBlocksCompletion) &&
|
|
4504
|
+
!purgeComponentBlocksCompletion(hostResult.extension)
|
|
4505
|
+
? "complete"
|
|
4506
|
+
: "partial";
|
|
4507
|
+
return hostResult.status === "complete";
|
|
4508
|
+
}
|
|
4509
|
+
|
|
4510
|
+
async function resetPurgeTrustExtension(hostResult, options) {
|
|
4511
|
+
if (hostResult.host !== "codex" || !options.resetTrust) return true;
|
|
4512
|
+
try {
|
|
4513
|
+
const reset = await resetCodexOpenSocratesHookTrust({ hooks: options.trustResetHooks ?? {} });
|
|
4514
|
+
hostResult.extension = {
|
|
4515
|
+
component: "host-security-trust",
|
|
4516
|
+
status: reset.status,
|
|
4517
|
+
removedCount: reset.removedEvents.length,
|
|
4518
|
+
nextAction: null,
|
|
4519
|
+
};
|
|
4520
|
+
return true;
|
|
4521
|
+
} catch (error) {
|
|
4522
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4523
|
+
hostResult.extension = {
|
|
4524
|
+
component: "host-security-trust",
|
|
4525
|
+
status: "failed",
|
|
4526
|
+
removedCount: 0,
|
|
4527
|
+
nextAction: "retry-purge-with-reset-trust-or-review-exact-sections-manually",
|
|
4528
|
+
detail: message,
|
|
4529
|
+
};
|
|
4530
|
+
hostResult.errors.push(message);
|
|
4531
|
+
return false;
|
|
4532
|
+
}
|
|
4533
|
+
}
|
|
4534
|
+
|
|
4535
|
+
function preservePurgeComponentsAfterTrustFailure(hostResult, paths) {
|
|
4536
|
+
hostResult.registration = "preserved";
|
|
4537
|
+
hostResult.registrationDetail = "not-attempted-after-trust-reset-failure";
|
|
4538
|
+
const preserved = [
|
|
4539
|
+
["managed-root", paths.root],
|
|
4540
|
+
["transaction-residue", null],
|
|
4541
|
+
];
|
|
4542
|
+
if (hostResult.host === "opencode") {
|
|
4543
|
+
preserved.push(["opencode-bridge", paths.bridge], ["opencode-bridge-residue", null]);
|
|
4544
|
+
}
|
|
4545
|
+
if (paths.cacheRoot !== null) preserved.push(["plugin-cache", paths.cacheRoot]);
|
|
4546
|
+
preserved.push(...paths.pluginData.map((path) => ["plugin-data", path]));
|
|
4547
|
+
for (const [component, path] of preserved) {
|
|
4548
|
+
hostResult.components.push({
|
|
4549
|
+
component,
|
|
4550
|
+
status: "preserved",
|
|
4551
|
+
path,
|
|
4552
|
+
detail: "not-attempted-after-trust-reset-failure",
|
|
4553
|
+
});
|
|
4554
|
+
}
|
|
4555
|
+
}
|
|
4556
|
+
|
|
4557
|
+
async function purgeOneHost(hostResult, options) {
|
|
4558
|
+
const paths = purgePathsFor(hostResult.host);
|
|
4559
|
+
if (!(await resetPurgeTrustExtension(hostResult, options))) {
|
|
4560
|
+
preservePurgeComponentsAfterTrustFailure(hostResult, paths);
|
|
4561
|
+
finalizePurgeHostResult(hostResult);
|
|
4562
|
+
return;
|
|
4563
|
+
}
|
|
4564
|
+
try {
|
|
4565
|
+
const registration = await purgeRegistration(hostResult.host, paths);
|
|
4566
|
+
hostResult.registration = registration.status;
|
|
4567
|
+
hostResult.registrationDetail = registration.detail;
|
|
4568
|
+
if (registration.status === "unverified") hostResult.errors.push(registration.detail);
|
|
4569
|
+
} catch (error) {
|
|
4570
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4571
|
+
hostResult.registration = "failed";
|
|
4572
|
+
hostResult.registrationDetail = message;
|
|
4573
|
+
hostResult.errors.push(message);
|
|
4574
|
+
}
|
|
4575
|
+
|
|
4576
|
+
await capturePurgeComponent(hostResult, "managed-root", () =>
|
|
4577
|
+
removeOwnedManagedRoot(hostResult.host, paths),
|
|
4578
|
+
);
|
|
4579
|
+
if (hostResult.host === "opencode") {
|
|
4580
|
+
await capturePurgeComponent(hostResult, "opencode-bridge", () => purgeOpenCodeBridge(paths));
|
|
4581
|
+
}
|
|
4582
|
+
await capturePurgeComponent(hostResult, "transaction-residue", () =>
|
|
4583
|
+
cleanupTransientRootResidue(hostResult.host, paths),
|
|
4584
|
+
);
|
|
4585
|
+
if (hostResult.host === "opencode") {
|
|
4586
|
+
await capturePurgeComponent(hostResult, "opencode-bridge-residue", () =>
|
|
4587
|
+
purgeOpenCodeBridgeResidue(paths),
|
|
4588
|
+
);
|
|
4589
|
+
}
|
|
4590
|
+
if (paths.cacheRoot !== null) {
|
|
4591
|
+
await capturePurgeComponent(hostResult, "plugin-cache", () => purgeHostCache(hostResult.host, paths));
|
|
4592
|
+
}
|
|
4593
|
+
if (paths.pluginData.length > 0) {
|
|
4594
|
+
await capturePurgeComponent(hostResult, "plugin-data", () => purgeClaudePluginData(paths));
|
|
4595
|
+
}
|
|
4596
|
+
finalizePurgeHostResult(hostResult);
|
|
4597
|
+
}
|
|
4598
|
+
|
|
4599
|
+
function hostDeactivated(hostResult) {
|
|
4600
|
+
const registrationComplete = new Set(["removed", "absent", "not-applicable"]).has(hostResult.registration);
|
|
4601
|
+
const activationComponents = hostResult.components.filter((item) =>
|
|
4602
|
+
new Set(["managed-root", "opencode-bridge"]).has(item.component),
|
|
4603
|
+
);
|
|
4604
|
+
return registrationComplete && !activationComponents.some(purgeComponentBlocksCompletion);
|
|
4605
|
+
}
|
|
4606
|
+
|
|
4607
|
+
async function updatePurgeDesiredState(options, desired, result) {
|
|
4608
|
+
const deactivated = result.hosts.filter(hostDeactivated).map((item) => item.host);
|
|
4609
|
+
const installedHosts = desired.installedHosts.filter((host) => !deactivated.includes(host));
|
|
4610
|
+
const autoUpdateHosts = desired.autoUpdate.hosts.filter((host) => !deactivated.includes(host));
|
|
4611
|
+
const keepAutoUpdate = desired.autoUpdate.enabled && autoUpdateHosts.length > 0;
|
|
4612
|
+
let nextDesired = {
|
|
4613
|
+
...desired,
|
|
4614
|
+
installedHosts,
|
|
4615
|
+
activeVersion: installedHosts.length === 0 ? null : desired.activeVersion,
|
|
4616
|
+
autoUpdate: keepAutoUpdate
|
|
4617
|
+
? { ...desired.autoUpdate, hosts: autoUpdateHosts }
|
|
4618
|
+
: { enabled: false, hosts: [], nextCheckAt: null },
|
|
4619
|
+
};
|
|
4620
|
+
const launchAgentPresent = await entryExists(statePaths().launchAgent);
|
|
4621
|
+
const schedulerNeedsChange =
|
|
4622
|
+
deactivated.length > 0 &&
|
|
4623
|
+
(options.host === ALL_HOST ||
|
|
4624
|
+
installedHosts.length === 0 ||
|
|
4625
|
+
launchAgentPresent !== keepAutoUpdate ||
|
|
4626
|
+
desired.autoUpdate.hosts.some((host) => deactivated.includes(host)));
|
|
4627
|
+
if (schedulerNeedsChange) {
|
|
4628
|
+
try {
|
|
4629
|
+
if (keepAutoUpdate) await installLaunchAgent(desired.channel, autoUpdateHosts);
|
|
4630
|
+
else await disableLaunchAgent();
|
|
4631
|
+
result.finalization.components.push({
|
|
4632
|
+
component: "launch-agent",
|
|
4633
|
+
status: launchAgentPresent ? "removed-or-reconciled" : "absent",
|
|
4634
|
+
path: statePaths().launchAgent,
|
|
4635
|
+
});
|
|
4636
|
+
} catch (error) {
|
|
4637
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4638
|
+
result.finalization.components.push({
|
|
4639
|
+
component: "launch-agent",
|
|
4640
|
+
status: "failed",
|
|
4641
|
+
path: statePaths().launchAgent,
|
|
4642
|
+
detail: message,
|
|
4643
|
+
});
|
|
4644
|
+
result.finalization.errors.push(message);
|
|
4645
|
+
nextDesired = desired;
|
|
4646
|
+
}
|
|
4647
|
+
} else {
|
|
4648
|
+
result.finalization.components.push({
|
|
4649
|
+
component: "launch-agent",
|
|
4650
|
+
status: "preserved",
|
|
4651
|
+
path: statePaths().launchAgent,
|
|
4652
|
+
});
|
|
4653
|
+
}
|
|
4654
|
+
if (!schedulerNeedsChange && jsonDeepEqual(nextDesired, desired)) {
|
|
4655
|
+
result.finalization.components.push({
|
|
4656
|
+
component: "desired-state",
|
|
4657
|
+
status: "preserved",
|
|
4658
|
+
path: statePaths().desiredState,
|
|
4659
|
+
});
|
|
4660
|
+
return desired;
|
|
4661
|
+
}
|
|
4662
|
+
try {
|
|
4663
|
+
nextDesired = await writeDesiredState(nextDesired);
|
|
4664
|
+
result.finalization.components.push({
|
|
4665
|
+
component: "desired-state",
|
|
4666
|
+
status: "updated",
|
|
4667
|
+
path: statePaths().desiredState,
|
|
4668
|
+
});
|
|
4669
|
+
} catch (error) {
|
|
4670
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4671
|
+
result.finalization.components.push({
|
|
4672
|
+
component: "desired-state",
|
|
4673
|
+
status: "failed",
|
|
4674
|
+
path: statePaths().desiredState,
|
|
4675
|
+
detail: message,
|
|
4676
|
+
});
|
|
4677
|
+
result.finalization.errors.push(message);
|
|
4678
|
+
}
|
|
4679
|
+
return nextDesired;
|
|
4680
|
+
}
|
|
4681
|
+
|
|
4682
|
+
async function preparePurgeStateFinalization() {
|
|
4683
|
+
const paths = statePaths();
|
|
4684
|
+
if (!(await entryExists(paths.directory))) return { directory: paths.directory, tombstones: [] };
|
|
4685
|
+
const directoryInfo = await lstat(paths.directory);
|
|
4686
|
+
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) {
|
|
4687
|
+
fail(`refusing to finalize an unsafe OpenSocrates state directory: ${paths.directory}`);
|
|
4688
|
+
}
|
|
4689
|
+
const uuid = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
|
|
4690
|
+
const stateTemporary = new RegExp(
|
|
4691
|
+
`^\\.(?:desired-state|auto-update-receipt)\\.json\\.${uuid}\\.tmp$`,
|
|
4692
|
+
"u",
|
|
4693
|
+
);
|
|
4694
|
+
const purgeTombstone = new RegExp(
|
|
4695
|
+
`^\\.purge-finalize-${uuid}-(?:desired-state\\.json|auto-update-receipt\\.json|` +
|
|
4696
|
+
`\\.(?:desired-state|auto-update-receipt)\\.json\\.${uuid}\\.tmp)$`,
|
|
4697
|
+
"u",
|
|
4698
|
+
);
|
|
4699
|
+
const candidates = [paths.desiredState, paths.receipt];
|
|
4700
|
+
const tombstones = [];
|
|
4701
|
+
for (const name of await readdir(paths.directory)) {
|
|
4702
|
+
if (stateTemporary.test(name)) {
|
|
4703
|
+
candidates.push(join(paths.directory, name));
|
|
4704
|
+
} else if (purgeTombstone.test(name)) {
|
|
4705
|
+
tombstones.push({ original: null, tombstone: join(paths.directory, name) });
|
|
4706
|
+
}
|
|
4707
|
+
}
|
|
4708
|
+
const renamed = [];
|
|
4709
|
+
try {
|
|
4710
|
+
for (const item of tombstones) {
|
|
4711
|
+
await requireSafePathBelow(paths.directory, item.tombstone, "OpenSocrates prior purge tombstone");
|
|
4712
|
+
const info = await lstat(item.tombstone);
|
|
4713
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
4714
|
+
fail(`refusing to remove an unsafe prior OpenSocrates purge tombstone: ${item.tombstone}`);
|
|
4715
|
+
}
|
|
4716
|
+
}
|
|
4717
|
+
for (const target of candidates) {
|
|
4718
|
+
if (!(await entryExists(target))) continue;
|
|
4719
|
+
await requireSafePathBelow(paths.directory, target, "OpenSocrates state file");
|
|
4720
|
+
const info = await lstat(target);
|
|
4721
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
4722
|
+
fail(`refusing to remove an unsafe OpenSocrates state file: ${target}`);
|
|
4723
|
+
}
|
|
4724
|
+
const tombstone = join(paths.directory, `.purge-finalize-${randomUUID()}-${basename(target)}`);
|
|
4725
|
+
await rename(target, tombstone);
|
|
4726
|
+
const item = { original: target, tombstone };
|
|
4727
|
+
tombstones.push(item);
|
|
4728
|
+
renamed.push(item);
|
|
4729
|
+
}
|
|
4730
|
+
return { directory: paths.directory, tombstones };
|
|
4731
|
+
} catch (error) {
|
|
4732
|
+
for (const item of [...renamed].reverse()) {
|
|
4733
|
+
await recoveryStep("restore installer state after purge finalization failed", async () => {
|
|
4734
|
+
if (await entryExists(item.tombstone)) await rename(item.tombstone, item.original);
|
|
4735
|
+
});
|
|
4736
|
+
}
|
|
4737
|
+
throw error;
|
|
4738
|
+
}
|
|
4739
|
+
}
|
|
4740
|
+
|
|
4741
|
+
async function completePurgeStateFinalization(plan, result, options) {
|
|
4742
|
+
try {
|
|
4743
|
+
for (const item of plan.tombstones) {
|
|
4744
|
+
if (!(await entryExists(item.tombstone))) continue;
|
|
4745
|
+
await requireSafePathBelow(plan.directory, item.tombstone, "OpenSocrates purge tombstone");
|
|
4746
|
+
const info = await lstat(item.tombstone);
|
|
4747
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
4748
|
+
fail(`refusing to remove an unsafe OpenSocrates purge tombstone: ${item.tombstone}`);
|
|
4749
|
+
}
|
|
4750
|
+
await rm(item.tombstone);
|
|
4751
|
+
}
|
|
4752
|
+
if (await entryExists(plan.directory)) await rmdir(plan.directory);
|
|
4753
|
+
result.finalization.components.push({
|
|
4754
|
+
component: "state-directory",
|
|
4755
|
+
status: "removed",
|
|
4756
|
+
path: plan.directory,
|
|
4757
|
+
});
|
|
4758
|
+
result.finalization.status = "complete";
|
|
4759
|
+
} catch (error) {
|
|
4760
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4761
|
+
result.finalization.components.push({
|
|
4762
|
+
component: "state-directory",
|
|
4763
|
+
status: "failed",
|
|
4764
|
+
path: plan.directory,
|
|
4765
|
+
detail: message,
|
|
4766
|
+
});
|
|
4767
|
+
result.finalization.errors.push(message);
|
|
4768
|
+
result.finalization.status = "partial";
|
|
4769
|
+
result.status = "partial";
|
|
4770
|
+
console.error(`error: OpenSocrates state cleanup remains at: ${plan.directory}`);
|
|
4771
|
+
console.error(
|
|
4772
|
+
`error: retry command: opensocrates remove --host ${options.host} --purge` +
|
|
4773
|
+
(options.resetTrust ? " --reset-trust" : ""),
|
|
4774
|
+
);
|
|
4775
|
+
}
|
|
4776
|
+
}
|
|
4777
|
+
|
|
4778
|
+
async function runPurgeLocked(options) {
|
|
4779
|
+
const hosts = options.host === ALL_HOST ? SUPPORTED_HOSTS : [options.host];
|
|
4780
|
+
const result = createPurgeResult(hosts, { resetTrust: options.resetTrust });
|
|
4781
|
+
const desired = await readDesiredState();
|
|
4782
|
+
for (const hostResult of result.hosts) await purgeOneHost(hostResult, options);
|
|
4783
|
+
const nextDesired = await updatePurgeDesiredState(options, desired, result);
|
|
4784
|
+
const hostsComplete = result.hosts.every((item) => item.status === "complete");
|
|
4785
|
+
const finalizationComplete = result.finalization.errors.length === 0;
|
|
4786
|
+
result.status = hostsComplete && finalizationComplete ? "complete" : "partial";
|
|
4787
|
+
let stateFinalization = null;
|
|
4788
|
+
if (
|
|
4789
|
+
result.status === "complete" &&
|
|
4790
|
+
nextDesired.installedHosts.length === 0 &&
|
|
4791
|
+
!nextDesired.autoUpdate.enabled
|
|
4792
|
+
) {
|
|
4793
|
+
try {
|
|
4794
|
+
stateFinalization = await preparePurgeStateFinalization();
|
|
4795
|
+
result.finalization.status = "ready";
|
|
4796
|
+
} catch (error) {
|
|
4797
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4798
|
+
result.finalization.components.push({
|
|
4799
|
+
component: "state-finalization",
|
|
4800
|
+
status: "failed",
|
|
4801
|
+
path: statePaths().directory,
|
|
4802
|
+
detail: message,
|
|
4803
|
+
});
|
|
4804
|
+
result.finalization.errors.push(message);
|
|
4805
|
+
result.finalization.status = "partial";
|
|
4806
|
+
result.status = "partial";
|
|
4807
|
+
}
|
|
4808
|
+
} else {
|
|
4809
|
+
result.finalization.status = result.status === "complete" ? "preserved" : "deferred";
|
|
4810
|
+
}
|
|
4811
|
+
return { result, stateFinalization };
|
|
4812
|
+
}
|
|
4813
|
+
|
|
4814
|
+
function reportPurgeResult(result) {
|
|
4815
|
+
for (const host of result.hosts) {
|
|
4816
|
+
console.log(`${host.host}: purge ${host.status}; registration ${host.registration}.`);
|
|
4817
|
+
if (
|
|
4818
|
+
new Set(["unverified", "failed"]).has(host.registration) &&
|
|
4819
|
+
typeof host.registrationDetail === "string"
|
|
4820
|
+
) {
|
|
4821
|
+
console.error(`${host.host}: registration ${host.registration} (${host.registrationDetail})`);
|
|
4822
|
+
}
|
|
4823
|
+
for (const component of host.components) {
|
|
4824
|
+
if (new Set(["pending", "failed"]).has(component.status)) {
|
|
4825
|
+
console.error(
|
|
4826
|
+
`${host.host}: ${component.component} ${component.status}` +
|
|
4827
|
+
(component.path ? ` at ${component.path}` : "") +
|
|
4828
|
+
(component.detail ? ` (${component.detail})` : ""),
|
|
4829
|
+
);
|
|
4830
|
+
}
|
|
4831
|
+
}
|
|
4832
|
+
if (host.extension.status === "preserved") {
|
|
4833
|
+
console.log(
|
|
4834
|
+
`${host.host}: host security trust was preserved; rerun purge with --reset-trust to reset only ` +
|
|
4835
|
+
"the exact OpenSocrates hook approvals.",
|
|
4836
|
+
);
|
|
4837
|
+
} else if (host.extension.status === "reset") {
|
|
4838
|
+
console.log(
|
|
4839
|
+
`${host.host}: host security trust reset completed for ${host.extension.removedCount} exact ` +
|
|
4840
|
+
"OpenSocrates hook entries.",
|
|
4841
|
+
);
|
|
4842
|
+
} else if (host.extension.status === "absent") {
|
|
4843
|
+
console.log(`${host.host}: no exact OpenSocrates host security trust entries were present.`);
|
|
4844
|
+
} else if (host.extension.status === "failed") {
|
|
4845
|
+
console.error(
|
|
4846
|
+
`${host.host}: host security trust reset failed; no successful trust mutation is claimed.`,
|
|
4847
|
+
);
|
|
4848
|
+
console.error(
|
|
4849
|
+
`${host.host}: rerun the same purge with --reset-trust after resolving Codex config validation, ` +
|
|
4850
|
+
"or back up the config and manually review only the seven exact OpenSocrates hook sections.",
|
|
4851
|
+
);
|
|
4852
|
+
}
|
|
4853
|
+
}
|
|
4854
|
+
for (const component of result.finalization.components) {
|
|
4855
|
+
if (new Set(["pending", "failed"]).has(component.status)) {
|
|
4856
|
+
console.error(
|
|
4857
|
+
`finalization: ${component.component} ${component.status}` +
|
|
4858
|
+
(component.path ? ` at ${component.path}` : "") +
|
|
4859
|
+
(component.detail ? ` (${component.detail})` : ""),
|
|
4860
|
+
);
|
|
4861
|
+
}
|
|
4862
|
+
}
|
|
4863
|
+
if (result.status === "complete") {
|
|
4864
|
+
console.log("OpenSocrates purge completed for registrations and provably owned payloads.");
|
|
4865
|
+
console.log("User task, project, chat, plan, and history data was preserved.");
|
|
4866
|
+
} else {
|
|
4867
|
+
console.error("OpenSocrates purge is incomplete; no complete-uninstall success is claimed.");
|
|
4868
|
+
console.error("Resolve the reported item, close active hosts if needed, and rerun the same purge command.");
|
|
4869
|
+
}
|
|
4870
|
+
}
|
|
4871
|
+
|
|
4872
|
+
async function runPurge(options) {
|
|
4873
|
+
const outcome = await withOperationLock(() => runPurgeLocked(options));
|
|
4874
|
+
if (outcome.stateFinalization !== null) {
|
|
4875
|
+
await completePurgeStateFinalization(outcome.stateFinalization, outcome.result, options);
|
|
4876
|
+
}
|
|
4877
|
+
reportPurgeResult(outcome.result);
|
|
4878
|
+
if (outcome.result.status !== "complete") {
|
|
4879
|
+
fail("purge remains pending or failed for one or more components");
|
|
4880
|
+
}
|
|
4881
|
+
return outcome.result;
|
|
2007
4882
|
}
|
|
2008
4883
|
|
|
2009
4884
|
async function enableAutoUpdate(options) {
|
|
@@ -2042,19 +4917,14 @@ async function enableAutoUpdate(options) {
|
|
|
2042
4917
|
const observedVersions = new Set();
|
|
2043
4918
|
for (const host of installedHosts) {
|
|
2044
4919
|
const preflight = preflights.get(host) ?? (await preflightHost(host, "update"));
|
|
2045
|
-
if (
|
|
2046
|
-
preflight.previousState.kind !== "installed" ||
|
|
2047
|
-
typeof preflight.previousState.version !== "string"
|
|
2048
|
-
) {
|
|
4920
|
+
if (preflight.previousState.kind !== "installed" || typeof preflight.previousState.version !== "string") {
|
|
2049
4921
|
fail(`cannot determine the installed OpenSocrates version on ${host}`);
|
|
2050
4922
|
}
|
|
2051
4923
|
preflights.set(host, preflight);
|
|
2052
4924
|
observedVersions.add(preflight.previousState.version);
|
|
2053
4925
|
}
|
|
2054
4926
|
if (observedVersions.size !== 1) {
|
|
2055
|
-
fail(
|
|
2056
|
-
"installed hosts do not share one known version; run update --host all before enabling automatic updates",
|
|
2057
|
-
);
|
|
4927
|
+
fail("installed hosts do not share one known version; run update --host all before enabling automatic updates");
|
|
2058
4928
|
}
|
|
2059
4929
|
[activeVersion] = observedVersions;
|
|
2060
4930
|
}
|
|
@@ -2158,18 +5028,20 @@ async function runScheduledUpdate(options) {
|
|
|
2158
5028
|
for (const host of hosts) ready.push(await preflightHost(host, "update"));
|
|
2159
5029
|
const alreadyCurrent =
|
|
2160
5030
|
desired.activeVersion === PRODUCT_VERSION &&
|
|
2161
|
-
ready.every(
|
|
2162
|
-
(item) =>
|
|
2163
|
-
item.previousState.kind === "installed" && item.previousState.version === PRODUCT_VERSION,
|
|
2164
|
-
);
|
|
5031
|
+
ready.every((item) => item.previousState.kind === "installed" && item.previousState.version === PRODUCT_VERSION);
|
|
2165
5032
|
if (!alreadyCurrent) {
|
|
2166
|
-
if (hosts.length
|
|
5033
|
+
if (hosts.length > 1) {
|
|
2167
5034
|
await runInstallOrUpdate({ ...options, host: ALL_HOST }, "update");
|
|
2168
5035
|
} else {
|
|
2169
5036
|
const [host] = hosts;
|
|
2170
5037
|
const local = localAssetInputs(options, host);
|
|
2171
5038
|
await runInstallOrUpdate(
|
|
2172
|
-
{
|
|
5039
|
+
{
|
|
5040
|
+
...options,
|
|
5041
|
+
host,
|
|
5042
|
+
asset: local.asset,
|
|
5043
|
+
checksum: local.checksum,
|
|
5044
|
+
},
|
|
2173
5045
|
"update",
|
|
2174
5046
|
);
|
|
2175
5047
|
}
|
|
@@ -2179,15 +5051,16 @@ async function runScheduledUpdate(options) {
|
|
|
2179
5051
|
...refreshed,
|
|
2180
5052
|
availableVersion: PRODUCT_VERSION,
|
|
2181
5053
|
lastCheckAt: checkedAt,
|
|
2182
|
-
lastSuccessfulUpdateAt: alreadyCurrent
|
|
2183
|
-
? refreshed.lastSuccessfulUpdateAt
|
|
2184
|
-
: checkedAt,
|
|
5054
|
+
lastSuccessfulUpdateAt: alreadyCurrent ? refreshed.lastSuccessfulUpdateAt : checkedAt,
|
|
2185
5055
|
autoUpdate: { ...refreshed.autoUpdate, nextCheckAt: next },
|
|
2186
5056
|
});
|
|
2187
5057
|
await writeAutoUpdateReceipt({
|
|
2188
5058
|
version: PRODUCT_VERSION,
|
|
2189
5059
|
checkedAt,
|
|
2190
|
-
hosts: hosts.map((host) => ({
|
|
5060
|
+
hosts: hosts.map((host) => ({
|
|
5061
|
+
host,
|
|
5062
|
+
result: alreadyCurrent ? "current" : "updated",
|
|
5063
|
+
})),
|
|
2191
5064
|
result: alreadyCurrent ? "no-update" : "updated",
|
|
2192
5065
|
errorCategory: null,
|
|
2193
5066
|
});
|
|
@@ -2226,23 +5099,35 @@ function requireSupportedPlatform() {
|
|
|
2226
5099
|
}
|
|
2227
5100
|
|
|
2228
5101
|
async function verifyPackages(options) {
|
|
2229
|
-
const
|
|
5102
|
+
const assetHosts = qualifiedAssetHosts(options);
|
|
5103
|
+
const hosts =
|
|
5104
|
+
options.host === ALL_HOST ? (assetHosts.length > 0 ? assetHosts : SUPPORTED_HOSTS) : [options.host];
|
|
2230
5105
|
const prepared = await prepareVerifiedPackages(options, hosts);
|
|
2231
5106
|
try {
|
|
2232
5107
|
for (const item of prepared) {
|
|
2233
5108
|
console.log(
|
|
2234
|
-
`${item.host}: verified OpenSocrates ${PRODUCT_VERSION} release and ` +
|
|
2235
|
-
`${item.checkedFiles} package files.`,
|
|
5109
|
+
`${item.host}: verified OpenSocrates ${PRODUCT_VERSION} release and ` + `${item.checkedFiles} package files.`,
|
|
2236
5110
|
);
|
|
5111
|
+
if (item.host === "opencode") {
|
|
5112
|
+
const paths = managedPaths("opencode");
|
|
5113
|
+
const presence = await Promise.all(
|
|
5114
|
+
[paths.root, paths.bridge, paths.bridgeMarker].map((target) => exists(target)),
|
|
5115
|
+
);
|
|
5116
|
+
if (presence.some(Boolean)) {
|
|
5117
|
+
if (!presence.every(Boolean)) {
|
|
5118
|
+
fail("OpenCode installed state is partial and failed verification");
|
|
5119
|
+
}
|
|
5120
|
+
await verifyOpenCodeInstallation(paths);
|
|
5121
|
+
console.log("opencode: verified installed bridge, skill inventory, and ownership.");
|
|
5122
|
+
}
|
|
5123
|
+
}
|
|
2237
5124
|
}
|
|
2238
5125
|
} finally {
|
|
2239
|
-
await Promise.all(
|
|
2240
|
-
prepared.map((item) => rm(item.scratch, { recursive: true, force: true })),
|
|
2241
|
-
);
|
|
5126
|
+
await Promise.all(prepared.map((item) => rm(item.scratch, { recursive: true, force: true })));
|
|
2242
5127
|
}
|
|
2243
5128
|
}
|
|
2244
5129
|
|
|
2245
|
-
export async function main(argv = process.argv.slice(2)) {
|
|
5130
|
+
export async function main(argv = process.argv.slice(2), internalDependencies = {}) {
|
|
2246
5131
|
const options = parseCli(argv);
|
|
2247
5132
|
if (options.action === "help") {
|
|
2248
5133
|
showHelp();
|
|
@@ -2260,9 +5145,18 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
2260
5145
|
await showAutoUpdateStatus();
|
|
2261
5146
|
return 0;
|
|
2262
5147
|
}
|
|
5148
|
+
if (options.action === "remove" && options.purge) {
|
|
5149
|
+
await runPurge({
|
|
5150
|
+
...options,
|
|
5151
|
+
trustResetHooks: internalDependencies.trustResetHooks ?? {},
|
|
5152
|
+
});
|
|
5153
|
+
return 0;
|
|
5154
|
+
}
|
|
2263
5155
|
return withOperationLock(async () => {
|
|
2264
5156
|
if (options.action === "remove") {
|
|
2265
|
-
await
|
|
5157
|
+
await runStandardRemove(options, {
|
|
5158
|
+
beforeBackupDelete: internalDependencies.beforeRemovalBackupDelete ?? null,
|
|
5159
|
+
});
|
|
2266
5160
|
return 0;
|
|
2267
5161
|
}
|
|
2268
5162
|
if (options.action === "auto-update") {
|