@eyeauras/insight-cli 0.0.0-stage → 0.1.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.
Files changed (164) hide show
  1. package/.cli-package-build.json +1 -0
  2. package/LICENSE +21 -0
  3. package/README.md +54 -2
  4. package/SKILL.md +55 -0
  5. package/dist/src/administration-results.d.ts +1 -0
  6. package/dist/src/administration-results.js +84 -0
  7. package/dist/src/administration.d.ts +14 -0
  8. package/dist/src/administration.js +120 -0
  9. package/dist/src/archive-contract.d.ts +6 -0
  10. package/dist/src/archive-contract.js +108 -0
  11. package/dist/src/archive.d.ts +12 -0
  12. package/dist/src/archive.js +116 -0
  13. package/dist/src/arguments.d.ts +14 -0
  14. package/dist/src/arguments.js +62 -0
  15. package/dist/src/artifacts.d.ts +17 -0
  16. package/dist/src/artifacts.js +115 -0
  17. package/dist/src/bin.d.ts +2 -0
  18. package/dist/src/bin.js +4 -0
  19. package/dist/src/cli.d.ts +10 -0
  20. package/dist/src/cli.js +387 -0
  21. package/dist/src/comment-effects.d.ts +3 -0
  22. package/dist/src/comment-effects.js +55 -0
  23. package/dist/src/contexts.d.ts +115 -0
  24. package/dist/src/contexts.js +1068 -0
  25. package/dist/src/coordinator.d.ts +41 -0
  26. package/dist/src/coordinator.js +160 -0
  27. package/dist/src/cross-results.d.ts +14 -0
  28. package/dist/src/cross-results.js +71 -0
  29. package/dist/src/cross.d.ts +3 -0
  30. package/dist/src/cross.js +43 -0
  31. package/dist/src/data-effects.d.ts +3 -0
  32. package/dist/src/data-effects.js +103 -0
  33. package/dist/src/debugger-results.d.ts +4 -0
  34. package/dist/src/debugger-results.js +151 -0
  35. package/dist/src/debugger-supervisor.d.ts +1 -0
  36. package/dist/src/debugger-supervisor.js +136 -0
  37. package/dist/src/debugger.d.ts +21 -0
  38. package/dist/src/debugger.js +267 -0
  39. package/dist/src/documentation-effects.d.ts +5 -0
  40. package/dist/src/documentation-effects.js +56 -0
  41. package/dist/src/extensions.d.ts +64 -0
  42. package/dist/src/extensions.js +289 -0
  43. package/dist/src/function-effects.d.ts +4 -0
  44. package/dist/src/function-effects.js +112 -0
  45. package/dist/src/ghidra.d.ts +50 -0
  46. package/dist/src/ghidra.js +468 -0
  47. package/dist/src/index.d.ts +2 -0
  48. package/dist/src/index.js +1 -0
  49. package/dist/src/initial-model.d.ts +6 -0
  50. package/dist/src/initial-model.js +85 -0
  51. package/dist/src/json.d.ts +2 -0
  52. package/dist/src/json.js +8 -0
  53. package/dist/src/lifecycle.d.ts +49 -0
  54. package/dist/src/lifecycle.js +148 -0
  55. package/dist/src/memory-inputs.d.ts +8 -0
  56. package/dist/src/memory-inputs.js +56 -0
  57. package/dist/src/merge.d.ts +29 -0
  58. package/dist/src/merge.js +66 -0
  59. package/dist/src/model.d.ts +74 -0
  60. package/dist/src/model.js +67 -0
  61. package/dist/src/mutation-effects.d.ts +4 -0
  62. package/dist/src/mutation-effects.js +321 -0
  63. package/dist/src/mutation-inputs.d.ts +2 -0
  64. package/dist/src/mutation-inputs.js +71 -0
  65. package/dist/src/mutations.d.ts +4 -0
  66. package/dist/src/mutations.js +53 -0
  67. package/dist/src/native-inputs.d.ts +28 -0
  68. package/dist/src/native-inputs.js +171 -0
  69. package/dist/src/native-queries.d.ts +2 -0
  70. package/dist/src/native-queries.js +29 -0
  71. package/dist/src/projects.d.ts +31 -0
  72. package/dist/src/projects.js +301 -0
  73. package/dist/src/prototype-cli.d.ts +8 -0
  74. package/dist/src/prototype-cli.js +155 -0
  75. package/dist/src/queries.d.ts +5 -0
  76. package/dist/src/queries.js +84 -0
  77. package/dist/src/query-policy.d.ts +6 -0
  78. package/dist/src/query-policy.js +72 -0
  79. package/dist/src/query-results.d.ts +2 -0
  80. package/dist/src/query-results.js +224 -0
  81. package/dist/src/research.d.ts +184 -0
  82. package/dist/src/research.js +808 -0
  83. package/dist/src/saved-materials.d.ts +12 -0
  84. package/dist/src/saved-materials.js +196 -0
  85. package/dist/src/scripts.d.ts +19 -0
  86. package/dist/src/scripts.js +79 -0
  87. package/dist/src/sdk-contracts.d.ts +13 -0
  88. package/dist/src/sdk-contracts.js +37 -0
  89. package/dist/src/sdk-files.d.ts +29 -0
  90. package/dist/src/sdk-files.js +249 -0
  91. package/dist/src/sdk.d.ts +4 -0
  92. package/dist/src/sdk.js +125 -0
  93. package/dist/src/server.d.ts +44 -0
  94. package/dist/src/server.js +455 -0
  95. package/dist/src/store.d.ts +33 -0
  96. package/dist/src/store.js +178 -0
  97. package/dist/src/sync-policy.d.ts +22 -0
  98. package/dist/src/sync-policy.js +123 -0
  99. package/dist/src/transport.d.ts +23 -0
  100. package/dist/src/transport.js +135 -0
  101. package/dist/src/ui-results.d.ts +2 -0
  102. package/dist/src/ui-results.js +80 -0
  103. package/dist/src/ui.d.ts +17 -0
  104. package/dist/src/ui.js +88 -0
  105. package/dist/src/utility.d.ts +14 -0
  106. package/dist/src/utility.js +43 -0
  107. package/dist/src/version-tracking.d.ts +57 -0
  108. package/dist/src/version-tracking.js +200 -0
  109. package/dist/src/workspace.d.ts +199 -0
  110. package/dist/src/workspace.js +564 -0
  111. package/package.json +45 -4
  112. package/references/InsightRepositoryProbe.java +55 -0
  113. package/references/administration.md +33 -0
  114. package/references/archive.md +32 -0
  115. package/references/artifacts.md +47 -0
  116. package/references/debugger.md +40 -0
  117. package/references/extensions-and-sync.md +124 -0
  118. package/references/mutations.json +1424 -0
  119. package/references/native/InsightApplyDocumentation.java +48 -0
  120. package/references/native/InsightArchivePayloads.java +36 -0
  121. package/references/native/InsightBlockTransfer.java +57 -0
  122. package/references/native/InsightEmulation.java +93 -0
  123. package/references/native/InsightExtensions.java +36 -0
  124. package/references/native/InsightFunctionDatabases.java +98 -0
  125. package/references/native/InsightGuiMain.java +32 -0
  126. package/references/native/InsightHeadlessMain.java +10 -0
  127. package/references/native/InsightInitialImport.java +42 -0
  128. package/references/native/InsightInitialJournal.java +48 -0
  129. package/references/native/InsightInitialPublication.java +142 -0
  130. package/references/native/InsightKeytool.java +16 -0
  131. package/references/native/InsightNativeReceipt.java +31 -0
  132. package/references/native/InsightPlugins.java +34 -0
  133. package/references/native/InsightProjectFiles.java +92 -0
  134. package/references/native/InsightPublicationRecovery.java +63 -0
  135. package/references/native/InsightPythonWheels.java +31 -0
  136. package/references/native/InsightResearchQueries.java +188 -0
  137. package/references/native/InsightSdkFiles.java +142 -0
  138. package/references/native/InsightSdkScript.java +31 -0
  139. package/references/native/InsightSdkTools.java +338 -0
  140. package/references/native/InsightSdkTypes.java +132 -0
  141. package/references/native/InsightServerAdministration.java +82 -0
  142. package/references/native/InsightServerInit.java +62 -0
  143. package/references/native/InsightServerMain.java +43 -0
  144. package/references/native/InsightServerProbe.java +29 -0
  145. package/references/native/InsightSnapshotFields.java +68 -0
  146. package/references/native/InsightSnapshotInspect.java +189 -0
  147. package/references/native/InsightSnapshotPublisher.java +123 -0
  148. package/references/native/InsightStartDebugger.java +28 -0
  149. package/references/native/InsightStopDebugger.java +20 -0
  150. package/references/native/InsightTraceInspect.java +24 -0
  151. package/references/native/InsightTraceState.java +130 -0
  152. package/references/native/InsightTraceTools.java +114 -0
  153. package/references/native/InsightVersionTracking.java +91 -0
  154. package/references/native-workflows.md +112 -0
  155. package/references/queries.json +1761 -0
  156. package/references/query-semantics.md +96 -0
  157. package/references/recovery.md +53 -0
  158. package/references/research.md +101 -0
  159. package/references/setup.md +47 -0
  160. package/runtime/GhidraMCP-7.0.0.jar +0 -0
  161. package/runtime/GhidraMCP-LICENSE +202 -0
  162. package/runtime/GhidraMCP-NOTICE +10 -0
  163. package/runtime/provenance.json +11 -0
  164. package/skills/insight-re/SKILL.md +108 -0
@@ -0,0 +1,455 @@
1
+ import { randomUUID, randomBytes, createHash } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { readFile, mkdir, open, rename, readdir, stat, chmod, realpath } from 'node:fs/promises';
4
+ import { delimiter, dirname, join, resolve } from 'node:path';
5
+ import { createServer } from 'node:net';
6
+ import { setTimeout as delay } from 'node:timers/promises';
7
+ import { fail, object, stable, text, digest } from './model.js';
8
+ import { nativeHelpers, nativeJvmDirectories, nativeExtensionArguments } from './native-inputs.js';
9
+ import { parseDomainJson } from './json.js';
10
+ const live = (pid) => {
11
+ try {
12
+ process.kill(pid, 0);
13
+ return true;
14
+ }
15
+ catch (error) {
16
+ if (error.code === 'ESRCH')
17
+ return false;
18
+ throw error;
19
+ }
20
+ };
21
+ async function durable(path, value) {
22
+ const temporary = path + '.new-' + randomUUID(), handle = await open(temporary, 'wx', 0o600);
23
+ try {
24
+ await handle.writeFile(stable(value));
25
+ await handle.sync();
26
+ }
27
+ finally {
28
+ await handle.close();
29
+ }
30
+ await rename(temporary, path);
31
+ if (process.platform !== 'win32') {
32
+ const directory = await open(dirname(path), 'r');
33
+ try {
34
+ await directory.sync();
35
+ }
36
+ finally {
37
+ await directory.close();
38
+ }
39
+ }
40
+ }
41
+ async function ports(count) {
42
+ for (let attempt = 0; attempt < 100; attempt++) {
43
+ const base = 20000 + Math.floor(Math.random() * 30000), held = [];
44
+ try {
45
+ for (let index = 0; index < count; index++) {
46
+ const listener = createServer();
47
+ held.push(listener);
48
+ await new Promise((resolve, reject) => { listener.once('error', reject); listener.listen(base + index, '127.0.0.1', resolve); });
49
+ }
50
+ return base;
51
+ }
52
+ catch { /* Collision is retried before any resource or process is published. */ }
53
+ finally {
54
+ await Promise.all(held.filter(listener => listener.listening).map(listener => new Promise(resolve => listener.close(() => resolve()))));
55
+ }
56
+ }
57
+ fail('launch', 'Could not reserve loopback ports for the owned server.');
58
+ }
59
+ /** Own local startup; external profiles connect without taking ownership of their server. */
60
+ export class GhidraServer {
61
+ directory;
62
+ inputs;
63
+ c;
64
+ state;
65
+ constructor(directory, inputs, c, state = {}) {
66
+ this.directory = directory;
67
+ this.inputs = inputs;
68
+ this.c = c;
69
+ this.state = state;
70
+ }
71
+ fork(c) { return new GhidraServer(this.directory, this.inputs, c, this.state); }
72
+ root() { return join(this.directory, 'server'); }
73
+ async run(label, main, args, environment = {}, input, acceptedCodes = [0], timeoutMilliseconds = 120000) {
74
+ const root = join(this.root(), 'calls', label + '-' + randomUUID());
75
+ await mkdir(root, { recursive: true, mode: 0o700 });
76
+ const classes = await nativeHelpers(this.directory, this.inputs);
77
+ const log = await open(join(root, 'diagnostic.log'), 'a', 0o600);
78
+ const child = spawn(this.inputs.java, ['-Djava.awt.headless=true', '-Dghidra.home=' + this.inputs.ghidraHome,
79
+ ...nativeJvmDirectories(root), ...nativeExtensionArguments(this.inputs), '-cp', classes + delimiter + this.inputs.classpath, main, ...args], { cwd: root, windowsHide: true, env: { PATH: this.c.environment.PATH ?? process.env.PATH, HOME: root, TMPDIR: root, ...environment }, stdio: ['pipe', 'pipe', log.fd] });
80
+ const exited = new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', resolve); });
81
+ exited.catch(() => { });
82
+ await log.close();
83
+ let output = '', overflow = false;
84
+ child.stdout.on('data', chunk => { if (!overflow)
85
+ output += chunk.toString(); if (Buffer.byteLength(output) > 1024 * 1024) {
86
+ overflow = true;
87
+ child.kill('SIGTERM');
88
+ } });
89
+ const abort = () => child.kill('SIGTERM');
90
+ this.c.signal.addEventListener('abort', abort, { once: true });
91
+ const timer = setTimeout(abort, timeoutMilliseconds);
92
+ const force = setTimeout(() => { if (child.exitCode === null && child.signalCode === null)
93
+ child.kill('SIGKILL'); }, timeoutMilliseconds + 5000);
94
+ try {
95
+ child.stdin.on('error', () => { });
96
+ child.stdin.end(input);
97
+ if (this.c.signal.aborted)
98
+ abort();
99
+ const code = await exited;
100
+ if (code === null || !acceptedCodes.includes(code) || overflow)
101
+ fail('authorization', 'Native server operation could not be verified; its owned diagnostic stage is retained.');
102
+ let result;
103
+ try {
104
+ result = object(parseDomainJson(output));
105
+ }
106
+ catch {
107
+ fail('malformed', 'Native server operation returned malformed content.');
108
+ }
109
+ if (result.error || result.state === 'failed')
110
+ fail('authorization', 'Native server operation did not establish its outcome.');
111
+ return result;
112
+ }
113
+ finally {
114
+ clearTimeout(timer);
115
+ clearTimeout(force);
116
+ this.c.signal.removeEventListener('abort', abort);
117
+ }
118
+ }
119
+ async credentials(binding, role) {
120
+ const url = new URL(binding.origin);
121
+ return { GHIDRA_SERVER_HOST: url.hostname, GHIDRA_SERVER_PORT: url.port || '13100',
122
+ GHIDRA_SERVER_USER: role === 'reader' ? binding.readerUser : binding.publisherUser,
123
+ GHIDRA_SERVER_PASSWORD: await this.c.secrets.require(role === 'reader' ? binding.readerSecret : binding.publisherSecret) };
124
+ }
125
+ async fields(snapshot, selectors) {
126
+ return this.run('snapshot-fields', 'InsightSnapshotFields', [snapshot, selectors]);
127
+ }
128
+ async inspect(snapshot, request) { return this.run('snapshot-inspect', 'InsightSnapshotInspect', [snapshot, request]); }
129
+ async sdkFile(operation, p) {
130
+ const root = join(this.root(), 'sdk-files', randomUUID());
131
+ await mkdir(root, { recursive: true, mode: 0o700 });
132
+ const tracking = operation.startsWith('vt.');
133
+ const request = join(root, 'request.json');
134
+ await durable(request, { ...p, operation: tracking ? operation.slice(3) : operation });
135
+ const main = tracking ? 'InsightVersionTracking' : /^(fid|bsim)-(build|query)$/.test(operation) ? 'InsightFunctionDatabases' : 'InsightSdkFiles';
136
+ const result = await this.run('sdk-file', main, [request]);
137
+ return object(result.value);
138
+ }
139
+ async traceInspect(snapshot) { return this.run('trace-inspect', 'InsightTraceInspect', [snapshot]); }
140
+ async project(operation, parameters) {
141
+ const requestId = text(parameters.request_id, 128);
142
+ if (!/^(?:a|read)_[a-zA-Z0-9-]+$/.test(requestId))
143
+ fail('binding', 'Invalid internal project request identity.');
144
+ const parent = join(this.directory, 'project-calls');
145
+ await mkdir(parent, { recursive: true, mode: 0o700 });
146
+ if (await realpath(parent) !== resolve(parent))
147
+ fail('binding', 'Project call container moved outside owned storage.');
148
+ const root = join(parent, requestId);
149
+ await mkdir(root, { mode: 0o700 });
150
+ const request = join(root, 'request.json');
151
+ await durable(request, { ...parameters, operation });
152
+ return this.run('project-files', 'InsightProjectFiles', [request]);
153
+ }
154
+ async projectOutcome(requestId, expected) {
155
+ if (!/^a_[a-zA-Z0-9-]+$/.test(requestId))
156
+ fail('binding', 'Invalid retained project request identity.');
157
+ const root = join(this.directory, 'project-calls', requestId);
158
+ try {
159
+ if (await realpath(root) !== resolve(root))
160
+ fail('binding', 'Retained project call moved outside owned storage.');
161
+ const raw = await readFile(join(root, 'request.json'), 'utf8'), request = object(parseDomainJson(raw)), journal = object(parseDomainJson(await readFile(join(root, 'result.json'), 'utf8')));
162
+ if (journal.schema !== 1 || journal.request !== requestId || journal.hash !== createHash('sha256').update(raw).digest('hex') ||
163
+ Object.entries(expected).some(([key, value]) => stable(request[key]) !== stable(value)))
164
+ fail('binding', 'Project completion journal differs from the retained request.');
165
+ return object(journal.result);
166
+ }
167
+ catch (error) {
168
+ if (error.code === 'ENOENT')
169
+ return;
170
+ throw error;
171
+ }
172
+ }
173
+ async administration(operation, parameters) {
174
+ const connection = join(this.root(), 'connection.json');
175
+ if (operation === 'status') {
176
+ try {
177
+ if (object(JSON.parse(await readFile(connection, 'utf8'))).enabled === false)
178
+ return { connected: false, mode: this.c.profile.values.server ? 'external' : 'local', state: 'disconnected' };
179
+ }
180
+ catch (error) {
181
+ if (error.code !== 'ENOENT')
182
+ throw error;
183
+ }
184
+ }
185
+ if (operation === 'connect') {
186
+ await mkdir(this.root(), { recursive: true, mode: 0o700 });
187
+ await durable(connection, { enabled: true });
188
+ }
189
+ if (operation === 'disconnect') {
190
+ await this.stop();
191
+ await mkdir(this.root(), { recursive: true, mode: 0o700 });
192
+ await durable(connection, { enabled: false });
193
+ return { connected: false, mode: this.c.profile.values.server ? 'external' : 'local' };
194
+ }
195
+ if (operation === 'status' && !this.c.profile.values.server) {
196
+ let local;
197
+ try {
198
+ local = JSON.parse(await readFile(join(this.root(), 'local.json'), 'utf8'));
199
+ }
200
+ catch (error) {
201
+ if (error.code === 'ENOENT')
202
+ return { connected: false, mode: 'local', state: 'not_started' };
203
+ throw error;
204
+ }
205
+ if (!local.pid || !live(local.pid))
206
+ return { connected: false, mode: 'local', repository: local.repository };
207
+ await this.identity(local);
208
+ await this.authority(local);
209
+ return { connected: true, mode: 'local', repository: local.repository };
210
+ }
211
+ const binding = await this.ensure();
212
+ if (operation === 'connect' || operation === 'status')
213
+ return { connected: true, mode: binding.mode, repository: binding.repository };
214
+ if (operation === 'set_permissions' && parameters.repository === binding.repository &&
215
+ (parameters.user === binding.readerUser && parameters.access !== 0 || parameters.user === binding.publisherUser && ![1, 2].includes(Number(parameters.access))))
216
+ fail('authorization', 'Canonical reader/publisher authority cannot be changed by repository administration.');
217
+ const root = join(this.root(), 'administration', randomUUID());
218
+ await mkdir(root, { recursive: true, mode: 0o700 });
219
+ const request = join(root, 'request.json');
220
+ await durable(request, { operation, ...parameters, protected_repository: binding.repository });
221
+ return this.run('administration', 'InsightServerAdministration', [request], await this.credentials(binding, 'publisher'));
222
+ }
223
+ async programStatus(binding, path) {
224
+ return this.run('program-status', 'InsightRepositoryProbe', [binding.repository, path, 'reader'], await this.credentials(binding, 'reader'));
225
+ }
226
+ async initialImport(root) { return this.run('initial-import', 'InsightInitialImport', [root], {}, undefined, [0], 600000); }
227
+ async initialPublication(binding, root, operation = 'stage') {
228
+ return this.run('initial-publication', 'db.InsightInitialPublication', [root, operation], await this.credentials(binding, operation.startsWith('verify') ? 'reader' : 'publisher'));
229
+ }
230
+ async publish(binding, path, snapshot, expected, request, journal, sourceSha) {
231
+ return this.run('publish', 'db.InsightSnapshotPublisher', [binding.repository, path, snapshot, String(expected), 'insight merge ' + request, journal, sourceSha], await this.credentials(binding, 'publisher'), undefined, [0, 2]);
232
+ }
233
+ async recoverPublication(binding, path, snapshot, expected, request, journal, sourceSha) {
234
+ return this.run('recover-publication', 'db.InsightPublicationRecovery', [binding.repository, path, snapshot, String(expected), 'insight merge ' + request, journal, sourceSha], { ...await this.credentials(binding, 'reader'), INSIGHT_PUBLISHER_USER: binding.publisherUser,
235
+ INSIGHT_PUBLISHER_PASSWORD: await this.c.secrets.require(binding.publisherSecret) });
236
+ }
237
+ async authority(binding) {
238
+ for (const role of ['reader', 'publisher']) {
239
+ const result = await this.run('verify-' + role, 'InsightServerProbe', [binding.repository, role], await this.credentials(binding, role));
240
+ if (result.repository !== binding.repository || result.role !== role || (role === 'reader' ? result.permission !== 0 : ![1, 2].includes(Number(result.permission))))
241
+ fail('authorization', 'Actual repository permissions do not match the selected server profile.');
242
+ }
243
+ }
244
+ async ensure() {
245
+ try {
246
+ if (object(JSON.parse(await readFile(join(this.root(), 'connection.json'), 'utf8'))).enabled === false)
247
+ fail('blocked', 'Configured server is disconnected; use ghidra server connect before shared operations.');
248
+ }
249
+ catch (error) {
250
+ if (error.code !== 'ENOENT')
251
+ throw error;
252
+ }
253
+ if (!this.state.initializing)
254
+ this.state.initializing = this.initialize().catch(error => { this.state.initializing = undefined; throw error; });
255
+ const binding = await this.state.initializing;
256
+ if (binding.mode === 'local') {
257
+ const local = binding;
258
+ if (local.pid && !live(local.pid)) {
259
+ this.state.initializing = undefined;
260
+ return this.ensure();
261
+ }
262
+ await this.identity(local);
263
+ }
264
+ return binding;
265
+ }
266
+ async initialize() {
267
+ const root = this.root();
268
+ await mkdir(root, { recursive: true, mode: 0o700 });
269
+ const values = this.c.profile.values;
270
+ const mode = values.server ? 'external' : 'local';
271
+ const effective = mode === 'external' ? { mode, origin: text(values.server), repository: text(values.repository),
272
+ readerUser: text(values.readerUser), publisherUser: text(values.publisherUser) } : { mode };
273
+ if (mode === 'external' && (!/^ghidra:\/\/[^/?#@]+$/.test(text(values.server)) || /[\\/]/.test(text(values.repository)) ||
274
+ text(values.repository).startsWith('.') || values.readerUser === values.publisherUser))
275
+ fail('configuration', 'External server requires an origin, repository and distinct reader/publisher accounts.');
276
+ const configuration = digest(effective), configured = join(root, 'configuration.json');
277
+ try {
278
+ if (object(JSON.parse(await readFile(configured, 'utf8'))).hash !== configuration)
279
+ fail('binding', 'Server profile changed while its durable workspace remains; restore the original binding.');
280
+ }
281
+ catch (error) {
282
+ if (error.code !== 'ENOENT')
283
+ throw error;
284
+ await durable(configured, { hash: configuration });
285
+ }
286
+ if (mode === 'external') {
287
+ const origin = text(values.server), repository = text(values.repository), readerUser = text(values.readerUser), publisherUser = text(values.publisherUser);
288
+ if (!/^ghidra:\/\/[^/?#@]+$/.test(origin) || new URL(origin).protocol !== 'ghidra:' || /[\\/]/.test(repository) || repository.startsWith('.') || readerUser === publisherUser)
289
+ fail('configuration', 'External server requires an origin, repository and distinct reader/publisher accounts.');
290
+ const binding = { mode, origin, repository, readerUser, publisherUser, readerSecret: 'readerPassword', publisherSecret: 'publisherPassword' };
291
+ await this.authority(binding);
292
+ return binding;
293
+ }
294
+ const saved = join(root, 'local.json'), data = join(root, 'data');
295
+ let local;
296
+ try {
297
+ local = JSON.parse(await readFile(saved, 'utf8'));
298
+ }
299
+ catch (error) {
300
+ if (error.code !== 'ENOENT')
301
+ throw error;
302
+ await mkdir(data, { recursive: true, mode: 0o700 });
303
+ if ((await readdir(data)).length)
304
+ fail('binding', 'Unidentified server storage exists; it cannot be adopted.');
305
+ const instance = 'server_' + randomUUID(), base = await ports(4);
306
+ local = { mode: 'local', instance, port: base, identityPort: base + 3, origin: `ghidra://127.0.0.1:${base}`,
307
+ repository: 'insight_' + randomUUID().replaceAll('-', ''), readerUser: 'reader', publisherUser: 'publisher',
308
+ readerSecret: instance + '-readerPassword', publisherSecret: instance + '-publisherPassword',
309
+ keySecret: instance + '-keystorePassword', tokenSecret: instance + '-identityToken', phase: 'seeded' };
310
+ await durable(saved, local);
311
+ }
312
+ if (local.mode !== 'local' || !/^server_[a-f0-9-]{36}$/.test(local.instance) || local.origin !== `ghidra://127.0.0.1:${local.port}` ||
313
+ local.identityPort !== local.port + 3 || !local.repository.startsWith('insight_') || local.readerUser !== 'reader' || local.publisherUser !== 'publisher')
314
+ fail('binding', 'Owned local server receipt is inconsistent.');
315
+ for (const key of [local.readerSecret, local.publisherSecret, local.keySecret, local.tokenSecret]) {
316
+ if (!key.startsWith(local.instance + '-'))
317
+ fail('binding', 'Owned local server secret reference is inconsistent.');
318
+ if (!await this.c.secrets.get(key)) {
319
+ if (local.phase !== 'seeded')
320
+ fail('credentials', 'Owned local server credentials are missing; retain its storage and restore its credential store.');
321
+ await this.c.secrets.set(key, randomBytes(32).toString('hex'));
322
+ }
323
+ }
324
+ if (local.pid && live(local.pid)) {
325
+ await this.identity(local);
326
+ await this.authority(local);
327
+ return local;
328
+ }
329
+ // A prior owner may have died after spawn and before recording its PID.
330
+ if (!local.pid && await this.identity(local).then(() => true, () => false)) {
331
+ const identity = await this.identity(local);
332
+ local.pid = Number(identity.pid);
333
+ local.phase = 'running';
334
+ await durable(saved, local);
335
+ await this.authority(local);
336
+ return local;
337
+ }
338
+ const initialized = await this.run('initialize', 'ghidra.server.InsightServerInit', [data, local.repository, local.publisherUser, local.readerUser], {
339
+ INSIGHT_PUBLISHER_PASSWORD: await this.c.secrets.require(local.publisherSecret), INSIGHT_READER_PASSWORD: await this.c.secrets.require(local.readerSecret)
340
+ });
341
+ if (initialized.state !== 'ready' || initialized.repository !== local.repository || initialized.readerReadOnly !== true || initialized.publisherAdmin !== true)
342
+ fail('authorization', 'Owned local server initialization did not establish its permission boundary.');
343
+ local.phase = 'initialized';
344
+ delete local.pid;
345
+ await durable(saved, local);
346
+ const keystore = join(root, 'owned-cert.p12');
347
+ try {
348
+ await stat(keystore);
349
+ }
350
+ catch (error) {
351
+ if (error.code !== 'ENOENT')
352
+ throw error;
353
+ // keytool gets the random password through its documented environment input, never argv.
354
+ await this.run('certificate', 'InsightKeytool', [keystore], { INSIGHT_KEYSTORE_PASSWORD: await this.c.secrets.require(local.keySecret) });
355
+ await chmod(keystore, 0o600);
356
+ }
357
+ const classes = await nativeHelpers(this.directory, this.inputs);
358
+ const processRoot = join(root, 'processes', randomUUID());
359
+ await mkdir(processRoot, { recursive: true, mode: 0o700 });
360
+ const log = await open(join(processRoot, 'runtime.log'), 'a', 0o600);
361
+ const child = spawn(this.inputs.java, ['-Djava.awt.headless=true', '-Dghidra.home=' + this.inputs.ghidraHome,
362
+ ...nativeJvmDirectories(processRoot), '-cp', classes + delimiter + (this.inputs.serverClasspath ?? this.inputs.classpath),
363
+ 'InsightServerMain', data, String(local.port), String(local.identityPort), local.instance, keystore], { cwd: processRoot, detached: true, windowsHide: true, env: { PATH: this.c.environment.PATH ?? process.env.PATH, HOME: processRoot, TMPDIR: processRoot,
364
+ INSIGHT_KEYSTORE_PASSWORD: await this.c.secrets.require(local.keySecret), INSIGHT_SERVER_TOKEN: await this.c.secrets.require(local.tokenSecret) }, stdio: ['ignore', log.fd, log.fd] });
365
+ child.on('error', () => { });
366
+ child.unref();
367
+ await log.close();
368
+ if (!child.pid)
369
+ fail('launch', 'Owned local Ghidra server could not be started.');
370
+ this.state.child = child;
371
+ local.pid = child.pid;
372
+ local.phase = 'running';
373
+ await durable(saved, local);
374
+ try {
375
+ const deadline = Date.now() + 60000;
376
+ while (Date.now() < deadline && child.exitCode === null && child.signalCode === null && !this.c.signal.aborted) {
377
+ try {
378
+ await this.identity(local);
379
+ await this.authority(local);
380
+ return local;
381
+ }
382
+ catch {
383
+ await delay(100);
384
+ }
385
+ }
386
+ fail('launch', 'Owned local Ghidra server did not become ready; its storage and diagnostic stage are retained.');
387
+ }
388
+ catch (error) {
389
+ // Only this newly-created child handle can be cleaned without a ready identity probe.
390
+ if (child.exitCode === null && child.signalCode === null)
391
+ child.kill('SIGTERM');
392
+ for (let i = 0; i < 100 && child.exitCode === null && child.signalCode === null; i++)
393
+ await delay(50);
394
+ throw error;
395
+ }
396
+ }
397
+ async identity(local) {
398
+ const token = await this.c.secrets.require(local.tokenSecret);
399
+ const response = await this.c.fetch(`http://127.0.0.1:${local.identityPort}/identity`, { redirect: 'error',
400
+ signal: AbortSignal.timeout(1000), headers: { Authorization: 'Bearer ' + token } });
401
+ if (!response.ok || Number(response.headers.get('content-length')) > 4096)
402
+ fail('binding', 'Owned server identity was not verified.');
403
+ const reader = response.body?.getReader();
404
+ if (!reader)
405
+ fail('binding', 'Owned server identity has no body.');
406
+ const chunks = [];
407
+ let size = 0;
408
+ try {
409
+ for (;;) {
410
+ const next = await reader.read();
411
+ if (next.done)
412
+ break;
413
+ size += next.value.byteLength;
414
+ if (size > 4096)
415
+ fail('binding', 'Owned server identity exceeded its bound.');
416
+ chunks.push(next.value);
417
+ }
418
+ }
419
+ finally {
420
+ await reader.cancel().catch(() => { });
421
+ }
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
+ }
429
+ async stop() {
430
+ let local;
431
+ try {
432
+ local = JSON.parse(await readFile(join(this.root(), 'local.json'), 'utf8'));
433
+ }
434
+ catch (error) {
435
+ if (error.code === 'ENOENT')
436
+ return;
437
+ throw error;
438
+ }
439
+ if (!local.pid)
440
+ return;
441
+ if (live(local.pid)) {
442
+ await this.identity(local);
443
+ process.kill(local.pid, 'SIGTERM');
444
+ for (let i = 0; i < 100 && live(local.pid); i++)
445
+ await delay(50);
446
+ if (live(local.pid))
447
+ fail('busy', 'Owned server did not stop; its receipt is retained.');
448
+ }
449
+ local.phase = 'stopped';
450
+ delete local.pid;
451
+ await durable(join(this.root(), 'local.json'), local);
452
+ this.state.child = undefined;
453
+ this.state.initializing = undefined;
454
+ }
455
+ }
@@ -0,0 +1,33 @@
1
+ import { type Batch, type Binding, type FindingRecord, type State, type Target } from './model.js';
2
+ export declare class Store {
3
+ readonly directory: string;
4
+ readonly profile: string;
5
+ readonly server: string;
6
+ static pending(directory: string): boolean;
7
+ private db;
8
+ constructor(directory: string, profile: string, server: string);
9
+ close(): void;
10
+ private transaction;
11
+ register(value: unknown): Target;
12
+ target(id: string): Target;
13
+ targets(): Target[];
14
+ submit(value: unknown): FindingRecord;
15
+ getFinding(id: string): FindingRecord;
16
+ findings(targetId?: string): FindingRecord[];
17
+ begin(targetId: string): Batch | undefined;
18
+ batch(id: string): Batch;
19
+ batches(): Batch[];
20
+ update(id: string, state: State, patch?: Partial<Omit<Batch, 'id' | 'targetId' | 'findings'>>): Batch;
21
+ conflicts(id: string, conflicts: unknown): void;
22
+ private event;
23
+ events(id: string): {
24
+ seq: import("node:sqlite").SQLOutputValue | undefined;
25
+ state: import("node:sqlite").SQLOutputValue | undefined;
26
+ data: any;
27
+ }[];
28
+ worker(id: string): Binding;
29
+ putWorker(id: string, targetId: string, binding: Binding): void;
30
+ updateReader(id: string, binding: Binding): void;
31
+ workers(): any[];
32
+ removeWorker(id: string): void;
33
+ }
@@ -0,0 +1,178 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { mkdirSync, chmodSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { assertIdentity, digest, fail, finding, identity, stable } from './model.js';
6
+ export class Store {
7
+ directory;
8
+ profile;
9
+ server;
10
+ static pending(directory) {
11
+ const path = join(directory, 'coordinator.sqlite');
12
+ if (!existsSync(path))
13
+ return false;
14
+ const db = new DatabaseSync(path, { readOnly: true });
15
+ try {
16
+ return Number(db.prepare("SELECT COUNT(*) AS count FROM findings WHERE state != 'published_verified'").get()?.count) > 0;
17
+ }
18
+ finally {
19
+ db.close();
20
+ }
21
+ }
22
+ db;
23
+ constructor(directory, profile, server) {
24
+ this.directory = directory;
25
+ this.profile = profile;
26
+ this.server = server;
27
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
28
+ const path = join(directory, 'coordinator.sqlite');
29
+ this.db = new DatabaseSync(path);
30
+ chmodSync(path, 0o600);
31
+ this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;
32
+ CREATE TABLE IF NOT EXISTS targets(id TEXT PRIMARY KEY, lane TEXT NOT NULL UNIQUE, data TEXT NOT NULL);
33
+ CREATE TABLE IF NOT EXISTS findings(id TEXT PRIMARY KEY, request_id TEXT NOT NULL UNIQUE, hash TEXT NOT NULL, target_id TEXT NOT NULL REFERENCES targets(id), state TEXT NOT NULL, batch_id TEXT, data TEXT NOT NULL, conflict TEXT);
34
+ CREATE TABLE IF NOT EXISTS batches(id TEXT PRIMARY KEY, target_id TEXT NOT NULL REFERENCES targets(id), state TEXT NOT NULL, owner_pid INTEGER NOT NULL, data TEXT NOT NULL);
35
+ CREATE UNIQUE INDEX IF NOT EXISTS active_lane ON batches(target_id) WHERE state IN ('applying','staged','checking_in','publication_unknown','failed');
36
+ CREATE TABLE IF NOT EXISTS events(seq INTEGER PRIMARY KEY AUTOINCREMENT, batch_id TEXT NOT NULL, state TEXT NOT NULL, data TEXT NOT NULL);
37
+ CREATE TABLE IF NOT EXISTS workers(id TEXT PRIMARY KEY, target_id TEXT NOT NULL, data TEXT NOT NULL);
38
+ PRAGMA user_version=1;`);
39
+ }
40
+ close() { this.db.close(); }
41
+ transaction(action) {
42
+ this.db.exec('BEGIN IMMEDIATE');
43
+ try {
44
+ const result = action();
45
+ this.db.exec('COMMIT');
46
+ return result;
47
+ }
48
+ catch (error) {
49
+ this.db.exec('ROLLBACK');
50
+ throw error;
51
+ }
52
+ }
53
+ register(value) {
54
+ const selected = identity(value);
55
+ if (selected.profile !== this.profile || selected.server !== this.server)
56
+ fail('binding', 'Target must match the selected profile and server.');
57
+ const lane = digest([selected.profile, selected.server, selected.repository, selected.programPath]);
58
+ const target = { ...selected, id: 't-' + digest(selected).slice(0, 24), lane };
59
+ return this.transaction(() => {
60
+ const existing = this.db.prepare('SELECT data FROM targets WHERE lane=?').get(lane);
61
+ if (existing) {
62
+ const t = JSON.parse(String(existing.data));
63
+ assertIdentity(t, target);
64
+ return t;
65
+ }
66
+ this.db.prepare('INSERT INTO targets VALUES(?,?,?)').run(target.id, lane, stable(target));
67
+ return target;
68
+ });
69
+ }
70
+ target(id) {
71
+ const row = this.db.prepare('SELECT data FROM targets WHERE id=?').get(id);
72
+ if (!row)
73
+ fail('notFound', 'Target was not found in the selected profile.');
74
+ const target = JSON.parse(String(row.data));
75
+ if (target.profile !== this.profile || target.server !== this.server)
76
+ fail('binding', 'Registered target belongs to a different profile or server.');
77
+ return target;
78
+ }
79
+ targets() { return this.db.prepare('SELECT data FROM targets ORDER BY id LIMIT 100').all().map(r => this.target(JSON.parse(String(r.data)).id)); }
80
+ submit(value) {
81
+ const f = finding(value);
82
+ assertIdentity(f.identity, this.target(f.targetId));
83
+ return this.transaction(() => {
84
+ const previous = this.db.prepare('SELECT id,hash FROM findings WHERE request_id=? OR id=?').all(f.requestId, f.findingId);
85
+ if (previous.length) {
86
+ if (previous.length !== 1 || previous[0]?.hash !== digest(f))
87
+ fail('idempotency', 'Request or finding ID was already used with different content.');
88
+ return this.getFinding(String(previous[0].id));
89
+ }
90
+ const worker = this.worker(f.workerId);
91
+ assertIdentity(worker.identity, f.identity);
92
+ if (worker.role !== 'reader' || worker.version !== f.baselineVersion)
93
+ fail('binding', 'Finding does not match its registered reader baseline.');
94
+ if (f.resolves) {
95
+ const previous = this.getFinding(f.resolves);
96
+ if (previous.state !== 'conflict_pending' || previous.targetId !== f.targetId || previous.address !== f.address || previous.kind !== f.kind)
97
+ fail('conflict', 'Resolution must reference a conflicting finding for the same scoped comment.');
98
+ }
99
+ this.db.prepare('INSERT INTO findings(id,request_id,hash,target_id,state,data) VALUES(?,?,?,?,?,?)').run(f.findingId, f.requestId, digest(f), f.targetId, 'queued', stable(f));
100
+ return this.getFinding(f.findingId);
101
+ });
102
+ }
103
+ getFinding(id) {
104
+ const row = this.db.prepare('SELECT * FROM findings WHERE id=?').get(id);
105
+ if (!row)
106
+ fail('notFound', 'Finding was not found.');
107
+ return { ...JSON.parse(String(row.data)), state: row.state, ...(row.batch_id ? { batchId: row.batch_id } : {}), ...(row.conflict ? { conflict: JSON.parse(String(row.conflict)) } : {}) };
108
+ }
109
+ findings(targetId) {
110
+ if (targetId)
111
+ this.target(targetId);
112
+ const rows = targetId ? this.db.prepare('SELECT id FROM findings WHERE target_id=? ORDER BY rowid LIMIT 100').all(targetId) : this.db.prepare('SELECT id FROM findings ORDER BY rowid LIMIT 100').all();
113
+ return rows.map(r => this.getFinding(String(r.id)));
114
+ }
115
+ begin(targetId) {
116
+ this.target(targetId);
117
+ return this.transaction(() => {
118
+ const active = this.db.prepare("SELECT id FROM batches WHERE target_id=? AND state IN ('applying','staged','checking_in','publication_unknown','failed')").get(targetId);
119
+ if (active)
120
+ fail('blocked', 'Publication lane requires recovery before new integration.', this.batch(String(active.id)));
121
+ const rows = this.db.prepare("SELECT id FROM findings WHERE target_id=? AND state='queued' ORDER BY rowid LIMIT 100").all(targetId);
122
+ if (!rows.length)
123
+ return undefined;
124
+ const id = 'b-' + randomUUID();
125
+ const batch = { id, targetId, state: 'applying', ownerPid: process.pid, findings: rows.map(r => this.getFinding(String(r.id))) };
126
+ this.db.prepare('INSERT INTO batches VALUES(?,?,?,?,?)').run(id, targetId, batch.state, process.pid, stable({}));
127
+ for (const row of rows)
128
+ this.db.prepare("UPDATE findings SET state='applying',batch_id=? WHERE id=?").run(id, row.id);
129
+ this.event(id, 'applying', {});
130
+ return this.batch(id);
131
+ });
132
+ }
133
+ batch(id) {
134
+ const row = this.db.prepare('SELECT * FROM batches WHERE id=?').get(id);
135
+ if (!row)
136
+ fail('notFound', 'Publication batch was not found.');
137
+ this.target(String(row.target_id));
138
+ return { ...JSON.parse(String(row.data)), id: String(row.id), targetId: String(row.target_id), state: String(row.state), ownerPid: Number(row.owner_pid), findings: this.db.prepare('SELECT id FROM findings WHERE batch_id=? ORDER BY rowid').all(id).map(r => this.getFinding(String(r.id))) };
139
+ }
140
+ batches() { return this.db.prepare('SELECT id FROM batches ORDER BY rowid DESC LIMIT 100').all().map(r => this.batch(String(r.id))); }
141
+ update(id, state, patch = {}) {
142
+ return this.transaction(() => {
143
+ const current = this.batch(id);
144
+ const { findings: _findings, id: _id, targetId: _targetId, ...data } = { ...current, ...patch, state };
145
+ this.db.prepare('UPDATE batches SET state=?,data=? WHERE id=?').run(state, stable(data), id);
146
+ this.db.prepare('UPDATE findings SET state=? WHERE batch_id=?').run(state, id);
147
+ this.event(id, state, patch);
148
+ return this.batch(id);
149
+ });
150
+ }
151
+ conflicts(id, conflicts) {
152
+ this.db.prepare('UPDATE findings SET conflict=? WHERE batch_id=?').run(stable(conflicts), id);
153
+ }
154
+ event(id, state, data) { this.db.prepare('INSERT INTO events(batch_id,state,data) VALUES(?,?,?)').run(id, state, stable(data)); }
155
+ events(id) { return this.db.prepare('SELECT seq,state,data FROM events WHERE batch_id=? ORDER BY seq').all(id).map(r => ({ seq: r.seq, state: r.state, data: JSON.parse(String(r.data)) })); }
156
+ worker(id) {
157
+ const row = this.db.prepare('SELECT data,target_id FROM workers WHERE id=?').get(id);
158
+ if (!row)
159
+ fail('notFound', 'Worker was not found.');
160
+ this.target(String(row.target_id));
161
+ return JSON.parse(String(row.data));
162
+ }
163
+ putWorker(id, targetId, binding) {
164
+ assertIdentity(binding.identity, this.target(targetId));
165
+ if (this.db.prepare('SELECT id FROM workers WHERE id=?').get(id))
166
+ fail('workerExists', 'Worker ID is already registered.');
167
+ this.db.prepare('INSERT INTO workers VALUES(?,?,?)').run(id, targetId, stable(binding));
168
+ }
169
+ updateReader(id, binding) {
170
+ const previous = this.worker(id);
171
+ assertIdentity(binding.identity, previous.identity);
172
+ if (previous.pid !== binding.pid || previous.endpoint !== binding.endpoint || previous.version !== binding.version || previous.projectDirectory !== binding.projectDirectory || previous.role !== 'reader' || binding.role !== 'reader' || previous.readEpoch && previous.readEpoch !== binding.readEpoch)
173
+ fail('binding', 'Reader update changed its immutable binding.');
174
+ this.db.prepare('UPDATE workers SET data=? WHERE id=?').run(stable(binding), id);
175
+ }
176
+ workers() { return this.db.prepare('SELECT id,target_id,data FROM workers ORDER BY id LIMIT 100').all().map(r => ({ id: r.id, targetId: r.target_id, ...JSON.parse(String(r.data)) })); }
177
+ removeWorker(id) { this.db.prepare('DELETE FROM workers WHERE id=?').run(id); }
178
+ }