opensocrates 1.4.0 → 1.5.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 +63 -0
- package/README.ja.md +129 -0
- package/README.ko.md +128 -71
- package/README.md +138 -79
- package/README.zh.md +113 -0
- package/SECURITY.md +49 -6
- package/VERSION +1 -1
- package/installer/managed-hosts.mjs +668 -0
- package/installer/opensocrates.mjs +105 -5
- package/installer/windows.ps1 +124 -5
- package/package.json +5 -4
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
// Additive managed-host lifecycle. No Codex engine, user settings rewrite or model calls.
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const HOSTS = new Set(["claude", "antigravity", "claude-chat"]);
|
|
11
|
+
const ACTIONS = new Set(["install", "update", "status", "diagnose", "remove", "disable", "enable", "verify", "export"]);
|
|
12
|
+
const MARKER = ".opensocrates-managed.json";
|
|
13
|
+
const MARKER_SCHEMA = "opensocrates.managed-host/1.0.0";
|
|
14
|
+
const NAMESPACE = "opensocrates-macos";
|
|
15
|
+
const PLUGIN_ID = `opensocrates@${NAMESPACE}`;
|
|
16
|
+
const SHA = /^(?:sha256:)?[a-f0-9]{64}$/u;
|
|
17
|
+
const METHOD = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
18
|
+
const LOCALES = ["en", "ko", "zh", "ja"];
|
|
19
|
+
const MAX_FILES = 10_000;
|
|
20
|
+
const MAX_JSON_BYTES = 8 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
export class ManagedHostError extends Error {
|
|
23
|
+
constructor(code, message = code) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "ManagedHostError";
|
|
26
|
+
this.code = code;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fail(code, message) { throw new ManagedHostError(code, message); }
|
|
31
|
+
function windowsArgs(action) {
|
|
32
|
+
return ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", fileURLToPath(new URL("./windows.ps1", import.meta.url)), "-Action", action];
|
|
33
|
+
}
|
|
34
|
+
function windows(action, path) {
|
|
35
|
+
if (process.platform !== "win32") return;
|
|
36
|
+
const value = spawnSync("powershell.exe", windowsArgs(action), { encoding: "utf8", windowsHide: true, timeout: 30_000, maxBuffer: 1024 * 1024, env: { ...process.env, OPENSOCRATES_WINDOWS_PATH: path } });
|
|
37
|
+
if (value.error || value.status !== 0) fail("unsafe_windows_path", "Windows ownership, DACL or reparse validation failed; no unowned path is modified.");
|
|
38
|
+
}
|
|
39
|
+
async function privateDirectory(path) {
|
|
40
|
+
if (process.platform === "win32") windows("mkdir-private", path);
|
|
41
|
+
else await fs.mkdir(path, { mode: 0o700 });
|
|
42
|
+
}
|
|
43
|
+
async function privateScratch(parent, prefix) {
|
|
44
|
+
const path = join(parent, `${prefix}${randomUUID()}`);
|
|
45
|
+
await privateDirectory(path);
|
|
46
|
+
return path;
|
|
47
|
+
}
|
|
48
|
+
async function withWindowsLease(path, action) {
|
|
49
|
+
if (process.platform !== "win32") return action();
|
|
50
|
+
const lease = spawn("powershell.exe", windowsArgs("lease"), { windowsHide: true, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, OPENSOCRATES_WINDOWS_PATH: path } });
|
|
51
|
+
let ready = false, exited = false;
|
|
52
|
+
lease.on("exit", () => { exited = true; });
|
|
53
|
+
lease.stderr.resume(); // Host/user paths and raw helper errors are never retained.
|
|
54
|
+
try {
|
|
55
|
+
await new Promise((accept, reject) => {
|
|
56
|
+
let output = "";
|
|
57
|
+
const timer = setTimeout(() => reject(new ManagedHostError("unsafe_windows_path", "Cannot acquire the Windows directory lease.")), 30_000);
|
|
58
|
+
const failed = () => { clearTimeout(timer); reject(new ManagedHostError("unsafe_windows_path", "Windows directory lease was unavailable.")); };
|
|
59
|
+
lease.once("error", failed); lease.once("exit", failed);
|
|
60
|
+
lease.stdout.on("data", (data) => {
|
|
61
|
+
output += data.toString("utf8");
|
|
62
|
+
if (output.length > 64) failed();
|
|
63
|
+
if (output === "ready\r\n" || output === "ready\n") { ready = true; clearTimeout(timer); accept(); }
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
const value = await action();
|
|
67
|
+
if (exited) fail("lease_interrupted", "Windows ancestor protection was interrupted; inspect the owned recovery state before retrying.");
|
|
68
|
+
return value;
|
|
69
|
+
} finally {
|
|
70
|
+
if (ready && !exited) {
|
|
71
|
+
const closed = new Promise((accept) => lease.once("exit", accept));
|
|
72
|
+
lease.stdin.end("done\n");
|
|
73
|
+
await closed;
|
|
74
|
+
} else if (!exited) lease.kill();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function object(value) { return value !== null && typeof value === "object" && !Array.isArray(value); }
|
|
78
|
+
function hash(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
|
|
79
|
+
function digest(value) {
|
|
80
|
+
if (typeof value !== "string" || !SHA.test(value)) fail("invalid_inventory", "Invalid SHA-256 inventory.");
|
|
81
|
+
return value.replace(/^sha256:/u, "");
|
|
82
|
+
}
|
|
83
|
+
function safeRelative(value) {
|
|
84
|
+
if (typeof value !== "string" || !value || value.includes("\\") || value.includes("\0") || isAbsolute(value) || value.split("/").some((p) => !p || p === "." || p === "..")) {
|
|
85
|
+
fail("unsafe_path", "Package inventory contains an unsafe relative path.");
|
|
86
|
+
}
|
|
87
|
+
if (value.split("/").some((part) => /[\x00-\x1f<>:"|?*]/u.test(part) || /[. ]$/u.test(part) || /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/iu.test(part))) fail("unsafe_path", "Package paths must not use Windows aliases or alternate streams.");
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
async function entry(path) {
|
|
91
|
+
try { return await fs.lstat(path); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
92
|
+
}
|
|
93
|
+
async function regular(path) {
|
|
94
|
+
const info = await entry(path);
|
|
95
|
+
if (!info || !info.isFile() || info.isSymbolicLink()) fail("unsafe_path", "Expected an owned regular file.");
|
|
96
|
+
return info;
|
|
97
|
+
}
|
|
98
|
+
async function safeParents(path, { create = false } = {}) {
|
|
99
|
+
const absolute = resolve(path);
|
|
100
|
+
if (process.platform === "win32" && !/^[a-z]:[\\/]/iu.test(absolute)) fail("unsafe_path", "Managed Windows paths require a local absolute drive path.");
|
|
101
|
+
const root = parse(absolute).root;
|
|
102
|
+
const parts = absolute.slice(root.length).split(sep).filter(Boolean);
|
|
103
|
+
let current = root;
|
|
104
|
+
for (const part of parts) {
|
|
105
|
+
current = join(current, part);
|
|
106
|
+
let info = await entry(current);
|
|
107
|
+
if (!info && create) { await privateDirectory(current); info = await fs.lstat(current); }
|
|
108
|
+
if (!info || !info.isDirectory() || info.isSymbolicLink()) fail("unsafe_path", "Managed directory or ancestor is missing, linked or not a directory.");
|
|
109
|
+
}
|
|
110
|
+
return absolute;
|
|
111
|
+
}
|
|
112
|
+
async function json(path) {
|
|
113
|
+
const info = await regular(path);
|
|
114
|
+
if (info.size > MAX_JSON_BYTES) fail("invalid_manifest", "Manifest exceeds the bounded input size.");
|
|
115
|
+
let result;
|
|
116
|
+
try { result = JSON.parse(await fs.readFile(path, "utf8")); } catch { fail("invalid_manifest", "Cannot read a valid managed manifest."); }
|
|
117
|
+
if (!object(result)) fail("invalid_manifest", "Managed manifest must be an object.");
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
async function files(root) {
|
|
121
|
+
windows("check-tree", root);
|
|
122
|
+
const rootInfo = await entry(root);
|
|
123
|
+
if (!rootInfo || !rootInfo.isDirectory() || rootInfo.isSymbolicLink()) fail("unsafe_path", "Package or managed root is not a regular directory.");
|
|
124
|
+
const result = {};
|
|
125
|
+
async function walk(directory) {
|
|
126
|
+
const children = await fs.readdir(directory, { withFileTypes: true });
|
|
127
|
+
for (const child of children) {
|
|
128
|
+
const path = join(directory, child.name);
|
|
129
|
+
const info = await fs.lstat(path);
|
|
130
|
+
if (info.isSymbolicLink()) fail("unsafe_path", "Symlinks are not supported in managed packages.");
|
|
131
|
+
if (info.isDirectory()) await walk(path);
|
|
132
|
+
else if (info.isFile()) {
|
|
133
|
+
if (Object.keys(result).length >= MAX_FILES) fail("invalid_inventory", "Managed inventory exceeds its file bound.");
|
|
134
|
+
result[relative(root, path).split(sep).join("/")] = hash(await fs.readFile(path));
|
|
135
|
+
} else fail("unsafe_path", "Special filesystem entries are not supported.");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
await walk(root);
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
function inventory(rows) {
|
|
142
|
+
if (!Array.isArray(rows) || !rows.length || rows.length > MAX_FILES) fail("invalid_inventory", "Missing complete file inventory.");
|
|
143
|
+
const result = {};
|
|
144
|
+
for (const item of rows) {
|
|
145
|
+
if (!object(item) || Object.keys(item).sort().join(",") !== "path,sha256") fail("invalid_inventory", "Inventory rows must contain path and sha256 only.");
|
|
146
|
+
const name = safeRelative(item.path);
|
|
147
|
+
if (Object.hasOwn(result, name)) fail("invalid_inventory", "Duplicate inventory path.");
|
|
148
|
+
result[name] = digest(item.sha256);
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
function equalInventory(actual, expected) {
|
|
153
|
+
const names = Object.keys(actual).sort();
|
|
154
|
+
return JSON.stringify(names) === JSON.stringify(Object.keys(expected).sort()) && names.every((name) => actual[name] === expected[name]);
|
|
155
|
+
}
|
|
156
|
+
function packageLocales(version) {
|
|
157
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/u.exec(version);
|
|
158
|
+
if (!match) fail("package_identity_mismatch", "Invalid package version.");
|
|
159
|
+
const [major, minor, patch] = match.slice(1).map(Number);
|
|
160
|
+
return major > 1 || (major === 1 && (minor > 5 || (minor === 5 && patch >= 1))) ? LOCALES : ["en", "ko"];
|
|
161
|
+
}
|
|
162
|
+
function canonical(manifest, expected, skillRoot) {
|
|
163
|
+
const locales = packageLocales(manifest.product_version);
|
|
164
|
+
const ids = manifest.method_ids;
|
|
165
|
+
if (manifest.method_count !== 48 || !Array.isArray(ids) || ids.length !== 48 || new Set(ids).size !== 48 || ids.some((id) => typeof id !== "string" || !METHOD.test(id))) fail("invalid_canonical_content", "Expected 48 unique canonical methods.");
|
|
166
|
+
if (!Array.isArray(manifest.canonical_methods) || manifest.canonical_methods.length !== 48 * locales.length) fail("invalid_canonical_content", "Expected the complete canonical procedure inventory for this version.");
|
|
167
|
+
const seen = new Set();
|
|
168
|
+
for (const row of manifest.canonical_methods) {
|
|
169
|
+
if (!object(row) || Object.keys(row).sort().join(",") !== "locale,method_id,path,sha256" || !ids.includes(row.method_id) || !locales.includes(row.locale)) fail("invalid_canonical_content", "Canonical procedure identity is invalid.");
|
|
170
|
+
const name = `${skillRoot}/references/decision/methods/${row.locale}/${row.method_id}.md`;
|
|
171
|
+
const key = `${row.locale}:${row.method_id}`;
|
|
172
|
+
if (row.path !== name || seen.has(key) || expected[name] !== digest(row.sha256)) fail("invalid_canonical_content", "Canonical procedure path or hash is inconsistent.");
|
|
173
|
+
seen.add(key);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async function verifyPackage(root, host, version) {
|
|
177
|
+
const actual = await files(root);
|
|
178
|
+
const manifestPath = host === "claude-chat" ? "opensocrates/release-manifest.json" : "release-manifest.json";
|
|
179
|
+
const manifest = await json(join(root, manifestPath));
|
|
180
|
+
if (manifest.product_version !== version || manifest.host !== host) fail("package_identity_mismatch", "Package host/version does not match the requested installer.");
|
|
181
|
+
const expected = inventory(manifest.files);
|
|
182
|
+
if (Object.hasOwn(expected, manifestPath)) fail("invalid_inventory", "A manifest cannot inventory itself.");
|
|
183
|
+
const rest = { ...actual };
|
|
184
|
+
delete rest[manifestPath];
|
|
185
|
+
if (host === "claude") {
|
|
186
|
+
if (manifest.schema !== "opensocrates.plugin-release-manifest/1.0.0") fail("invalid_manifest", "Invalid native plugin release manifest.");
|
|
187
|
+
// Release assembly adds an exact package checksum file outside the generator inventory.
|
|
188
|
+
if (Object.hasOwn(rest, "checksums.sha256") && !Object.hasOwn(expected, "checksums.sha256")) {
|
|
189
|
+
const text = await fs.readFile(join(root, "checksums.sha256"), "utf8");
|
|
190
|
+
const checksums = {};
|
|
191
|
+
for (const line of text.trim().split(/\r?\n/u)) {
|
|
192
|
+
const match = /^([a-f0-9]{64}) (.+)$/u.exec(line);
|
|
193
|
+
if (!match || Object.hasOwn(checksums, match[2])) fail("invalid_inventory", "Invalid native checksum inventory.");
|
|
194
|
+
checksums[safeRelative(match[2])] = match[1];
|
|
195
|
+
}
|
|
196
|
+
const full = { ...actual }; delete full["checksums.sha256"];
|
|
197
|
+
if (!equalInventory(full, checksums)) fail("inventory_mismatch", "Native checksum inventory does not match the package.");
|
|
198
|
+
delete rest["checksums.sha256"];
|
|
199
|
+
}
|
|
200
|
+
if (!equalInventory(rest, expected)) fail("inventory_mismatch", "Native package has modified, missing or unknown files.");
|
|
201
|
+
const plugin = await json(join(root, ".claude-plugin/plugin.json"));
|
|
202
|
+
if (plugin.name !== "opensocrates" || plugin.version !== version || manifest.method_count !== 48 || !Array.isArray(manifest.method_ids) || manifest.method_ids.length !== 48 || new Set(manifest.method_ids).size !== 48 || manifest.method_ids.some((id) => typeof id !== "string" || !METHOD.test(id))) fail("package_identity_mismatch", "Native plugin or core method identity is inconsistent.");
|
|
203
|
+
for (const key of ["runtime_targets", "release_targets"]) if (JSON.stringify(manifest[key]) !== '["darwin-arm64"]') fail("unsupported_package_target", "The Claude companion must contain only the Apple-silicon Mac runtime.");
|
|
204
|
+
if (Object.keys(expected).some((name) => name.startsWith("runtime/") && !name.startsWith("runtime/darwin-arm64/"))) fail("unsupported_package_target", "The Claude companion contains an undeclared runtime target.");
|
|
205
|
+
const runtime = join(root, "runtime/darwin-arm64/opensocrates-runtime/opensocrates-runtime");
|
|
206
|
+
if (process.platform !== "win32" && !((await regular(runtime)).mode & 0o111)) fail("invalid_runtime", "The Mac runtime is not executable.");
|
|
207
|
+
await regular(runtime);
|
|
208
|
+
const launcher = join(root, "bin/launch.sh");
|
|
209
|
+
if ((process.platform !== "win32" && !((await regular(launcher)).mode & 0o111)) || JSON.stringify(manifest.launchers) !== '["bin/launch.sh"]') fail("invalid_runtime", "The Mac launcher identity is invalid.");
|
|
210
|
+
await regular(launcher);
|
|
211
|
+
const locales = packageLocales(version);
|
|
212
|
+
const localeIds = Object.fromEntries(locales.map((locale) => [locale, new Set()]));
|
|
213
|
+
for (const name of Object.keys(expected)) {
|
|
214
|
+
const match = /^skills\/opensocrates\/references\/decision\/methods\/(en|ko|zh|ja)\/([a-z0-9-]+)\.md$/u.exec(name);
|
|
215
|
+
if (match && Object.hasOwn(localeIds, match[1])) localeIds[match[1]].add(match[2]);
|
|
216
|
+
}
|
|
217
|
+
for (const locale of locales) if (localeIds[locale].size !== 48 || manifest.method_ids.some((id) => !localeIds[locale].has(id))) fail("invalid_canonical_content", "The native companion is missing a complete required locale of canonical procedures.");
|
|
218
|
+
} else {
|
|
219
|
+
if (manifest.schema !== "opensocrates.content-host-manifest/1.0.0" || !equalInventory(rest, expected)) fail("inventory_mismatch", "Content package has modified, missing or unknown files.");
|
|
220
|
+
const skillRoot = host === "claude-chat" ? "opensocrates" : ".agents/skills/opensocrates";
|
|
221
|
+
if (manifest.skill_root !== skillRoot || !object(manifest.source_identity) || !/^sha256:[a-f0-9]{64}$/u.test(manifest.source_identity.canonical ?? "") || !/^sha256:[a-f0-9]{64}$/u.test(manifest.source_identity.templates ?? "") || manifest.source_identity.canonical !== manifest.source_tree_hash) fail("invalid_manifest", "Content package source identity is missing or inconsistent.");
|
|
222
|
+
for (const key of ["launchers", "runtime_targets"]) if (JSON.stringify(manifest[key]) !== "[]") fail("unexpected_executable_surface", "Content packages cannot carry launchers or native runtimes.");
|
|
223
|
+
if (JSON.stringify(manifest.public_skills) !== '["opensocrates"]') fail("invalid_manifest", "Expected one public discovery controller.");
|
|
224
|
+
for (const name of Object.keys(actual)) {
|
|
225
|
+
if (host === "claude-chat" ? !name.startsWith("opensocrates/") : name !== manifestPath && name !== ".agents/rules/opensocrates.md" && !name.startsWith(`${skillRoot}/`)) fail("invalid_layout", "Unexpected content package layout.");
|
|
226
|
+
if (name.split("/").some((part) => ["bin", "runtime", "hooks", ".claude-plugin"].includes(part))) fail("unexpected_executable_surface", "Content package contains a native integration surface.");
|
|
227
|
+
if (process.platform !== "win32" && (await regular(join(root, name))).mode & 0o111) fail("unexpected_executable_surface", "Content package contains an executable file.");
|
|
228
|
+
if (/\.(?:exe|dll|com|cmd|bat|ps1|sh|mjs|py|pyc)$/iu.test(name)) fail("unexpected_executable_surface", "Content packages contain authored references only.");
|
|
229
|
+
}
|
|
230
|
+
if (!Object.hasOwn(expected, `${skillRoot}/SKILL.md`)) fail("invalid_layout", "Discovery skill is missing.");
|
|
231
|
+
canonical(manifest, expected, skillRoot);
|
|
232
|
+
if (host === "antigravity") {
|
|
233
|
+
const rule = await fs.readFile(join(root, ".agents/rules/opensocrates.md"));
|
|
234
|
+
if (rule.length > 4096 || !rule.toString("utf8").startsWith("---\ntrigger: always_on\n")) fail("invalid_layout", "Antigravity entry must be a bounded always_on rule.");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { root, host, version, manifest, manifestPath, hashes: actual };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function marker(host, scope, version, hashes, enabled) {
|
|
241
|
+
return { schema: MARKER_SCHEMA, host, scope, version, hashes, enabled };
|
|
242
|
+
}
|
|
243
|
+
function validateMarker(value, host, scope) {
|
|
244
|
+
if (!object(value) || Object.keys(value).sort().join(",") !== "enabled,hashes,host,schema,scope,version" || value.schema !== MARKER_SCHEMA || value.host !== host || value.scope !== scope || typeof value.enabled !== "boolean" || !/^\d+\.\d+\.\d+$/u.test(value.version) || !object(value.hashes) || !Object.keys(value.hashes).length) fail("unowned_collision", "Existing managed root has no exact ownership marker.");
|
|
245
|
+
for (const [name, valueHash] of Object.entries(value.hashes)) { safeRelative(name); digest(valueHash); }
|
|
246
|
+
return value;
|
|
247
|
+
}
|
|
248
|
+
async function writeJson(path, value) {
|
|
249
|
+
await fs.writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
250
|
+
windows("seal-new", path);
|
|
251
|
+
}
|
|
252
|
+
async function atomicMarker(path, value) {
|
|
253
|
+
const temporary = join(dirname(path), `.opensocrates-marker-${randomUUID()}`);
|
|
254
|
+
try { await writeJson(temporary, value); await fs.rename(temporary, path); }
|
|
255
|
+
finally { await fs.rm(temporary, { force: true }); }
|
|
256
|
+
}
|
|
257
|
+
async function withLock(root, host, action) {
|
|
258
|
+
await safeParents(root, { create: true });
|
|
259
|
+
windows("check", root);
|
|
260
|
+
const lock = join(root, `.opensocrates-${host}-operation.lock`);
|
|
261
|
+
let descriptor;
|
|
262
|
+
try { descriptor = await fs.open(lock, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
|
|
263
|
+
catch (error) { if (error.code === "EEXIST" || error.code === "ELOOP") fail("operation_locked", "Another managed-host operation is active; no stale lock is removed automatically."); throw error; }
|
|
264
|
+
const identity = await descriptor.stat();
|
|
265
|
+
try { windows("seal-new", lock); return await withWindowsLease(root, action); }
|
|
266
|
+
finally {
|
|
267
|
+
const current = await entry(lock);
|
|
268
|
+
await descriptor.close();
|
|
269
|
+
if (!current || current.dev !== identity.dev || current.ino !== identity.ino || current.isSymbolicLink()) fail("operation_lock_changed", "Operation lock changed; no replacement lock is deleted.");
|
|
270
|
+
await fs.unlink(lock);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function basePaths(options) {
|
|
275
|
+
if (options.host === "claude") {
|
|
276
|
+
if (options.workspace) fail("invalid_scope", "The native Claude companion uses explicit user scope.");
|
|
277
|
+
const base = resolve(process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"));
|
|
278
|
+
return { base, scope: "global", root: join(base, "managed-marketplaces", NAMESPACE) };
|
|
279
|
+
}
|
|
280
|
+
if (options.workspace !== null && options.workspace !== undefined) {
|
|
281
|
+
if (typeof options.workspace !== "string" || !isAbsolute(options.workspace)) fail("invalid_scope", "Workspace must be an explicit absolute existing directory.");
|
|
282
|
+
const workspace = resolve(options.workspace);
|
|
283
|
+
return { workspace, base: join(workspace, ".agents"), scope: "workspace", root: join(workspace, ".agents/.opensocrates-managed/antigravity"), rule: join(workspace, ".agents/rules/opensocrates.md"), skill: join(workspace, ".agents/skills/opensocrates") };
|
|
284
|
+
}
|
|
285
|
+
const base = resolve(process.env.ANTIGRAVITY_CONFIG_DIR || join(homedir(), ".gemini/config"));
|
|
286
|
+
return { base, scope: "global", root: join(base, ".opensocrates-managed/antigravity"), rule: join(base, "rules/opensocrates.md"), skill: join(base, "skills/opensocrates") };
|
|
287
|
+
}
|
|
288
|
+
function result(options, details = {}) {
|
|
289
|
+
return { host: options.host, action: options.action, automatic_entry: "unverified", method_application: "unverified", ...details };
|
|
290
|
+
}
|
|
291
|
+
function cli(helpers, args, { jsonOutput = false } = {}) {
|
|
292
|
+
const command = spawnSync(helpers.claudeBinary || process.env.CLAUDE_BIN || "claude", args, { encoding: "utf8", windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024, env: process.env });
|
|
293
|
+
if (command.error || command.status !== 0) fail("claude_cli_unavailable", "Claude plugin operation failed; host output is not retained.");
|
|
294
|
+
if (!jsonOutput) return null;
|
|
295
|
+
try { return JSON.parse(command.stdout); } catch { fail("claude_cli_schema", "Claude plugin command did not return valid JSON."); }
|
|
296
|
+
}
|
|
297
|
+
function arrayResult(value, key) {
|
|
298
|
+
if (Array.isArray(value)) return value;
|
|
299
|
+
if (object(value) && Array.isArray(value[key])) return value[key];
|
|
300
|
+
fail("claude_cli_schema", "Claude plugin inventory has an unrecognized JSON shape.");
|
|
301
|
+
}
|
|
302
|
+
function nativeState(helpers, paths) {
|
|
303
|
+
const marketplaces = arrayResult(cli(helpers, ["plugin", "marketplace", "list", "--json"], { jsonOutput: true }), "marketplaces");
|
|
304
|
+
const matches = marketplaces.filter((value) => value?.name === NAMESPACE);
|
|
305
|
+
if (matches.length > 1) fail("unowned_collision", "Claude reports duplicate managed marketplace names.");
|
|
306
|
+
const registration = matches[0] ?? null;
|
|
307
|
+
if (registration) {
|
|
308
|
+
const source = registration.source;
|
|
309
|
+
const location = registration.path ?? registration.installLocation ?? registration.root ?? (object(source) ? source.path : null);
|
|
310
|
+
// Prefer the original local source when the CLI also exposes a cache installLocation.
|
|
311
|
+
const sourcePath = object(source) && typeof source.path === "string" ? source.path : location;
|
|
312
|
+
if ((typeof source === "string" && source !== "directory") || typeof sourcePath !== "string" || !isAbsolute(sourcePath) || resolve(sourcePath) !== paths.root || (registration.scope && registration.scope !== "user")) fail("unowned_collision", "Claude marketplace name is registered to an unmanaged origin or scope.");
|
|
313
|
+
}
|
|
314
|
+
const plugins = arrayResult(cli(helpers, ["plugin", "list", "--json"], { jsonOutput: true }), "plugins");
|
|
315
|
+
const installed = plugins.filter((value) => (value?.id ?? value?.pluginId ?? value?.name) === PLUGIN_ID);
|
|
316
|
+
if (installed.length > 1) fail("unowned_collision", "Claude reports duplicate managed plugin origins.");
|
|
317
|
+
const plugin = installed[0] ?? null;
|
|
318
|
+
if (plugin && (!registration || plugin.scope !== "user")) fail("unowned_collision", "Claude plugin has an unmanaged registration or scope.");
|
|
319
|
+
if (plugin && (typeof plugin.enabled !== "boolean" || typeof plugin.version !== "string")) fail("claude_cli_schema", "Claude plugin enabled state or version is unavailable.");
|
|
320
|
+
return { registration, plugin, enabled: plugin ? plugin.enabled !== false : null, version: plugin?.version ?? null };
|
|
321
|
+
}
|
|
322
|
+
function registerNative(helpers, paths, enabled) {
|
|
323
|
+
cli(helpers, ["plugin", "marketplace", "add", paths.root, "--scope", "user"]);
|
|
324
|
+
cli(helpers, ["plugin", "install", PLUGIN_ID, "--scope", "user", "--json"], { jsonOutput: true });
|
|
325
|
+
if (!enabled) cli(helpers, ["plugin", "disable", PLUGIN_ID, "--scope", "user"]);
|
|
326
|
+
}
|
|
327
|
+
function unregisterNative(helpers, state) {
|
|
328
|
+
if (state.plugin) cli(helpers, ["plugin", "uninstall", PLUGIN_ID, "--scope", "user"]);
|
|
329
|
+
if (state.registration) cli(helpers, ["plugin", "marketplace", "remove", NAMESPACE, "--scope", "user"]);
|
|
330
|
+
}
|
|
331
|
+
async function nativeOwned(paths) {
|
|
332
|
+
if (!(await entry(paths.base))) return null;
|
|
333
|
+
await safeParents(paths.base);
|
|
334
|
+
windows("check", paths.base);
|
|
335
|
+
const info = await entry(paths.root);
|
|
336
|
+
if (!info) return null;
|
|
337
|
+
if (!info.isDirectory() || info.isSymbolicLink()) fail("unsafe_path", "Claude managed root is linked or not a directory.");
|
|
338
|
+
await safeParents(paths.root);
|
|
339
|
+
const owned = validateMarker(await json(join(paths.root, MARKER)), "claude", paths.scope);
|
|
340
|
+
const actual = await files(paths.root); delete actual[MARKER];
|
|
341
|
+
if (!equalInventory(actual, owned.hashes)) fail("owned_files_modified", "Claude managed files have changed, are missing or contain unknown additions.");
|
|
342
|
+
return owned;
|
|
343
|
+
}
|
|
344
|
+
async function nativeInstall(options, helpers, paths, prepared) {
|
|
345
|
+
const owned = await nativeOwned(paths);
|
|
346
|
+
const before = nativeState(helpers, paths);
|
|
347
|
+
if (before.registration && !owned) fail("unowned_collision", "Existing Claude registration has no owned managed root.");
|
|
348
|
+
const enabled = before.plugin ? before.enabled : owned?.enabled ?? true;
|
|
349
|
+
await safeParents(dirname(paths.root), { create: true });
|
|
350
|
+
const scratch = await fs.mkdtemp(join(dirname(paths.root), ".opensocrates-transaction-"));
|
|
351
|
+
const staging = join(scratch, "staging");
|
|
352
|
+
const backup = join(scratch, "backup");
|
|
353
|
+
let oldMoved = false, activated = false, registrationTouched = false;
|
|
354
|
+
try {
|
|
355
|
+
await fs.mkdir(staging, { mode: 0o700 });
|
|
356
|
+
await fs.cp(prepared.root, join(staging, "plugin"), { recursive: true, preserveTimestamps: true, dereference: false });
|
|
357
|
+
await fs.mkdir(join(staging, ".claude-plugin"), { mode: 0o700 });
|
|
358
|
+
await writeJson(join(staging, ".claude-plugin/marketplace.json"), { name: NAMESPACE, owner: { name: "OpenSocrates" }, plugins: [{ name: "opensocrates", source: "./plugin", version: helpers.version }] });
|
|
359
|
+
const hashes = await files(staging);
|
|
360
|
+
await writeJson(join(staging, MARKER), marker("claude", paths.scope, helpers.version, hashes, enabled));
|
|
361
|
+
// Repeat the ownership check immediately before unregistering/moving anything.
|
|
362
|
+
if (owned) await nativeOwned(paths);
|
|
363
|
+
else if (await entry(paths.root)) fail("unowned_collision", "A managed target appeared during staging.");
|
|
364
|
+
registrationTouched = true;
|
|
365
|
+
unregisterNative(helpers, before);
|
|
366
|
+
if (owned) { await fs.rename(paths.root, backup); oldMoved = true; }
|
|
367
|
+
await fs.rename(staging, paths.root); activated = true;
|
|
368
|
+
registerNative(helpers, paths, enabled);
|
|
369
|
+
const after = nativeState(helpers, paths);
|
|
370
|
+
if (!after.plugin || after.version !== helpers.version || after.enabled !== enabled) fail("claude_registration_unconfirmed", "Claude did not confirm the exact companion version and enabled state.");
|
|
371
|
+
await nativeOwned(paths);
|
|
372
|
+
if (oldMoved) { await nativeOwned({ ...paths, root: backup }); await fs.rm(backup, { recursive: true }); oldMoved = false; }
|
|
373
|
+
return result(options, { installation: "installed", version: helpers.version, enabled, scope: paths.scope, registration: "confirmed" });
|
|
374
|
+
} catch (error) {
|
|
375
|
+
let rollbackFailed = false;
|
|
376
|
+
if (registrationTouched) try { unregisterNative(helpers, nativeState(helpers, paths)); } catch { rollbackFailed = true; }
|
|
377
|
+
try {
|
|
378
|
+
if (activated) { await nativeOwned(paths); await fs.rm(paths.root, { recursive: true }); }
|
|
379
|
+
if (oldMoved) { await fs.rename(backup, paths.root); oldMoved = false; }
|
|
380
|
+
if (registrationTouched && before.registration) {
|
|
381
|
+
cli(helpers, ["plugin", "marketplace", "add", paths.root, "--scope", "user"]);
|
|
382
|
+
if (before.plugin) {
|
|
383
|
+
cli(helpers, ["plugin", "install", PLUGIN_ID, "--scope", "user", "--json"], { jsonOutput: true });
|
|
384
|
+
if (!before.enabled) cli(helpers, ["plugin", "disable", PLUGIN_ID, "--scope", "user"]);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
} catch { rollbackFailed = true; }
|
|
388
|
+
if (rollbackFailed) fail("rollback_incomplete", "Claude rollback was incomplete; owned backups are preserved for recovery.");
|
|
389
|
+
throw error;
|
|
390
|
+
} finally {
|
|
391
|
+
// Never discard the last preserved old tree when rollback was incomplete.
|
|
392
|
+
if (!oldMoved) await fs.rm(scratch, { recursive: true, force: true });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async function nativeAction(options, helpers, paths) {
|
|
396
|
+
if (!(await entry(paths.base))) return result(options, { installation: "missing", version: null, enabled: false, scope: paths.scope, integrity: "not-installed", registration: "absent" });
|
|
397
|
+
const owned = await nativeOwned(paths);
|
|
398
|
+
const before = nativeState(helpers, paths);
|
|
399
|
+
if (before.registration && !owned) fail("unowned_collision", "Registered companion has no exact owned root.");
|
|
400
|
+
if (options.action === "status" || options.action === "diagnose") return result(options, { installation: owned ? before.plugin ? "installed" : "files-only" : "missing", version: owned?.version ?? null, enabled: before.enabled, scope: paths.scope, integrity: owned ? "verified" : "not-installed", registration: before.registration ? "confirmed" : "absent" });
|
|
401
|
+
if (!owned) return result(options, { installation: "missing", version: null, enabled: false, scope: paths.scope });
|
|
402
|
+
if (options.action === "enable" || options.action === "disable") {
|
|
403
|
+
if (!before.plugin) fail("claude_registration_unconfirmed", "Enable or disable requires the exact installed companion registration.");
|
|
404
|
+
const enabled = options.action === "enable";
|
|
405
|
+
try {
|
|
406
|
+
cli(helpers, ["plugin", options.action, PLUGIN_ID, "--scope", "user"]);
|
|
407
|
+
const after = nativeState(helpers, paths);
|
|
408
|
+
if (!after.plugin || after.enabled !== enabled) fail("claude_registration_unconfirmed", "Claude did not confirm the enabled state.");
|
|
409
|
+
await nativeOwned(paths);
|
|
410
|
+
await atomicMarker(join(paths.root, MARKER), { ...owned, enabled });
|
|
411
|
+
} catch (error) {
|
|
412
|
+
try { cli(helpers, ["plugin", before.enabled ? "enable" : "disable", PLUGIN_ID, "--scope", "user"]); } catch { fail("rollback_incomplete", "Claude enabled-state rollback was incomplete."); }
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
return result(options, { installation: "installed", version: owned.version, enabled, scope: paths.scope, registration: "confirmed" });
|
|
416
|
+
}
|
|
417
|
+
const backup = join(dirname(paths.root), `.opensocrates-removal-${randomUUID()}`);
|
|
418
|
+
let moved = false;
|
|
419
|
+
try {
|
|
420
|
+
unregisterNative(helpers, before);
|
|
421
|
+
await nativeOwned(paths);
|
|
422
|
+
await fs.rename(paths.root, backup); moved = true;
|
|
423
|
+
const backupFiles = await files(backup); delete backupFiles[MARKER];
|
|
424
|
+
if (!equalInventory(backupFiles, owned.hashes)) fail("owned_files_modified", "Managed files changed during removal.");
|
|
425
|
+
await fs.rm(backup, { recursive: true }); moved = false;
|
|
426
|
+
} catch (error) {
|
|
427
|
+
try {
|
|
428
|
+
if (moved) { await fs.rename(backup, paths.root); moved = false; }
|
|
429
|
+
if (before.registration) {
|
|
430
|
+
cli(helpers, ["plugin", "marketplace", "add", paths.root, "--scope", "user"]);
|
|
431
|
+
if (before.plugin) {
|
|
432
|
+
cli(helpers, ["plugin", "install", PLUGIN_ID, "--scope", "user", "--json"], { jsonOutput: true });
|
|
433
|
+
if (!before.enabled) cli(helpers, ["plugin", "disable", PLUGIN_ID, "--scope", "user"]);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
} catch { fail("rollback_incomplete", "Claude removal rollback was incomplete; owned backup remains."); }
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
return result(options, { installation: "missing", version: null, enabled: false, scope: paths.scope, registration: "absent" });
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function antiPhysical(paths, logical, enabled) {
|
|
443
|
+
if (logical === "release-manifest.json") return join(paths.root, "release-manifest.json");
|
|
444
|
+
if (logical === ".agents/rules/opensocrates.md") return enabled ? paths.rule : join(paths.root, "disabled/rule.md");
|
|
445
|
+
const prefix = ".agents/skills/opensocrates/";
|
|
446
|
+
if (!logical.startsWith(prefix)) fail("invalid_inventory", "Unexpected modular content path.");
|
|
447
|
+
return join(enabled ? paths.skill : join(paths.root, "disabled/skill"), logical.slice(prefix.length));
|
|
448
|
+
}
|
|
449
|
+
async function antiOwned(paths) {
|
|
450
|
+
if (paths.workspace) await safeParents(paths.workspace);
|
|
451
|
+
const baseInfo = await entry(paths.base);
|
|
452
|
+
if (!baseInfo) return null;
|
|
453
|
+
await safeParents(paths.base);
|
|
454
|
+
windows("check", paths.base);
|
|
455
|
+
// A legacy plugin is a separate owned scope; never overwrite or duplicate it silently.
|
|
456
|
+
const legacy = join(paths.base, "plugins/opensocrates");
|
|
457
|
+
if (await entry(legacy)) fail("unowned_collision", "A legacy Antigravity OpenSocrates plugin must be reconciled separately.");
|
|
458
|
+
if (!(await entry(paths.root))) {
|
|
459
|
+
if (await entry(paths.rule) || await entry(paths.skill)) fail("unowned_collision", "Antigravity entry/skill already exists without this exact ownership marker.");
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
await safeParents(paths.root);
|
|
463
|
+
windows("check-tree", paths.root);
|
|
464
|
+
const owned = validateMarker(await json(join(paths.root, MARKER)), "antigravity", paths.scope);
|
|
465
|
+
if (owned.enabled) windows("check", paths.rule);
|
|
466
|
+
const actual = {};
|
|
467
|
+
for (const name of Object.keys(owned.hashes)) {
|
|
468
|
+
const path = antiPhysical(paths, name, owned.enabled);
|
|
469
|
+
await safeParents(dirname(path));
|
|
470
|
+
await regular(path);
|
|
471
|
+
actual[name] = hash(await fs.readFile(path));
|
|
472
|
+
}
|
|
473
|
+
if (!equalInventory(actual, owned.hashes)) fail("owned_files_modified", "Antigravity managed content was modified or is incomplete.");
|
|
474
|
+
const skill = owned.enabled ? paths.skill : join(paths.root, "disabled/skill");
|
|
475
|
+
const expectedSkill = {};
|
|
476
|
+
for (const [name, value] of Object.entries(owned.hashes)) if (name.startsWith(".agents/skills/opensocrates/")) expectedSkill[name.slice(".agents/skills/opensocrates/".length)] = value;
|
|
477
|
+
if (!equalInventory(await files(skill), expectedSkill)) fail("owned_files_modified", "Antigravity skill contains unknown or modified files.");
|
|
478
|
+
const store = await files(paths.root); delete store[MARKER];
|
|
479
|
+
const expectedStore = { "release-manifest.json": owned.hashes["release-manifest.json"] };
|
|
480
|
+
if (!owned.enabled) {
|
|
481
|
+
expectedStore["disabled/rule.md"] = owned.hashes[".agents/rules/opensocrates.md"];
|
|
482
|
+
for (const [name, value] of Object.entries(expectedSkill)) expectedStore[`disabled/skill/${name}`] = value;
|
|
483
|
+
if (await entry(paths.rule) || await entry(paths.skill)) fail("unowned_collision", "Disabled Antigravity targets were replaced by unrelated files.");
|
|
484
|
+
}
|
|
485
|
+
if (!equalInventory(store, expectedStore)) fail("owned_files_modified", "Antigravity metadata contains unknown or modified files.");
|
|
486
|
+
return owned;
|
|
487
|
+
}
|
|
488
|
+
async function antiParents(paths) {
|
|
489
|
+
const parents = [...new Set([dirname(paths.rule), dirname(paths.skill), dirname(paths.root)])];
|
|
490
|
+
for (const parent of parents) { await safeParents(parent, { create: true }); windows("check", parent); }
|
|
491
|
+
return parents;
|
|
492
|
+
}
|
|
493
|
+
async function antiStage(paths, source, enabled, version, hashes) {
|
|
494
|
+
await safeParents(paths.base, { create: true });
|
|
495
|
+
await antiParents(paths);
|
|
496
|
+
const scratch = await privateScratch(paths.base, ".opensocrates-transaction-");
|
|
497
|
+
try {
|
|
498
|
+
const store = join(scratch, "store");
|
|
499
|
+
await fs.mkdir(store, { mode: 0o700 });
|
|
500
|
+
await fs.copyFile(join(source, "release-manifest.json"), join(store, "release-manifest.json"));
|
|
501
|
+
const rule = join(scratch, "rule.md"), skill = join(scratch, "skill");
|
|
502
|
+
await fs.copyFile(join(source, ".agents/rules/opensocrates.md"), rule);
|
|
503
|
+
await fs.cp(join(source, ".agents/skills/opensocrates"), skill, { recursive: true, dereference: false });
|
|
504
|
+
if (!enabled) {
|
|
505
|
+
await fs.mkdir(join(store, "disabled"), { mode: 0o700 });
|
|
506
|
+
await fs.rename(rule, join(store, "disabled/rule.md"));
|
|
507
|
+
await fs.rename(skill, join(store, "disabled/skill"));
|
|
508
|
+
}
|
|
509
|
+
await writeJson(join(store, MARKER), marker("antigravity", paths.scope, version, hashes, enabled));
|
|
510
|
+
windows("seal-new", scratch);
|
|
511
|
+
return { scratch, store, rule: enabled ? rule : null, skill: enabled ? skill : null };
|
|
512
|
+
} catch (error) { await fs.rm(scratch, { recursive: true, force: true }); throw error; }
|
|
513
|
+
}
|
|
514
|
+
async function antiSwap(paths, stage, previous) {
|
|
515
|
+
// Pin the actual mutation parents as well as the operation root. A root
|
|
516
|
+
// handle alone does not prevent a rules/skills directory being replaced.
|
|
517
|
+
let entered = false;
|
|
518
|
+
try {
|
|
519
|
+
const parents = await antiParents(paths);
|
|
520
|
+
const held = (index) => index === parents.length
|
|
521
|
+
? (entered = true, antiSwapHeld(paths, stage, previous))
|
|
522
|
+
: withWindowsLease(parents[index], () => held(index + 1));
|
|
523
|
+
return await held(0);
|
|
524
|
+
} catch (error) {
|
|
525
|
+
if (!entered) throw new ManagedHostError(error.code ?? "transaction_unavailable", `Transaction parent protection failed; preserve the prepared recovery directory: ${stage.scratch}`);
|
|
526
|
+
throw error;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
async function antiSwapHeld(paths, stage, previous) {
|
|
530
|
+
const targets = [
|
|
531
|
+
{ target: paths.rule, staged: stage.rule },
|
|
532
|
+
{ target: paths.skill, staged: stage.skill },
|
|
533
|
+
{ target: paths.root, staged: stage.store },
|
|
534
|
+
];
|
|
535
|
+
const changes = [];
|
|
536
|
+
let rollbackComplete = true;
|
|
537
|
+
try {
|
|
538
|
+
const current = await antiOwned(paths);
|
|
539
|
+
if (JSON.stringify(current) !== JSON.stringify(previous)) fail("owned_files_modified", "Managed state changed during staging.");
|
|
540
|
+
for (let index = 0; index < targets.length; index++) {
|
|
541
|
+
const item = targets[index];
|
|
542
|
+
await safeParents(dirname(item.target), { create: true });
|
|
543
|
+
const priorInfo = await entry(item.target);
|
|
544
|
+
if (priorInfo && (priorInfo.isSymbolicLink() || (!priorInfo.isFile() && !priorInfo.isDirectory()))) fail("unsafe_path", "A transaction target changed to an unsafe filesystem entry.");
|
|
545
|
+
const beforeHashes = priorInfo ? priorInfo.isDirectory() && !priorInfo.isSymbolicLink() ? await files(item.target) : { ".": hash(await fs.readFile(item.target)) } : null;
|
|
546
|
+
const placedHashes = item.staged ? (await fs.lstat(item.staged)).isDirectory() ? await files(item.staged) : { ".": hash(await fs.readFile(item.staged)) } : null;
|
|
547
|
+
const change = { ...item, beforeHashes, placedHashes, backup: join(stage.scratch, `backup-${index}`), backedUp: false, placed: false };
|
|
548
|
+
changes.push(change);
|
|
549
|
+
if (await entry(item.target)) { await fs.rename(item.target, change.backup); change.backedUp = true; }
|
|
550
|
+
if (item.staged) { await fs.rename(item.staged, item.target); change.placed = true; }
|
|
551
|
+
}
|
|
552
|
+
if (stage.store) await antiOwned(paths);
|
|
553
|
+
else if (await entry(paths.root) || await entry(paths.rule) || await entry(paths.skill)) fail("removal_unconfirmed", "Modular removal did not clear the exact owned targets.");
|
|
554
|
+
for (const change of changes) if (change.backedUp) {
|
|
555
|
+
const info = await fs.lstat(change.backup);
|
|
556
|
+
const afterHashes = info.isDirectory() && !info.isSymbolicLink() ? await files(change.backup) : { ".": hash(await fs.readFile(change.backup)) };
|
|
557
|
+
if (!equalInventory(afterHashes, change.beforeHashes)) fail("owned_files_modified", "Owned content changed during its transaction; preserving the preimage.");
|
|
558
|
+
}
|
|
559
|
+
} catch (error) {
|
|
560
|
+
for (const change of [...changes].reverse()) {
|
|
561
|
+
try {
|
|
562
|
+
if (change.placed) {
|
|
563
|
+
await safeParents(dirname(change.target));
|
|
564
|
+
const info = await fs.lstat(change.target);
|
|
565
|
+
if (info.isSymbolicLink()) fail("unsafe_path", "Rollback target became linked.");
|
|
566
|
+
const actual = info.isDirectory() ? await files(change.target) : { ".": hash(await fs.readFile(change.target)) };
|
|
567
|
+
if (!equalInventory(actual, change.placedHashes)) fail("owned_files_modified", "Rollback target changed; preserve it and the backup.");
|
|
568
|
+
windows("check", change.target);
|
|
569
|
+
await fs.rm(change.target, { recursive: true });
|
|
570
|
+
}
|
|
571
|
+
if (change.backedUp) await fs.rename(change.backup, change.target);
|
|
572
|
+
} catch { rollbackComplete = false; }
|
|
573
|
+
}
|
|
574
|
+
if (!rollbackComplete) fail("rollback_incomplete", `Antigravity rollback was incomplete; preserve the recovery directory: ${stage.scratch}`);
|
|
575
|
+
throw error;
|
|
576
|
+
} finally {
|
|
577
|
+
if (rollbackComplete) await fs.rm(stage.scratch, { recursive: true, force: true });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
async function antiAction(options, helpers, paths, prepared = null) {
|
|
581
|
+
const owned = await antiOwned(paths);
|
|
582
|
+
if (options.action === "status" || options.action === "diagnose") return result(options, { installation: owned ? "managed-files" : "missing", version: owned?.version ?? null, enabled: owned?.enabled ?? false, scope: paths.scope, integrity: owned ? "verified" : "not-installed", registration: "modular-files" });
|
|
583
|
+
if (options.action === "remove") {
|
|
584
|
+
if (!owned) return result(options, { installation: "missing", version: null, enabled: false, scope: paths.scope });
|
|
585
|
+
const scratch = await privateScratch(paths.base, ".opensocrates-removal-");
|
|
586
|
+
await antiSwap(paths, { scratch, store: null, rule: null, skill: null }, owned);
|
|
587
|
+
return result(options, { installation: "missing", version: null, enabled: false, scope: paths.scope });
|
|
588
|
+
}
|
|
589
|
+
if (options.action === "install" || options.action === "update") {
|
|
590
|
+
const enabled = owned?.enabled ?? true;
|
|
591
|
+
const stage = await antiStage(paths, prepared.root, enabled, helpers.version, prepared.hashes);
|
|
592
|
+
await antiSwap(paths, stage, owned);
|
|
593
|
+
return result(options, { installation: "managed-files", version: helpers.version, enabled, scope: paths.scope, registration: "modular-files" });
|
|
594
|
+
}
|
|
595
|
+
if (!owned) fail("not_installed", "Enable or disable requires an owned Antigravity installation.");
|
|
596
|
+
const enabled = options.action === "enable";
|
|
597
|
+
if (owned.enabled === enabled) return result(options, { installation: "managed-files", version: owned.version, enabled, scope: paths.scope });
|
|
598
|
+
const scratch = await privateScratch(paths.base, ".opensocrates-source-");
|
|
599
|
+
try {
|
|
600
|
+
for (const name of Object.keys(owned.hashes)) {
|
|
601
|
+
const target = join(scratch, name);
|
|
602
|
+
await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
603
|
+
await fs.copyFile(antiPhysical(paths, name, owned.enabled), target);
|
|
604
|
+
}
|
|
605
|
+
const stage = await antiStage(paths, scratch, enabled, owned.version, owned.hashes);
|
|
606
|
+
await antiSwap(paths, stage, owned);
|
|
607
|
+
} finally { await fs.rm(scratch, { recursive: true, force: true }); }
|
|
608
|
+
return result(options, { installation: "managed-files", version: owned.version, enabled, scope: paths.scope, registration: "modular-files" });
|
|
609
|
+
}
|
|
610
|
+
async function exportChat(options, prepared) {
|
|
611
|
+
if (typeof options.output !== "string" || !isAbsolute(options.output) || !options.output.endsWith(".zip")) fail("invalid_output", "Export requires an explicit absolute ZIP output path.");
|
|
612
|
+
const target = resolve(options.output);
|
|
613
|
+
await safeParents(dirname(target));
|
|
614
|
+
windows("check", dirname(target));
|
|
615
|
+
return withWindowsLease(dirname(target), () => exportChatHeld(options, prepared, target));
|
|
616
|
+
}
|
|
617
|
+
async function exportChatHeld(options, prepared, target) {
|
|
618
|
+
const sourceInfo = await regular(prepared.asset);
|
|
619
|
+
const sourceHash = hash(await fs.readFile(prepared.asset));
|
|
620
|
+
const existing = await entry(target);
|
|
621
|
+
if (existing) {
|
|
622
|
+
if (!existing.isFile() || existing.isSymbolicLink() || hash(await fs.readFile(target)) !== sourceHash) fail("export_conflict", "Export output already exists with different bytes or an unsafe type.");
|
|
623
|
+
} else {
|
|
624
|
+
await fs.copyFile(prepared.asset, target, constants.COPYFILE_EXCL);
|
|
625
|
+
windows("seal-new", target);
|
|
626
|
+
await fs.chmod(target, sourceInfo.mode & 0o777 & ~0o111);
|
|
627
|
+
if (hash(await fs.readFile(target)) !== sourceHash) fail("export_mismatch", "Export changed during verification; preserve the output for inspection.");
|
|
628
|
+
}
|
|
629
|
+
return result(options, { installation: "export-only", version: prepared.version, output: target, sha256: sourceHash, account_activation: "unverified" });
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Return a JSON-safe record. Caller owns terminal presentation and its Codex engine. */
|
|
633
|
+
export async function runManagedHost(options, helpers) {
|
|
634
|
+
if (!object(options) || !HOSTS.has(options.host) || !ACTIONS.has(options.action) || !object(helpers) || !/^\d+\.\d+\.\d+$/u.test(helpers.version ?? "")) fail("invalid_options", "Invalid managed-host action or version.");
|
|
635
|
+
if (options.purge || options.resetTrust) fail("unsupported_destructive_action", "Managed addons do not support purge or trust resets; use exact owned remove.");
|
|
636
|
+
if (options.action === "export" && options.host !== "claude-chat") fail("invalid_action", "Standalone export is only available for claude-chat.");
|
|
637
|
+
if (options.host === "claude-chat" && !["verify", "export"].includes(options.action)) fail("account_action_unavailable", "Claude account installation and activation require the account UI; export/verify are local only.");
|
|
638
|
+
const offline = options.action === "verify" || options.action === "export";
|
|
639
|
+
const platform = helpers.platform ?? process.platform, arch = helpers.arch ?? process.arch;
|
|
640
|
+
if (!offline && !((platform === "darwin" && arch === "arm64") || (platform === "win32" && arch === "x64" && options.host === "antigravity"))) fail("unsupported_platform", "Addon lifecycle supports Apple-silicon Mac and Windows x64 Antigravity. Windows Claude account delivery uses verify/export; native Code is outside this scope.");
|
|
641
|
+
const needsPackage = ["install", "update", "verify", "export"].includes(options.action);
|
|
642
|
+
let prepared;
|
|
643
|
+
try {
|
|
644
|
+
if (needsPackage) {
|
|
645
|
+
if (typeof helpers.preparePackage !== "function") fail("package_unavailable", "A verified package helper is required.");
|
|
646
|
+
prepared = await helpers.preparePackage(options.host, options);
|
|
647
|
+
if (!object(prepared) || typeof prepared.root !== "string" || typeof prepared.cleanup !== "function") fail("package_unavailable", "Package helper did not return a scoped prepared package.");
|
|
648
|
+
Object.assign(prepared, await verifyPackage(prepared.root, options.host, helpers.version));
|
|
649
|
+
}
|
|
650
|
+
if (options.action === "verify") return result(options, { installation: "verified-package", version: helpers.version, files: Object.keys(prepared.hashes).length });
|
|
651
|
+
if (options.action === "export") return await exportChat(options, prepared);
|
|
652
|
+
const paths = basePaths(options);
|
|
653
|
+
if (paths.workspace) await safeParents(paths.workspace);
|
|
654
|
+
const inspect = options.action === "status" || options.action === "diagnose";
|
|
655
|
+
const action = async () => options.host === "claude"
|
|
656
|
+
? needsPackage ? nativeInstall(options, helpers, paths, prepared) : nativeAction(options, helpers, paths)
|
|
657
|
+
: antiAction(options, helpers, paths, prepared);
|
|
658
|
+
if (inspect) {
|
|
659
|
+
try { return await action(); }
|
|
660
|
+
catch (error) {
|
|
661
|
+
if (!(error instanceof ManagedHostError)) throw error;
|
|
662
|
+
return result(options, { installation: "unavailable", integrity: "unverified", reason: error.code, scope: paths.scope, version: null });
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (!needsPackage && !(await entry(paths.base))) return result(options, { installation: "missing", version: null, enabled: false, scope: paths.scope, integrity: "not-installed" });
|
|
666
|
+
return await withLock(paths.base, options.host, action);
|
|
667
|
+
} finally { if (typeof prepared?.cleanup === "function") await prepared.cleanup(); }
|
|
668
|
+
}
|