@eyeauras/insight-cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cli-package-build.json +1 -1
- package/README.md +8 -2
- package/SKILL.md +4 -1
- package/dist/src/administration-results.js +1 -1
- package/dist/src/administration.d.ts +1 -1
- package/dist/src/administration.js +16 -2
- package/dist/src/cli.js +37 -5
- package/dist/src/contexts.js +12 -2
- package/dist/src/lifecycle.d.ts +1 -1
- package/dist/src/lifecycle.js +8 -2
- package/dist/src/native-inputs.js +12 -1
- package/dist/src/runtime-archives.d.ts +5 -0
- package/dist/src/runtime-archives.js +127 -0
- package/dist/src/runtime-catalog.d.ts +18 -0
- package/dist/src/runtime-catalog.js +27 -0
- package/dist/src/runtime-manager.d.ts +101 -0
- package/dist/src/runtime-manager.js +444 -0
- package/dist/src/runtime-selection.d.ts +13 -0
- package/dist/src/runtime-selection.js +25 -0
- package/dist/src/server.d.ts +2 -0
- package/dist/src/server.js +59 -54
- package/package.json +4 -2
- package/references/administration.md +2 -1
- package/references/managed-runtime.md +116 -0
- package/references/native/InsightRuntimeDoctor.java +33 -0
- package/references/setup.md +4 -2
- package/skills/insight-re/SKILL.md +1 -1
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { command, Permission, CliError } from '@eyeauras/cli-factory';
|
|
2
|
+
import { readFile, mkdir, rename, rm, open, realpath, readdir, stat, cp } from 'node:fs/promises';
|
|
3
|
+
import { existsSync, constants } from 'node:fs';
|
|
4
|
+
import { join, dirname, resolve, delimiter, basename } from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { runtimeRoot, runtimeSelection, runtimeValues } from './runtime-selection.js';
|
|
9
|
+
import { runtimeCatalog } from './runtime-catalog.js';
|
|
10
|
+
import { cacheArtifact, extractArchive, fileSha256 } from './runtime-archives.js';
|
|
11
|
+
import { nativeInputs, nativeHelpers, nativeJvmDirectories, pinnedMcpJar } from './native-inputs.js';
|
|
12
|
+
import { GhidraServer, stopLocalServer } from './server.js';
|
|
13
|
+
import { fail, text, digest, object } from './model.js';
|
|
14
|
+
const exec = promisify(execFile), suffix = process.platform === 'win32' ? '.exe' : '';
|
|
15
|
+
export async function runtimeWrite(path, value) {
|
|
16
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
17
|
+
const temporary = path + '.new-' + randomUUID(), handle = await open(temporary, 'wx', 0o600);
|
|
18
|
+
try {
|
|
19
|
+
await handle.writeFile(JSON.stringify(value, null, 2) + '\n');
|
|
20
|
+
await handle.sync();
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
await handle.close();
|
|
24
|
+
}
|
|
25
|
+
await rename(temporary, path);
|
|
26
|
+
if (process.platform !== 'win32') {
|
|
27
|
+
const parent = await open(dirname(path), 'r');
|
|
28
|
+
try {
|
|
29
|
+
await parent.sync();
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await parent.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const live = (pid) => { if (!Number.isSafeInteger(pid) || Number(pid) < 1)
|
|
37
|
+
return false; try {
|
|
38
|
+
process.kill(Number(pid), 0);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error.code === 'ESRCH')
|
|
43
|
+
return false;
|
|
44
|
+
throw error;
|
|
45
|
+
} };
|
|
46
|
+
/** Passive, conservative inventory: a reused PID blocks deletion; it never grants kill authority. */
|
|
47
|
+
export async function runtimeProcesses(directory) {
|
|
48
|
+
const items = [];
|
|
49
|
+
async function walk(path, depth = 0) {
|
|
50
|
+
if (depth > 6)
|
|
51
|
+
return;
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = await readdir(path, { withFileTypes: true });
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error.code === 'ENOENT')
|
|
58
|
+
return;
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const at = join(path, entry.name);
|
|
63
|
+
if (entry.isDirectory())
|
|
64
|
+
await walk(at, depth + 1);
|
|
65
|
+
else if (entry.name === 'process.json') {
|
|
66
|
+
const p = object(JSON.parse(await readFile(at, 'utf8')));
|
|
67
|
+
let stopped = false;
|
|
68
|
+
try {
|
|
69
|
+
const s = object(JSON.parse(await readFile(join(path, 'stopped.json'), 'utf8')));
|
|
70
|
+
stopped = s.pid === p.pid && s.tokenSecret === p.tokenSecret;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error.code !== 'ENOENT')
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
if (!stopped && live(p.pid))
|
|
77
|
+
items.push({ kind: p.kind, pid: p.pid, sessionContext: p.context, target: p.target, mode: p.mode });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
await walk(join(directory, 'workspace', 'ghidra-native'));
|
|
82
|
+
try {
|
|
83
|
+
const s = object(JSON.parse(await readFile(join(directory, 'workspace/server/local.json'), 'utf8')));
|
|
84
|
+
if (live(s.pid))
|
|
85
|
+
items.push({ kind: 'server', pid: s.pid });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error.code !== 'ENOENT')
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
return items;
|
|
92
|
+
}
|
|
93
|
+
export async function externalGhidra(path) {
|
|
94
|
+
const resolved = await realpath(path), kind = await stat(resolved);
|
|
95
|
+
const parent = kind.isDirectory() ? resolved : dirname(dirname(resolved));
|
|
96
|
+
const candidates = kind.isDirectory() ? [resolved, join(resolved, 'libexec')] : [dirname(resolved), parent, join(parent, 'libexec')];
|
|
97
|
+
for (const home of candidates)
|
|
98
|
+
if (existsSync(join(home, 'Ghidra/application.properties'))) {
|
|
99
|
+
if (!kind.isDirectory() && !['ghidraRun', 'ghidraRun.bat', 'analyzeHeadless', 'analyzeHeadless.bat'].includes(basename(resolved)))
|
|
100
|
+
fail('configuration', 'Use the Ghidra launcher or installation directory, not a target EXE.');
|
|
101
|
+
return home;
|
|
102
|
+
}
|
|
103
|
+
fail('configuration', 'The external path does not identify a complete Ghidra installation. Use its ghidraRun launcher or installation directory.');
|
|
104
|
+
}
|
|
105
|
+
async function findRoot(root, marker, depth = 0) {
|
|
106
|
+
if (existsSync(join(root, marker)))
|
|
107
|
+
return root;
|
|
108
|
+
if (depth >= 4)
|
|
109
|
+
return;
|
|
110
|
+
for (const entry of await readdir(root, { withFileTypes: true }))
|
|
111
|
+
if (entry.isDirectory()) {
|
|
112
|
+
const found = await findRoot(join(root, entry.name), marker, depth + 1);
|
|
113
|
+
if (found)
|
|
114
|
+
return found;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export class RuntimeManager {
|
|
118
|
+
c;
|
|
119
|
+
directory;
|
|
120
|
+
root;
|
|
121
|
+
constructor(c) {
|
|
122
|
+
this.c = c;
|
|
123
|
+
this.directory = c.appArguments.AppDataDirectory;
|
|
124
|
+
this.root = runtimeRoot(this.directory);
|
|
125
|
+
}
|
|
126
|
+
async status() {
|
|
127
|
+
const selected = runtimeSelection(this.directory), values = selected ? runtimeValuesForStatus(selected, this.c.profile.values) : this.c.profile.values;
|
|
128
|
+
const java = typeof values.java === 'string' ? values.java : undefined, home = typeof values.ghidraHome === 'string' ? values.ghidraHome : undefined;
|
|
129
|
+
const missing = [];
|
|
130
|
+
if (!java || !existsSync(java))
|
|
131
|
+
missing.push('jdk');
|
|
132
|
+
if (!home || !existsSync(join(home, 'Ghidra/application.properties')))
|
|
133
|
+
missing.push('ghidra');
|
|
134
|
+
if (selected?.uninstalledAt)
|
|
135
|
+
missing.push('uninstalled');
|
|
136
|
+
if (selected?.mode === 'managed' && !existsSync(join(this.root, 'installations', selected.id, 'ready.json')))
|
|
137
|
+
missing.push('installation_receipt');
|
|
138
|
+
let version;
|
|
139
|
+
if (home)
|
|
140
|
+
try {
|
|
141
|
+
version = /^application.version=(.+)$/m.exec(await readFile(join(home, 'Ghidra/application.properties'), 'utf8'))?.[1]?.trim();
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (error.code !== 'ENOENT')
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
const host = process.platform === 'darwin' ? (process.arch === 'arm64' ? 'mac_arm_64' : 'mac_x86_64') : process.platform === 'win32' ? 'win_x86_64' : 'linux_x86_64';
|
|
148
|
+
const native = home ? join(home, 'Ghidra/Features/Decompiler/os', host, 'decompile' + suffix) : undefined;
|
|
149
|
+
if (home && (!native || !existsSync(native)))
|
|
150
|
+
missing.push('decompiler');
|
|
151
|
+
if (version && version !== '12.1.2')
|
|
152
|
+
missing.push('compatible_ghidra');
|
|
153
|
+
let lastCheck;
|
|
154
|
+
try {
|
|
155
|
+
lastCheck = JSON.parse(await readFile(join(this.root, 'last-check.json'), 'utf8'));
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error.code !== 'ENOENT')
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
const installations = [];
|
|
162
|
+
let stages = [];
|
|
163
|
+
try {
|
|
164
|
+
for (const name of (await readdir(join(this.root, 'installations'))).slice(0, 100)) {
|
|
165
|
+
if (!/^[a-f0-9]{64}$/.test(name))
|
|
166
|
+
continue;
|
|
167
|
+
const path = join(this.root, 'installations', name);
|
|
168
|
+
if (existsSync(join(path, 'ready.json'))) {
|
|
169
|
+
const ready = object(JSON.parse(await readFile(join(path, 'ready.json'), 'utf8')));
|
|
170
|
+
installations.push({ id: name, selected: name === selected?.id, ghidraVersion: ready.ghidraVersion, verifiedAt: ready.verifiedAt });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (error.code !== 'ENOENT')
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
stages = (await readdir(join(this.root, 'stages'))).slice(0, 100);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (error.code !== 'ENOENT')
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
const processes = await runtimeProcesses(this.directory), server = processes.find(p => p.kind === 'server');
|
|
186
|
+
return { state: missing.length ? 'not_ready' : 'installed', mode: selected?.mode ?? (home || java ? 'external' : 'unconfigured'), recommended: 'managed', ghidraVersion: version, java, ghidraHome: home,
|
|
187
|
+
...(selected ? { installation: selected.id, fingerprint: selected.fingerprint } : {}), missing, capabilities: { headless: !missing.length, decompiler: !missing.length, gui: !missing.length,
|
|
188
|
+
debugger: process.platform === 'darwin' ? { backend: 'system-lldb', requires: 'Apple developer tools and OS debugger authorization', checkedBy: 'ghidra doctor' } : { backend: 'host-debugger', checkedBy: 'ghidra doctor' } },
|
|
189
|
+
server: { mode: this.c.profile.values.server ? 'external' : 'local', state: this.c.profile.values.server ? 'configured' : server ? 'running' : 'stopped', ...(server ? { pid: server.pid } : {}) }, processes, installations, retainedStages: stages, lastCheck: lastCheck ? { ...lastCheck, appliesToSelection: lastCheck.fingerprint === selected?.fingerprint && !selected?.uninstalledAt } : undefined,
|
|
190
|
+
storage: { runtime: this.root, research: join(this.directory, 'workspace'), materials: join(this.directory, 'materials') } };
|
|
191
|
+
}
|
|
192
|
+
async download(offline = false) {
|
|
193
|
+
const catalog = runtimeCatalog(), cache = join(this.root, 'cache'), items = [];
|
|
194
|
+
for (const artifact of [catalog.ghidra, catalog.java]) {
|
|
195
|
+
const path = await cacheArtifact(artifact, cache, this.c.fetch, this.c.signal, offline);
|
|
196
|
+
items.push({ name: artifact.name, path, sha256: artifact.sha256, license: artifact.license });
|
|
197
|
+
}
|
|
198
|
+
return { state: 'downloaded', ghidraVersion: catalog.ghidraVersion, javaVersion: catalog.javaVersion, items };
|
|
199
|
+
}
|
|
200
|
+
async archive(artifact, provided, checksum, offline) {
|
|
201
|
+
if (provided === undefined)
|
|
202
|
+
return { path: await cacheArtifact(artifact, join(this.root, 'cache'), this.c.fetch, this.c.signal, offline), artifact };
|
|
203
|
+
const path = await realpath(resolve(this.c.cwd, text(provided, 4096))), cache = join(this.root, 'cache');
|
|
204
|
+
await mkdir(cache, { recursive: true, mode: 0o700 });
|
|
205
|
+
const temporary = join(cache, 'local.partial-' + randomUUID());
|
|
206
|
+
const source = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
207
|
+
try {
|
|
208
|
+
const metadata = await source.stat();
|
|
209
|
+
if (!metadata.isFile() || metadata.size > 2 * 1024 ** 3)
|
|
210
|
+
fail('invalid', 'Local runtime archive must be a regular file no larger than two GiB.');
|
|
211
|
+
const target = await open(temporary, 'wx', 0o600);
|
|
212
|
+
let size = 0;
|
|
213
|
+
try {
|
|
214
|
+
for await (const chunk of source.createReadStream({ autoClose: false })) {
|
|
215
|
+
if ((size += chunk.length) > 2 * 1024 ** 3)
|
|
216
|
+
fail('limit', 'Local runtime archive grew beyond two GiB.');
|
|
217
|
+
await target.writeFile(chunk);
|
|
218
|
+
}
|
|
219
|
+
await target.sync();
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
await target.close();
|
|
223
|
+
}
|
|
224
|
+
const hash = await fileSha256(temporary);
|
|
225
|
+
if (checksum !== undefined && hash !== text(checksum, 64).toLowerCase())
|
|
226
|
+
fail('binding', 'Local runtime archive differs from its expected SHA256.');
|
|
227
|
+
const format = (await readMagic(temporary)) === '504b' ? 'zip' : 'tgz', owned = join(cache, hash + '.' + format);
|
|
228
|
+
if (existsSync(owned)) {
|
|
229
|
+
if (await fileSha256(owned) !== hash)
|
|
230
|
+
fail('binding', 'Existing local-archive cache entry is corrupt; remove it before retrying.');
|
|
231
|
+
}
|
|
232
|
+
else
|
|
233
|
+
await rename(temporary, owned);
|
|
234
|
+
return { path: owned, artifact: { ...artifact, sha256: hash, format } };
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
await source.close();
|
|
238
|
+
await rm(temporary, { force: true });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
async cleanupProbes() {
|
|
242
|
+
// A killed owner may leave a disposable validation server alive. Its authenticated
|
|
243
|
+
// receipt, not PID existence, authorizes recovery; no research repository is touched.
|
|
244
|
+
for (const parent of ['stages', 'checks']) {
|
|
245
|
+
let names;
|
|
246
|
+
try {
|
|
247
|
+
names = await readdir(join(this.root, parent));
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (error.code === 'ENOENT')
|
|
251
|
+
continue;
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
for (const name of names) {
|
|
255
|
+
if (!/^[a-f0-9-]{36}$/.test(name))
|
|
256
|
+
continue;
|
|
257
|
+
const root = join(this.root, parent, name, ...(parent === 'stages' ? ['validation'] : []));
|
|
258
|
+
if (existsSync(join(root, 'server/local.json'))) {
|
|
259
|
+
if (await realpath(root) !== resolve(root))
|
|
260
|
+
fail('binding', 'Retained runtime probe moved outside its owned storage.');
|
|
261
|
+
await stopLocalServer(root, this.c);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async install(o) {
|
|
267
|
+
await this.cleanupProbes();
|
|
268
|
+
if (o.executable && o.archive)
|
|
269
|
+
fail('invalid', 'Choose --executable or --archive.');
|
|
270
|
+
if (o.archiveSha256 && !o.archive || o.javaArchiveSha256 && !o.javaArchive)
|
|
271
|
+
fail('invalid', 'Archive checksum requires its archive path.');
|
|
272
|
+
const catalog = runtimeCatalog(), offline = Boolean(o.offline), external = o.executable ? await externalGhidra(resolve(this.c.cwd, text(o.executable, 4096))) : undefined;
|
|
273
|
+
const ghidra = external ? undefined : await this.archive(catalog.ghidra, o.archive, o.archiveSha256, offline);
|
|
274
|
+
const java = o.java ? await realpath(resolve(this.c.cwd, text(o.java, 4096))) : undefined;
|
|
275
|
+
if (java && o.javaArchive)
|
|
276
|
+
fail('invalid', 'Choose --java or --java-archive.');
|
|
277
|
+
const jdk = java ? undefined : await this.archive(catalog.java, o.javaArchive, o.javaArchiveSha256, offline);
|
|
278
|
+
const identity = digest({ schema: 1, platform: process.platform, architecture: process.arch, external, java, ghidra: ghidra?.artifact.sha256, jdk: jdk?.artifact.sha256, bridge: pinnedMcpJar });
|
|
279
|
+
const installation = join(this.root, 'installations', identity), temporary = join(this.root, 'stages', randomUUID());
|
|
280
|
+
await mkdir(temporary, { recursive: true, mode: 0o700 });
|
|
281
|
+
let selected, complete = false;
|
|
282
|
+
try {
|
|
283
|
+
let inputs;
|
|
284
|
+
if (existsSync(join(installation, 'ready.json'))) {
|
|
285
|
+
const receipt = object(JSON.parse(await readFile(join(installation, 'ready.json'), 'utf8')));
|
|
286
|
+
inputs = await nativeInputs({ ...this.c.profile.values, java: java ?? receipt.java, ghidraHome: external ?? join(installation, 'ghidra') });
|
|
287
|
+
if (inputs.fingerprint !== receipt.fingerprint)
|
|
288
|
+
fail('binding', 'Installed runtime differs from its verified installation receipt.');
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
let ghidraHome = external;
|
|
292
|
+
if (ghidra) {
|
|
293
|
+
const extracted = join(temporary, 'ghidra-extract');
|
|
294
|
+
await extractArchive(ghidra.path, extracted, ghidra.artifact.format);
|
|
295
|
+
const found = await findRoot(extracted, 'Ghidra/application.properties');
|
|
296
|
+
if (!found)
|
|
297
|
+
fail('configuration', 'Archive omitted a complete Ghidra installation.');
|
|
298
|
+
await rename(found, join(temporary, 'ghidra'));
|
|
299
|
+
await rm(extracted, { recursive: true, force: true });
|
|
300
|
+
ghidraHome = join(temporary, 'ghidra');
|
|
301
|
+
const host = process.arch === 'arm64' ? 'mac_arm_64' : 'mac_x86_64';
|
|
302
|
+
if (process.platform === 'darwin' && !existsSync(join(ghidraHome, 'Ghidra/Features/Decompiler/os', host, 'decompile'))) {
|
|
303
|
+
const bottle = await cacheArtifact(catalog.ghidra, join(this.root, 'cache'), this.c.fetch, this.c.signal, offline), nativeRoot = join(temporary, 'native-extract');
|
|
304
|
+
await extractArchive(bottle, nativeRoot, 'tgz');
|
|
305
|
+
const nativeHome = join(nativeRoot, catalog.ghidra.root);
|
|
306
|
+
for (const module of ['Decompiler', 'FileFormats'])
|
|
307
|
+
await cp(join(nativeHome, 'Ghidra/Features', module, 'os', host), join(ghidraHome, 'Ghidra/Features', module, 'os', host), { recursive: true, errorOnExist: true });
|
|
308
|
+
await rm(nativeRoot, { recursive: true, force: true });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
let javaPath = java;
|
|
312
|
+
if (jdk) {
|
|
313
|
+
const extracted = join(temporary, 'jdk-extract');
|
|
314
|
+
await extractArchive(jdk.path, extracted, jdk.artifact.format);
|
|
315
|
+
const home = await findRoot(extracted, 'bin/java' + suffix);
|
|
316
|
+
if (!home)
|
|
317
|
+
fail('configuration', 'Java archive omitted a JDK.');
|
|
318
|
+
await rename(home, join(temporary, 'jdk'));
|
|
319
|
+
await rm(extracted, { recursive: true, force: true });
|
|
320
|
+
javaPath = join(temporary, 'jdk/bin/java' + suffix);
|
|
321
|
+
}
|
|
322
|
+
inputs = await nativeInputs({ ...this.c.profile.values, java: javaPath, ghidraHome });
|
|
323
|
+
await this.probe(inputs, join(temporary, 'validation'));
|
|
324
|
+
await rm(join(temporary, 'validation'), { recursive: true, force: true });
|
|
325
|
+
const receipt = { schema: 1, id: identity, ghidraVersion: inputs.ghidraVersion, fingerprint: inputs.fingerprint, java: java ?? join(installation, 'jdk/bin/java' + suffix), ghidraHome: external ?? join(installation, 'ghidra'),
|
|
326
|
+
archives: [...(ghidra ? [{ sha256: ghidra.artifact.sha256, source: o.archive ? 'local' : catalog.ghidra.url }] : []), ...(jdk ? [{ sha256: jdk.artifact.sha256, source: o.javaArchive ? 'local' : catalog.java.url }] : [])], verifiedAt: new Date().toISOString(), checks: ['jdk', 'native-decompiler', 'local-server-reader-publisher-authority'] };
|
|
327
|
+
await runtimeWrite(join(temporary, 'ready.json'), receipt);
|
|
328
|
+
await mkdir(dirname(installation), { recursive: true, mode: 0o700 });
|
|
329
|
+
if (existsSync(installation))
|
|
330
|
+
fail('binding', 'Incomplete installation directory is retained; inspect it before removing it.');
|
|
331
|
+
await rename(temporary, installation);
|
|
332
|
+
if (process.platform !== 'win32') {
|
|
333
|
+
const parent = await open(dirname(installation), 'r');
|
|
334
|
+
try {
|
|
335
|
+
await parent.sync();
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
await parent.close();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
selected = { schema: 1, mode: external ? 'external' : 'managed', id: identity, ghidraHome: external ?? join(installation, 'ghidra'), java: java ?? join(installation, 'jdk/bin/java' + suffix), fingerprint: inputs.fingerprint, selectedAt: new Date().toISOString() };
|
|
343
|
+
await runtimeWrite(join(this.root, 'selection.json'), selected);
|
|
344
|
+
complete = true;
|
|
345
|
+
return { state: 'installed', ...selected, checks: ['jdk', 'native-decompiler', 'local-server-reader-publisher-authority'] };
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
await runtimeWrite(join(temporary, 'failure.json'), { state: 'failed', code: error instanceof CliError ? error.code : 'installation', message: error instanceof Error ? error.message : 'Runtime installation failed' });
|
|
349
|
+
if (error instanceof CliError)
|
|
350
|
+
throw new CliError(error.message, { code: error.code, result: { ...(error.result && typeof error.result === 'object' ? error.result : {}), diagnostics: temporary } });
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
if (complete)
|
|
355
|
+
await rm(temporary, { recursive: true, force: true });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
async uninstall(purgeCache = false) {
|
|
359
|
+
await this.cleanupProbes();
|
|
360
|
+
const selected = runtimeSelection(this.directory);
|
|
361
|
+
if (!selected)
|
|
362
|
+
fail('configuration', 'No managed selection exists; externally configured profile paths are never removed.');
|
|
363
|
+
if (selected.id) {
|
|
364
|
+
if (!/^[a-f0-9]{64}$/.test(selected.id))
|
|
365
|
+
fail('binding', 'Invalid installation identity.');
|
|
366
|
+
await rm(join(this.root, 'installations', selected.id), { recursive: true, force: true });
|
|
367
|
+
}
|
|
368
|
+
if (purgeCache)
|
|
369
|
+
await rm(join(this.root, 'cache'), { recursive: true, force: true });
|
|
370
|
+
await runtimeWrite(join(this.root, 'selection.json'), { ...selected, uninstalledAt: new Date().toISOString() });
|
|
371
|
+
// Retain the selection: an explicit external profile must never become an implicit fallback.
|
|
372
|
+
return { state: 'uninstalled', mode: selected.mode, externalInstallationPreserved: selected.mode === 'external', researchPreserved: true, materialsPreserved: true, cachePreserved: !purgeCache };
|
|
373
|
+
}
|
|
374
|
+
async probe(inputs, root) {
|
|
375
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
376
|
+
const classes = await nativeHelpers(root, inputs), log = join(root, 'doctor.log');
|
|
377
|
+
let output;
|
|
378
|
+
try {
|
|
379
|
+
output = await exec(inputs.java, ['-Djava.awt.headless=true', '-Dghidra.home=' + inputs.ghidraHome, ...nativeJvmDirectories(root), '-cp', classes + delimiter + inputs.classpath, 'InsightRuntimeDoctor'], { cwd: root, env: { PATH: dirname(inputs.java), HOME: root, TMPDIR: root }, timeout: 90000, maxBuffer: 1024 * 1024, signal: this.c.signal });
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
await runtimeWrite(log, { failure: String(error.stderr ?? error) });
|
|
383
|
+
fail('configuration', 'Native decompiler probe failed; the diagnostic stage is retained.', { diagnostics: log });
|
|
384
|
+
}
|
|
385
|
+
const value = object(JSON.parse(output.stdout));
|
|
386
|
+
if (value.state !== 'ready' || value.decompiler !== true || value.expectedReturn !== 42)
|
|
387
|
+
fail('partial', 'Native decompiler probe did not verify its expected result.');
|
|
388
|
+
const probeContext = { ...this.c, profile: { ...this.c.profile, values: { ...this.c.profile.values, server: undefined } } };
|
|
389
|
+
const server = new GhidraServer(root, inputs, probeContext);
|
|
390
|
+
try {
|
|
391
|
+
const result = await server.administration('connect', {});
|
|
392
|
+
if (result.connected !== true || result.mode !== 'local')
|
|
393
|
+
fail('partial', 'Owned server probe did not connect.');
|
|
394
|
+
const checked = await server.administration('status', {});
|
|
395
|
+
if (checked.connected !== true)
|
|
396
|
+
fail('partial', 'Owned server identity/authority probe failed.');
|
|
397
|
+
}
|
|
398
|
+
finally {
|
|
399
|
+
await server.stop();
|
|
400
|
+
}
|
|
401
|
+
return value;
|
|
402
|
+
}
|
|
403
|
+
async doctor() {
|
|
404
|
+
await this.cleanupProbes();
|
|
405
|
+
const root = join(this.root, 'checks', randomUUID()), values = runtimeValues(this.directory, this.c.profile.values);
|
|
406
|
+
const inputs = await nativeInputs(values), native = await this.probe(inputs, root);
|
|
407
|
+
let debuggerCheck = { state: 'host_specific' };
|
|
408
|
+
if (process.platform === 'darwin')
|
|
409
|
+
try {
|
|
410
|
+
const checked = await exec('/usr/bin/lldb', ['--no-lldbinit', '--batch', '-o', 'script import lldb,sys; print("INSIGHT_LLDB_PYTHON_OK")'], { env: { PATH: '/usr/bin:/bin', HOME: root, TMPDIR: root }, timeout: 30000, maxBuffer: 65536 });
|
|
411
|
+
debuggerCheck = { state: checked.stdout.includes('INSIGHT_LLDB_PYTHON_OK') ? 'ready' : 'missing', backend: 'system-lldb' };
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
debuggerCheck = { state: 'missing', backend: 'system-lldb', reason: 'Apple developer tools or debugger authorization is unavailable' };
|
|
415
|
+
}
|
|
416
|
+
const result = { state: 'verified', fingerprint: inputs.fingerprint, ghidraVersion: inputs.ghidraVersion, checkedAt: new Date().toISOString(), headless: native.headless, decompiler: native.decompiler, server: true, debugger: debuggerCheck, diagnostics: root };
|
|
417
|
+
await runtimeWrite(join(this.root, 'last-check.json'), result);
|
|
418
|
+
if (debuggerCheck.state === 'missing')
|
|
419
|
+
fail('partial', 'Static analysis and local server passed; the host debugger is unavailable.', { ...result, state: 'partial' });
|
|
420
|
+
return result;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function runtimeValuesForStatus(selection, values) { return { ...values, java: selection.java, ghidraHome: selection.ghidraHome }; }
|
|
424
|
+
async function readMagic(path) { const handle = await open(path, 'r'); try {
|
|
425
|
+
const b = Buffer.alloc(2);
|
|
426
|
+
await handle.read(b, 0, 2, 0);
|
|
427
|
+
return b.toString('hex');
|
|
428
|
+
}
|
|
429
|
+
finally {
|
|
430
|
+
await handle.close();
|
|
431
|
+
} }
|
|
432
|
+
export function runtimeCommands(exclusive) {
|
|
433
|
+
const source = [{ flags: '--archive <path>', description: 'Local Ghidra zip/tar.gz; missing dependencies are downloaded' },
|
|
434
|
+
{ flags: '--archive-sha256 <sha256>', description: 'Expected SHA256 for the supplied Ghidra archive' },
|
|
435
|
+
{ flags: '--java-archive <path>', description: 'Local Java 21 JDK zip/tar.gz' },
|
|
436
|
+
{ flags: '--java-archive-sha256 <sha256>', description: 'Expected SHA256 for the supplied JDK archive' },
|
|
437
|
+
{ flags: '--java <path>', description: 'Use an external Java 21 JDK executable' },
|
|
438
|
+
{ flags: '--executable <path>', description: 'Use an external Ghidra launcher or installation directory; never deleted' },
|
|
439
|
+
{ flags: '--offline', description: 'Use local archives/cache only; never access the network' }];
|
|
440
|
+
return [command('download', 'Cache the pinned complete Ghidra/JDK distribution without activation', ({ options: o }, c) => exclusive(c, () => new RuntimeManager(c).download(Boolean(o.offline)), false), { permission: Permission.Update, options: [source.at(-1)] }),
|
|
441
|
+
command('install', 'Prepare, verify and select a complete runtime, including Ghidra Server', ({ options: o }, c) => exclusive(c, () => new RuntimeManager(c).install(o)), { permission: Permission.Update, options: source }),
|
|
442
|
+
command('uninstall', 'Remove selected owned runtime files while preserving research and external installations', ({ options: o }, c) => exclusive(c, () => new RuntimeManager(c).uninstall(Boolean(o.purgeCache))), { permission: 'Destructive', options: [{ flags: '--purge-cache', description: 'Also remove owned downloaded archives' }] }),
|
|
443
|
+
command('doctor', 'Launch a disposable native decompiler and local server probe', (_input, c) => exclusive(c, () => new RuntimeManager(c).doctor(), false), { permission: Permission.Update })];
|
|
444
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface RuntimeSelection {
|
|
2
|
+
schema: 1;
|
|
3
|
+
mode: 'managed' | 'external';
|
|
4
|
+
id?: string;
|
|
5
|
+
ghidraHome: string;
|
|
6
|
+
java: string;
|
|
7
|
+
fingerprint: string;
|
|
8
|
+
selectedAt: string;
|
|
9
|
+
uninstalledAt?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const runtimeRoot: (directory: string) => string;
|
|
12
|
+
export declare function runtimeSelection(directory: string): RuntimeSelection | undefined;
|
|
13
|
+
export declare function runtimeValues(directory: string, values: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, isAbsolute } from 'node:path';
|
|
3
|
+
import { fail, object } from './model.js';
|
|
4
|
+
export const runtimeRoot = (directory) => join(directory, 'runtime');
|
|
5
|
+
export function runtimeSelection(directory) {
|
|
6
|
+
const path = join(runtimeRoot(directory), 'selection.json');
|
|
7
|
+
if (!existsSync(path))
|
|
8
|
+
return;
|
|
9
|
+
const value = object(JSON.parse(readFileSync(path, 'utf8')));
|
|
10
|
+
if (value.schema !== 1 || !['managed', 'external'].includes(String(value.mode)) || typeof value.java !== 'string' || !isAbsolute(value.java) || typeof value.ghidraHome !== 'string' || !isAbsolute(value.ghidraHome) || !/^[a-f0-9]{64}$/.test(String(value.fingerprint)))
|
|
11
|
+
fail('binding', 'Runtime selection receipt is invalid.');
|
|
12
|
+
if (value.mode === 'managed' && (!/^[a-f0-9]{64}$/.test(String(value.id)) || value.ghidraHome !== join(runtimeRoot(directory), 'installations', String(value.id), 'ghidra')))
|
|
13
|
+
fail('binding', 'Managed runtime moved outside its owned installation.');
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
export function runtimeValues(directory, values) {
|
|
17
|
+
const selected = runtimeSelection(directory);
|
|
18
|
+
if (!selected)
|
|
19
|
+
return values;
|
|
20
|
+
if (selected.uninstalledAt)
|
|
21
|
+
fail('configuration', 'Selected Ghidra runtime was uninstalled. Run insight-cli ghidra install.');
|
|
22
|
+
if (selected.mode === 'managed' && !existsSync(join(runtimeRoot(directory), 'installations', selected.id, 'ready.json')))
|
|
23
|
+
fail('configuration', 'Selected managed Ghidra is not installed. Run insight-cli ghidra install.');
|
|
24
|
+
return { ...values, java: selected.java, ghidraHome: selected.ghidraHome };
|
|
25
|
+
}
|
package/dist/src/server.d.ts
CHANGED
|
@@ -42,3 +42,5 @@ export declare class GhidraServer {
|
|
|
42
42
|
private identity;
|
|
43
43
|
stop(): Promise<void>;
|
|
44
44
|
}
|
|
45
|
+
/** Stop only the authenticated owned server recorded below this workspace/probe root. */
|
|
46
|
+
export declare function stopLocalServer(directory: string, c: CommandContext): Promise<void>;
|
package/dist/src/server.js
CHANGED
|
@@ -186,6 +186,10 @@ export class GhidraServer {
|
|
|
186
186
|
await mkdir(this.root(), { recursive: true, mode: 0o700 });
|
|
187
187
|
await durable(connection, { enabled: true });
|
|
188
188
|
}
|
|
189
|
+
if (operation === 'stop') {
|
|
190
|
+
await this.stop();
|
|
191
|
+
return { connected: false, mode: this.c.profile.values.server ? 'external' : 'local', state: 'stopped' };
|
|
192
|
+
}
|
|
189
193
|
if (operation === 'disconnect') {
|
|
190
194
|
await this.stop();
|
|
191
195
|
await mkdir(this.root(), { recursive: true, mode: 0o700 });
|
|
@@ -394,62 +398,63 @@ export class GhidraServer {
|
|
|
394
398
|
throw error;
|
|
395
399
|
}
|
|
396
400
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
await reader.cancel().catch(() => { });
|
|
401
|
+
identity(local) { return serverIdentity(local, this.c); }
|
|
402
|
+
async stop() { await stopLocalServer(this.directory, this.c); this.state.child = undefined; this.state.initializing = undefined; }
|
|
403
|
+
}
|
|
404
|
+
async function serverIdentity(local, c) {
|
|
405
|
+
const token = await c.secrets.require(local.tokenSecret);
|
|
406
|
+
const response = await c.fetch(`http://127.0.0.1:${local.identityPort}/identity`, { redirect: 'error',
|
|
407
|
+
signal: AbortSignal.timeout(1000), headers: { Authorization: 'Bearer ' + token } });
|
|
408
|
+
if (!response.ok || Number(response.headers.get('content-length')) > 4096)
|
|
409
|
+
fail('binding', 'Owned server identity was not verified.');
|
|
410
|
+
const reader = response.body?.getReader();
|
|
411
|
+
if (!reader)
|
|
412
|
+
fail('binding', 'Owned server identity has no body.');
|
|
413
|
+
const chunks = [];
|
|
414
|
+
let size = 0;
|
|
415
|
+
try {
|
|
416
|
+
for (;;) {
|
|
417
|
+
const next = await reader.read();
|
|
418
|
+
if (next.done)
|
|
419
|
+
break;
|
|
420
|
+
size += next.value.byteLength;
|
|
421
|
+
if (size > 4096)
|
|
422
|
+
fail('binding', 'Owned server identity exceeded its bound.');
|
|
423
|
+
chunks.push(next.value);
|
|
421
424
|
}
|
|
422
|
-
const value = Buffer.concat(chunks).toString('utf8');
|
|
423
|
-
const result = object(JSON.parse(value));
|
|
424
|
-
if (result.instance !== local.instance || result.port !== local.port || !Number.isSafeInteger(result.pid) || Number(result.pid) < 1 ||
|
|
425
|
-
local.pid !== undefined && result.pid !== local.pid)
|
|
426
|
-
fail('binding', 'Server endpoint no longer belongs to the recorded owned process.');
|
|
427
|
-
return result;
|
|
428
425
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
426
|
+
finally {
|
|
427
|
+
await reader.cancel().catch(() => { });
|
|
428
|
+
}
|
|
429
|
+
const value = Buffer.concat(chunks).toString('utf8');
|
|
430
|
+
const result = object(JSON.parse(value));
|
|
431
|
+
if (result.instance !== local.instance || result.port !== local.port || !Number.isSafeInteger(result.pid) || Number(result.pid) < 1 ||
|
|
432
|
+
local.pid !== undefined && result.pid !== local.pid)
|
|
433
|
+
fail('binding', 'Server endpoint no longer belongs to the recorded owned process.');
|
|
434
|
+
return result;
|
|
435
|
+
}
|
|
436
|
+
/** Stop only the authenticated owned server recorded below this workspace/probe root. */
|
|
437
|
+
export async function stopLocalServer(directory, c) {
|
|
438
|
+
let local;
|
|
439
|
+
try {
|
|
440
|
+
local = JSON.parse(await readFile(join(directory, 'server', 'local.json'), 'utf8'));
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
if (error.code === 'ENOENT')
|
|
440
444
|
return;
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
this.state.child = undefined;
|
|
453
|
-
this.state.initializing = undefined;
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
447
|
+
if (!local.pid)
|
|
448
|
+
return;
|
|
449
|
+
if (live(local.pid)) {
|
|
450
|
+
await serverIdentity(local, c);
|
|
451
|
+
process.kill(local.pid, 'SIGTERM');
|
|
452
|
+
for (let i = 0; i < 100 && live(local.pid); i++)
|
|
453
|
+
await delay(50);
|
|
454
|
+
if (live(local.pid))
|
|
455
|
+
fail('busy', 'Owned server did not stop; its receipt is retained.');
|
|
454
456
|
}
|
|
457
|
+
local.phase = 'stopped';
|
|
458
|
+
delete local.pid;
|
|
459
|
+
await durable(join(directory, 'server', 'local.json'), local);
|
|
455
460
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eyeauras/insight-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Agent-facing durable sessions and isolated Ghidra research contexts",
|
|
6
6
|
"main": "./dist/src/index.js",
|
|
@@ -24,8 +24,10 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@eyeauras/cli-factory": "0.4.1",
|
|
27
|
+
"@eyeauras/cli-factory-ipc": "https://raw.githubusercontent.com/iXab3r/insight-cli/43f815a006711418bcffa47a7d99ef5c42424ca5/vendor/factory-ipc-a2094fb.tgz",
|
|
27
28
|
"@modelcontextprotocol/sdk": "1.32.1",
|
|
28
|
-
"
|
|
29
|
+
"tar": "7.5.22",
|
|
30
|
+
"yauzl": "3.4.0"
|
|
29
31
|
},
|
|
30
32
|
"engines": {
|
|
31
33
|
"node": ">=24"
|