rea-agents 1.1.0 → 1.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/README.md +21 -2
- package/dist/application/ArtifactInventory.js +80 -44
- package/dist/application/CommandShimReplay.js +136 -0
- package/dist/application/EvidenceBundleFiles.js +9 -1
- package/dist/application/ProcessCaptureAuthority.js +25 -0
- package/dist/application/ProcessCaptureCapability.js +23 -0
- package/dist/application/ProcessCaptureError.js +5 -0
- package/dist/application/ProcessCaptureLifecycle.js +269 -0
- package/dist/application/ProcessCheckpoints.js +129 -0
- package/dist/application/ProcessCli.js +61 -0
- package/dist/application/ProcessEvidence.js +38 -0
- package/dist/application/ProcessHarness.js +252 -272
- package/dist/application/ProcessNormalization.js +10 -1
- package/dist/application/ProcessOwnership.js +39 -1
- package/dist/application/ProcessSampling.js +39 -23
- package/dist/application/Setup.js +51 -76
- package/dist/application/SetupSkill.js +44 -0
- package/dist/application/TerminalRenderer.js +92 -0
- package/dist/application/runtime.js +1 -1
- package/dist/artifacts/ArtifactProvider.js +18 -6
- package/dist/artifacts/ArtifactReader.js +3 -1
- package/dist/artifacts/AsarArtifactReader.js +8 -1
- package/dist/artifacts/DirectoryArtifactReader.js +1 -0
- package/dist/artifacts/MachOSliceArtifactReader.js +1 -0
- package/dist/artifacts/NativeDmgArtifactReader.js +151 -0
- package/dist/artifacts/ZipArtifactReader.js +1 -0
- package/dist/cli.js +29 -24
- package/dist/cliProcessCommands.js +19 -0
- package/dist/config.js +2 -0
- package/dist/contracts/artifactToolContracts.js +1 -0
- package/dist/contracts/investigationExamples.js +3 -3
- package/dist/contracts/processCaptureExample.js +50 -7
- package/dist/contracts/toolContracts.js +3 -2
- package/dist/domain/changedBehavior.js +2 -2
- package/dist/domain/errors.js +11 -3
- package/dist/domain/processCapture.js +99 -293
- package/dist/domain/processCaptureValidation.js +124 -0
- package/dist/domain/processComparison.js +176 -32
- package/dist/domain/processScenario.js +370 -0
- package/dist/domain/reconstructionVerification.js +3 -3
- package/dist/domain/staticRuntimeCorrelation.js +3 -2
- package/dist/identity.js +1 -0
- package/dist/server/registerProcessComparisonTool.js +28 -8
- package/dist/server/registerSessionTools.js +3 -25
- package/dist/server/sessionToolPolicies.js +1 -6
- package/package.json +5 -1
- package/scripts/prepare-node-pty.mjs +35 -0
- package/skills/rea-analysis/SKILL.md +19 -4
|
@@ -43,7 +43,13 @@ const systemHost = {
|
|
|
43
43
|
process.kill(-processGroupId, signal);
|
|
44
44
|
},
|
|
45
45
|
};
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Verify run-token ownership before signaling a POSIX process group.
|
|
48
|
+
*
|
|
49
|
+
* Process IDs and group IDs can be reused. REA therefore re-reads every live
|
|
50
|
+
* member's environment immediately before signaling and fails closed if any
|
|
51
|
+
* member cannot be inspected or lacks the per-capture run token.
|
|
52
|
+
*/
|
|
47
53
|
export const cleanupOwnedProcessGroup = async (ownership, host = systemHost) => {
|
|
48
54
|
let members;
|
|
49
55
|
try {
|
|
@@ -97,6 +103,38 @@ export const cleanupOwnedProcessGroup = async (ownership, host = systemHost) =>
|
|
|
97
103
|
}
|
|
98
104
|
return { cleaned: true, signaled: true };
|
|
99
105
|
};
|
|
106
|
+
/** Observe one group without signaling it, failing closed on identity doubt. */
|
|
107
|
+
export const observeOwnedProcessGroup = async (ownership, host = systemHost) => {
|
|
108
|
+
let members;
|
|
109
|
+
try {
|
|
110
|
+
members = await host.listMembers(ownership.processGroupId);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return {
|
|
114
|
+
state: "unverifiable",
|
|
115
|
+
reason: "process group could not be inspected",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (members.length === 0)
|
|
119
|
+
return { state: "empty" };
|
|
120
|
+
for (const member of members) {
|
|
121
|
+
try {
|
|
122
|
+
if ((await host.environment(member.pid)).REA_PROCESS_RUN_ID !==
|
|
123
|
+
ownership.runId)
|
|
124
|
+
return {
|
|
125
|
+
state: "unverifiable",
|
|
126
|
+
reason: "process ownership did not match",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return {
|
|
131
|
+
state: "unverifiable",
|
|
132
|
+
reason: "process ownership could not be revalidated",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { state: "alive" };
|
|
137
|
+
};
|
|
100
138
|
const commandMatches = (actual, expected) => {
|
|
101
139
|
const normalizedActual = actual.trim();
|
|
102
140
|
const normalizedExpected = expected.trim();
|
|
@@ -24,17 +24,25 @@ const parseProcStat = (identifier, stat) => {
|
|
|
24
24
|
.split(/\s+/);
|
|
25
25
|
const pid = Number(identifier);
|
|
26
26
|
const parentPid = Number(fields[1]);
|
|
27
|
+
const processGroupId = Number(fields[2]);
|
|
28
|
+
const sessionId = Number(fields[3]);
|
|
27
29
|
const startTime = fields[19];
|
|
28
30
|
if (!Number.isSafeInteger(pid) ||
|
|
29
31
|
pid <= 0 ||
|
|
30
32
|
!Number.isSafeInteger(parentPid) ||
|
|
31
33
|
parentPid < 0 ||
|
|
34
|
+
!Number.isSafeInteger(processGroupId) ||
|
|
35
|
+
processGroupId <= 0 ||
|
|
36
|
+
!Number.isSafeInteger(sessionId) ||
|
|
37
|
+
sessionId <= 0 ||
|
|
32
38
|
startTime === undefined ||
|
|
33
39
|
!/^\d+$/.test(startTime))
|
|
34
40
|
return undefined;
|
|
35
41
|
return {
|
|
36
42
|
pid,
|
|
37
43
|
parent_pid: parentPid,
|
|
44
|
+
process_group_id: processGroupId,
|
|
45
|
+
session_id: sessionId,
|
|
38
46
|
command: "",
|
|
39
47
|
startTime,
|
|
40
48
|
};
|
|
@@ -104,13 +112,15 @@ const inspectProcess = async (pid, expectedParent, signal, identities) => {
|
|
|
104
112
|
return {
|
|
105
113
|
pid: before.pid,
|
|
106
114
|
parent_pid: before.parent_pid,
|
|
115
|
+
process_group_id: before.process_group_id,
|
|
116
|
+
session_id: before.session_id,
|
|
107
117
|
startTime: before.startTime,
|
|
108
118
|
command,
|
|
109
119
|
children: children ?? [],
|
|
110
120
|
};
|
|
111
121
|
};
|
|
112
122
|
const sampleLinux = async (context) => {
|
|
113
|
-
const { rootPid, limit, signal,
|
|
123
|
+
const { rootPid, limit, signal, identities } = context;
|
|
114
124
|
if (limit <= 0)
|
|
115
125
|
return [];
|
|
116
126
|
const rootStat = await readProcessStat(rootPid, signal);
|
|
@@ -131,11 +141,9 @@ const sampleLinux = async (context) => {
|
|
|
131
141
|
for (const node of batchResults) {
|
|
132
142
|
if (node === undefined)
|
|
133
143
|
continue;
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
rows.push(node);
|
|
138
|
-
}
|
|
144
|
+
if (rows.length >= limit)
|
|
145
|
+
break;
|
|
146
|
+
rows.push(node);
|
|
139
147
|
if (rows.length >= limit)
|
|
140
148
|
break;
|
|
141
149
|
for (const child of node.children) {
|
|
@@ -152,15 +160,17 @@ const sampleLinux = async (context) => {
|
|
|
152
160
|
return rows;
|
|
153
161
|
};
|
|
154
162
|
const readPsRows = async (signal) => {
|
|
155
|
-
const { stdout } = await execFileAsync("ps", ["-axo", "pid=,ppid=,command="], { signal });
|
|
163
|
+
const { stdout } = await execFileAsync("ps", ["-axo", "pid=,ppid=,pgid=,sess=,command="], { signal });
|
|
156
164
|
return stdout
|
|
157
165
|
.split("\n")
|
|
158
|
-
.map((line) => /\s*(\d+)\s+(\d+)\s+(.*)/u.exec(line))
|
|
166
|
+
.map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(.*)/u.exec(line))
|
|
159
167
|
.filter((match) => match !== null)
|
|
160
168
|
.map((match) => ({
|
|
161
169
|
pid: Number(match[1]),
|
|
162
170
|
parent_pid: Number(match[2]),
|
|
163
|
-
|
|
171
|
+
process_group_id: Number(match[3]),
|
|
172
|
+
session_id: Number(match[4]),
|
|
173
|
+
command: match[5] ?? "",
|
|
164
174
|
startTime: undefined,
|
|
165
175
|
}))
|
|
166
176
|
.filter((row) => Number.isSafeInteger(row.pid) &&
|
|
@@ -170,7 +180,7 @@ const readPsRows = async (signal) => {
|
|
|
170
180
|
.sort((left, right) => left.pid - right.pid);
|
|
171
181
|
};
|
|
172
182
|
const samplePs = async (context) => {
|
|
173
|
-
const { rootPid, limit, signal
|
|
183
|
+
const { rootPid, limit, signal } = context;
|
|
174
184
|
if (limit <= 0)
|
|
175
185
|
return [];
|
|
176
186
|
const rows = await readPsRows(signal);
|
|
@@ -194,9 +204,8 @@ const samplePs = async (context) => {
|
|
|
194
204
|
continue;
|
|
195
205
|
visited.add(pid);
|
|
196
206
|
const row = rowByPid.get(pid);
|
|
197
|
-
if (row !== undefined
|
|
207
|
+
if (row !== undefined)
|
|
198
208
|
result.push(row);
|
|
199
|
-
}
|
|
200
209
|
if (result.length >= limit)
|
|
201
210
|
break;
|
|
202
211
|
for (const child of childrenByParent.get(pid) ?? []) {
|
|
@@ -212,11 +221,9 @@ const sampleProcesses = async (context) => {
|
|
|
212
221
|
const rows = process.platform === "linux"
|
|
213
222
|
? await sampleLinux(context)
|
|
214
223
|
: await samplePs(context);
|
|
215
|
-
const { elapsedMs,
|
|
224
|
+
const { elapsedMs, identities } = context;
|
|
216
225
|
const samples = [];
|
|
217
226
|
for (const row of rows) {
|
|
218
|
-
if (sampledPids.has(row.pid))
|
|
219
|
-
continue;
|
|
220
227
|
if (row.startTime !== undefined) {
|
|
221
228
|
const existing = identities.get(row.pid);
|
|
222
229
|
if (existing !== undefined && existing !== row.startTime)
|
|
@@ -228,6 +235,8 @@ const sampleProcesses = async (context) => {
|
|
|
228
235
|
pid: row.pid,
|
|
229
236
|
parent_pid: row.parent_pid,
|
|
230
237
|
command: row.command,
|
|
238
|
+
process_group_id: row.process_group_id,
|
|
239
|
+
session_id: row.session_id,
|
|
231
240
|
});
|
|
232
241
|
}
|
|
233
242
|
return samples;
|
|
@@ -235,31 +244,38 @@ const sampleProcesses = async (context) => {
|
|
|
235
244
|
/** Start bounded process-tree sampling and expose an awaited stop. */
|
|
236
245
|
export const startProcessSampler = (rootPid, started, limit, samples) => {
|
|
237
246
|
const identities = new Map();
|
|
238
|
-
const
|
|
247
|
+
const lastObservations = new Map();
|
|
239
248
|
let pending;
|
|
240
249
|
let stopped = false;
|
|
241
250
|
let abortCurrent;
|
|
242
251
|
let partial = false;
|
|
243
252
|
const sample = () => {
|
|
244
|
-
if (stopped || pending !== undefined
|
|
253
|
+
if (stopped || pending !== undefined)
|
|
245
254
|
return;
|
|
246
255
|
const controller = new AbortController();
|
|
247
256
|
abortCurrent = () => controller.abort();
|
|
248
257
|
pending = sampleProcesses({
|
|
249
258
|
rootPid,
|
|
250
259
|
elapsedMs: Date.now() - started,
|
|
251
|
-
limit: limit
|
|
252
|
-
sampledPids,
|
|
260
|
+
limit: limit + 1,
|
|
253
261
|
identities,
|
|
254
262
|
signal: controller.signal,
|
|
255
263
|
})
|
|
256
264
|
.then((values) => {
|
|
257
265
|
for (const value of values) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
266
|
+
const observation = JSON.stringify({
|
|
267
|
+
parent_pid: value.parent_pid,
|
|
268
|
+
command: value.command,
|
|
269
|
+
process_group_id: value.process_group_id,
|
|
270
|
+
session_id: value.session_id,
|
|
271
|
+
});
|
|
272
|
+
if (lastObservations.get(value.pid) === observation)
|
|
273
|
+
continue;
|
|
274
|
+
if (samples.length >= limit) {
|
|
275
|
+
partial = true;
|
|
261
276
|
continue;
|
|
262
|
-
|
|
277
|
+
}
|
|
278
|
+
lastObservations.set(value.pid, observation);
|
|
263
279
|
samples.push(value);
|
|
264
280
|
}
|
|
265
281
|
})
|
|
@@ -9,9 +9,50 @@ import { supportsNodeVersion } from "../domain/runtimeVersion.js";
|
|
|
9
9
|
import { runDoctor, systemDoctorHost } from "./Doctor.js";
|
|
10
10
|
import { installLinuxHopper, readLinuxDistribution, } from "./LinuxHopper.js";
|
|
11
11
|
import { installMacHopper } from "./MacHopper.js";
|
|
12
|
+
import { installCanonicalSkill } from "./SetupSkill.js";
|
|
13
|
+
export { installCanonicalSkill } from "./SetupSkill.js";
|
|
12
14
|
const registrationCommand = () => process.env.npm_command === "exec"
|
|
13
15
|
? ["npx", "-y", PRODUCT_IDENTITY.packageName, "mcp"]
|
|
14
16
|
: [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
|
|
17
|
+
const setupPlan = (platform, hopperPath, clients) => [
|
|
18
|
+
...(hopperPath === undefined
|
|
19
|
+
? [
|
|
20
|
+
{
|
|
21
|
+
kind: "install_hopper",
|
|
22
|
+
target: platform === "darwin"
|
|
23
|
+
? "~/Applications/Hopper Disassembler.app"
|
|
24
|
+
: "system package manager",
|
|
25
|
+
detail: "Download the official Hopper package, verify it, install it, and open Hopper for activation.",
|
|
26
|
+
external: true,
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
: []),
|
|
30
|
+
...clients
|
|
31
|
+
.filter(({ format }) => format !== "unsupported")
|
|
32
|
+
.map((client) => ({
|
|
33
|
+
kind: "configure_client",
|
|
34
|
+
target: client.configPath,
|
|
35
|
+
detail: `Add the REA MCP registration for ${client.name}; preserve unrelated configuration.`,
|
|
36
|
+
external: false,
|
|
37
|
+
})),
|
|
38
|
+
{
|
|
39
|
+
kind: "install_skill",
|
|
40
|
+
target: "~/.agents/skills/rea-analysis/SKILL.md",
|
|
41
|
+
detail: "Install or update the bundled REA analysis skill.",
|
|
42
|
+
external: false,
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
const configureDetectedClients = async (options) => {
|
|
46
|
+
for (const client of options.detectedClients) {
|
|
47
|
+
const result = await options.host.configureClient(client, options.hopperPath, registrationCommand());
|
|
48
|
+
options.clients[client.name] = result;
|
|
49
|
+
if (result.status === "failed")
|
|
50
|
+
return `${client.name} configuration ${result.reason} verification failed; no successful configuration was reported.`;
|
|
51
|
+
if (result.status === "configured")
|
|
52
|
+
options.appliedActions.push(`configured_${client.name}`);
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
};
|
|
15
56
|
/**
|
|
16
57
|
* Install prerequisites and configure detected clients idempotently.
|
|
17
58
|
* Discovery always precedes mutation. Interactive confirmation or explicit
|
|
@@ -34,34 +75,7 @@ export const runSetup = async (options, host = systemSetupHost(), confirm) => {
|
|
|
34
75
|
return fail(unsupported);
|
|
35
76
|
let hopperPath = await host.hopperPath();
|
|
36
77
|
const detectedClients = await host.detectedClients();
|
|
37
|
-
plannedActions =
|
|
38
|
-
...(hopperPath === undefined
|
|
39
|
-
? [
|
|
40
|
-
{
|
|
41
|
-
kind: "install_hopper",
|
|
42
|
-
target: host.platform === "darwin"
|
|
43
|
-
? "~/Applications/Hopper Disassembler.app"
|
|
44
|
-
: "system package manager",
|
|
45
|
-
detail: "Download the official Hopper package, verify it, install it, and open Hopper for activation.",
|
|
46
|
-
external: true,
|
|
47
|
-
},
|
|
48
|
-
]
|
|
49
|
-
: []),
|
|
50
|
-
...detectedClients
|
|
51
|
-
.filter(({ format }) => format !== "unsupported")
|
|
52
|
-
.map((client) => ({
|
|
53
|
-
kind: "configure_client",
|
|
54
|
-
target: client.configPath,
|
|
55
|
-
detail: `Add the REA MCP registration for ${client.name}; preserve unrelated configuration.`,
|
|
56
|
-
external: false,
|
|
57
|
-
})),
|
|
58
|
-
{
|
|
59
|
-
kind: "install_skill",
|
|
60
|
-
target: "~/.agents/skills/rea-analysis/SKILL.md",
|
|
61
|
-
detail: "Install or update the bundled REA analysis skill.",
|
|
62
|
-
external: false,
|
|
63
|
-
},
|
|
64
|
-
];
|
|
78
|
+
plannedActions = setupPlan(host.platform, hopperPath, detectedClients);
|
|
65
79
|
let approved = options.approved;
|
|
66
80
|
let interactiveApproval = false;
|
|
67
81
|
if (!approved && confirm !== undefined && !options.structured) {
|
|
@@ -91,14 +105,15 @@ export const runSetup = async (options, host = systemSetupHost(), confirm) => {
|
|
|
91
105
|
};
|
|
92
106
|
appliedActions.push("installed_hopper");
|
|
93
107
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
108
|
+
const clientFailure = await configureDetectedClients({
|
|
109
|
+
host,
|
|
110
|
+
detectedClients,
|
|
111
|
+
hopperPath,
|
|
112
|
+
clients,
|
|
113
|
+
appliedActions,
|
|
114
|
+
});
|
|
115
|
+
if (clientFailure !== undefined)
|
|
116
|
+
return fail(clientFailure);
|
|
102
117
|
const skill = await host.installSkill();
|
|
103
118
|
if (skill === "failed")
|
|
104
119
|
return fail("Agent skill installation or readback failed.");
|
|
@@ -353,46 +368,6 @@ export const configureTomlClient = async (client, hopperPath, command = [
|
|
|
353
368
|
...(backupPath === undefined ? {} : { backupPath }),
|
|
354
369
|
};
|
|
355
370
|
};
|
|
356
|
-
/** Transactionally install or upgrade the versioned canonical REA skill. */
|
|
357
|
-
export const installCanonicalSkill = async (home) => {
|
|
358
|
-
const destination = join(home, ".agents/skills", PRODUCT_IDENTITY.skillName, "SKILL.md");
|
|
359
|
-
const backup = `${destination}.rea.backup`;
|
|
360
|
-
let original;
|
|
361
|
-
try {
|
|
362
|
-
const content = await readFile(new URL(`../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`, import.meta.url), "utf8");
|
|
363
|
-
original = await readFile(destination, "utf8").catch(() => undefined);
|
|
364
|
-
if (original === content)
|
|
365
|
-
return "unchanged";
|
|
366
|
-
await mkdir(dirname(destination), { recursive: true });
|
|
367
|
-
if (original !== undefined)
|
|
368
|
-
await writeFileAtomic(backup, original, {
|
|
369
|
-
encoding: "utf8",
|
|
370
|
-
mode: 0o600,
|
|
371
|
-
});
|
|
372
|
-
await writeFileAtomic(destination, content, {
|
|
373
|
-
encoding: "utf8",
|
|
374
|
-
mode: 0o600,
|
|
375
|
-
});
|
|
376
|
-
if ((await readFile(destination, "utf8")) !== content)
|
|
377
|
-
throw new Error("skill readback mismatch");
|
|
378
|
-
return "installed";
|
|
379
|
-
}
|
|
380
|
-
catch {
|
|
381
|
-
try {
|
|
382
|
-
if (original === undefined)
|
|
383
|
-
await rm(destination, { force: true });
|
|
384
|
-
else
|
|
385
|
-
await writeFileAtomic(destination, original, {
|
|
386
|
-
encoding: "utf8",
|
|
387
|
-
mode: 0o600,
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
catch {
|
|
391
|
-
// The backup remains beside the skill for explicit operator recovery.
|
|
392
|
-
}
|
|
393
|
-
return "failed";
|
|
394
|
-
}
|
|
395
|
-
};
|
|
396
371
|
const major = (version) => Number.parseInt(version.split(".")[0] ?? "0", 10);
|
|
397
372
|
const exists = async (path) => {
|
|
398
373
|
try {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import writeFileAtomic from "write-file-atomic";
|
|
4
|
+
import { PRODUCT_IDENTITY } from "../identity.js";
|
|
5
|
+
/** Transactionally install or upgrade the versioned canonical REA skill. */
|
|
6
|
+
export const installCanonicalSkill = async (home) => {
|
|
7
|
+
const destination = join(home, ".agents/skills", PRODUCT_IDENTITY.skillName, "SKILL.md");
|
|
8
|
+
const backup = `${destination}.rea.backup`;
|
|
9
|
+
let original;
|
|
10
|
+
try {
|
|
11
|
+
const content = await readFile(new URL(`../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`, import.meta.url), "utf8");
|
|
12
|
+
original = await readFile(destination, "utf8").catch(() => undefined);
|
|
13
|
+
if (original === content)
|
|
14
|
+
return "unchanged";
|
|
15
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
16
|
+
if (original !== undefined)
|
|
17
|
+
await writeFileAtomic(backup, original, {
|
|
18
|
+
encoding: "utf8",
|
|
19
|
+
mode: 0o600,
|
|
20
|
+
});
|
|
21
|
+
await writeFileAtomic(destination, content, {
|
|
22
|
+
encoding: "utf8",
|
|
23
|
+
mode: 0o600,
|
|
24
|
+
});
|
|
25
|
+
if ((await readFile(destination, "utf8")) !== content)
|
|
26
|
+
throw new Error("skill readback mismatch");
|
|
27
|
+
return "installed";
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
try {
|
|
31
|
+
if (original === undefined)
|
|
32
|
+
await rm(destination, { force: true });
|
|
33
|
+
else
|
|
34
|
+
await writeFileAtomic(destination, original, {
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
mode: 0o600,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// The backup remains beside the skill for explicit operator recovery.
|
|
41
|
+
}
|
|
42
|
+
return "failed";
|
|
43
|
+
}
|
|
44
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
// SAFETY: both pinned xterm packages publish CommonJS at runtime and matching declarations.
|
|
4
|
+
const HeadlessPackage = require("@xterm/headless");
|
|
5
|
+
// SAFETY: the addon package is pinned with the compatible headless xterm release.
|
|
6
|
+
const SerializePackage = require("@xterm/addon-serialize");
|
|
7
|
+
/** Owns one headless terminal and serializes writes into deterministic frames. */
|
|
8
|
+
/**
|
|
9
|
+
* Reconstructs bounded terminal states while raw PTY chunks remain authoritative.
|
|
10
|
+
*
|
|
11
|
+
* Rendered frames answer what an operator saw after control-sequence handling;
|
|
12
|
+
* raw frames preserve byte/chunk differences that can render identically.
|
|
13
|
+
* Comparisons retain both because neither representation subsumes the other.
|
|
14
|
+
*/
|
|
15
|
+
export class TerminalRenderer {
|
|
16
|
+
options;
|
|
17
|
+
#terminal;
|
|
18
|
+
#serializeAddon = new SerializePackage.SerializeAddon();
|
|
19
|
+
#frames = [];
|
|
20
|
+
#pending = Promise.resolve();
|
|
21
|
+
#capturedBytes = 0;
|
|
22
|
+
#truncated = false;
|
|
23
|
+
constructor(options) {
|
|
24
|
+
this.options = options;
|
|
25
|
+
this.#terminal = new HeadlessPackage.Terminal({
|
|
26
|
+
allowProposedApi: true,
|
|
27
|
+
cols: options.columns,
|
|
28
|
+
rows: options.rows,
|
|
29
|
+
scrollback: options.scrollback,
|
|
30
|
+
});
|
|
31
|
+
this.#terminal.loadAddon(this.#serializeAddon);
|
|
32
|
+
}
|
|
33
|
+
/** Queue one PTY chunk and capture state only after xterm has parsed it. */
|
|
34
|
+
write(data, atMs) {
|
|
35
|
+
this.#pending = this.#pending.then(() => new Promise((resolveWrite) => {
|
|
36
|
+
this.#terminal.write(data, () => {
|
|
37
|
+
this.#capture(atMs);
|
|
38
|
+
resolveWrite();
|
|
39
|
+
});
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
/** Queue a terminal resize after every preceding write. */
|
|
43
|
+
resize(columns, rows, atMs) {
|
|
44
|
+
this.#pending = this.#pending.then(() => {
|
|
45
|
+
this.#terminal.resize(columns, rows);
|
|
46
|
+
this.#capture(atMs);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** Await all queued parsing and return immutable rendered observations. */
|
|
50
|
+
async frames() {
|
|
51
|
+
await this.#pending;
|
|
52
|
+
return this.#frames;
|
|
53
|
+
}
|
|
54
|
+
/** Whether a rendered observation exceeded its independent capture budget. */
|
|
55
|
+
truncated() {
|
|
56
|
+
return this.#truncated;
|
|
57
|
+
}
|
|
58
|
+
/** Release addon and terminal resources after all writes settle. */
|
|
59
|
+
async dispose() {
|
|
60
|
+
await this.#pending;
|
|
61
|
+
this.#serializeAddon.dispose();
|
|
62
|
+
this.#terminal.dispose();
|
|
63
|
+
}
|
|
64
|
+
#capture(atMs) {
|
|
65
|
+
const buffer = this.#terminal.buffer.active;
|
|
66
|
+
const lines = [];
|
|
67
|
+
for (let row = 0; row < this.#terminal.rows; row += 1) {
|
|
68
|
+
const line = buffer.getLine(buffer.viewportY + row);
|
|
69
|
+
lines.push(this.options.normalize((line?.translateToString(false, 0, this.#terminal.cols) ?? "").padEnd(this.#terminal.cols, " ")));
|
|
70
|
+
}
|
|
71
|
+
const serializedState = this.options.normalize(this.#serializeAddon.serialize());
|
|
72
|
+
const bytes = Buffer.byteLength(serializedState) +
|
|
73
|
+
lines.reduce((total, line) => total + Buffer.byteLength(line), 0);
|
|
74
|
+
if (this.#frames.length >= this.options.maxFrames ||
|
|
75
|
+
this.#capturedBytes + bytes > this.options.maxBytes) {
|
|
76
|
+
this.#truncated = true;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.#capturedBytes += bytes;
|
|
80
|
+
this.#frames.push({
|
|
81
|
+
sequence: this.#frames.length,
|
|
82
|
+
at_ms: atMs,
|
|
83
|
+
columns: this.#terminal.cols,
|
|
84
|
+
rows: this.#terminal.rows,
|
|
85
|
+
cursor_x: buffer.cursorX,
|
|
86
|
+
cursor_y: buffer.cursorY,
|
|
87
|
+
active_buffer: buffer.type,
|
|
88
|
+
lines,
|
|
89
|
+
serialized_state: serializedState,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -11,7 +11,7 @@ import { silentLogger } from "../logger.js";
|
|
|
11
11
|
*/
|
|
12
12
|
export const createBinarySession = (config, logger = silentLogger) => {
|
|
13
13
|
return new BinarySession(new CompositeProvider([
|
|
14
|
-
new ArtifactProvider(),
|
|
14
|
+
new ArtifactProvider(config.artifactNativeMountEnabled),
|
|
15
15
|
new NativeMacOSProvider(),
|
|
16
16
|
new HopperProvider(config, logger),
|
|
17
17
|
]));
|
|
@@ -12,6 +12,10 @@ const IDENTITY = Object.freeze({
|
|
|
12
12
|
});
|
|
13
13
|
/** Read-only inventory and exclusively owned extraction provider. */
|
|
14
14
|
export class ArtifactProvider {
|
|
15
|
+
nativeMountEnabled;
|
|
16
|
+
constructor(nativeMountEnabled = false) {
|
|
17
|
+
this.nativeMountEnabled = nativeMountEnabled;
|
|
18
|
+
}
|
|
15
19
|
#capabilities = Object.freeze(ARTIFACT_TOOL_CONTRACTS.map((contract) => Object.freeze({
|
|
16
20
|
provider: IDENTITY,
|
|
17
21
|
operation: contract.name,
|
|
@@ -36,8 +40,8 @@ export class ArtifactProvider {
|
|
|
36
40
|
timeoutMs: 120_000,
|
|
37
41
|
}),
|
|
38
42
|
limitations: Object.freeze([
|
|
39
|
-
"DMG
|
|
40
|
-
"
|
|
43
|
+
"DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.",
|
|
44
|
+
"ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only.",
|
|
41
45
|
]),
|
|
42
46
|
})));
|
|
43
47
|
identity() {
|
|
@@ -47,13 +51,15 @@ export class ArtifactProvider {
|
|
|
47
51
|
return this.#capabilities;
|
|
48
52
|
}
|
|
49
53
|
createClient(target) {
|
|
50
|
-
return new ArtifactClient(target);
|
|
54
|
+
return new ArtifactClient(target, this.nativeMountEnabled);
|
|
51
55
|
}
|
|
52
56
|
}
|
|
53
57
|
class ArtifactClient {
|
|
54
58
|
target;
|
|
55
|
-
|
|
59
|
+
nativeMountEnabled;
|
|
60
|
+
constructor(target, nativeMountEnabled) {
|
|
56
61
|
this.target = target;
|
|
62
|
+
this.nativeMountEnabled = nativeMountEnabled;
|
|
57
63
|
}
|
|
58
64
|
async execute(operation, parameters, options) {
|
|
59
65
|
if (operation === "health")
|
|
@@ -90,7 +96,13 @@ class ArtifactClient {
|
|
|
90
96
|
occurrenceLimit: parsed.occurrence_limit,
|
|
91
97
|
edgeOffset: parsed.edge_offset,
|
|
92
98
|
edgeLimit: parsed.edge_limit,
|
|
93
|
-
},
|
|
99
|
+
}, {
|
|
100
|
+
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
|
101
|
+
nativeMount: {
|
|
102
|
+
nativeMountApproved: parsed.native_mount_approved === true,
|
|
103
|
+
nativeMountEnabled: this.nativeMountEnabled,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
94
106
|
return ok(createAnalysisExecution(result, IDENTITY, {
|
|
95
107
|
rawResult: null,
|
|
96
108
|
limitations: result.limitations,
|
|
@@ -120,7 +132,7 @@ const limitsFrom = (input) => ({
|
|
|
120
132
|
const isArtifactOperation = (operation) => ARTIFACT_TOOL_CONTRACTS.some(({ name }) => name === operation);
|
|
121
133
|
const translateFailure = (operation, cause) => {
|
|
122
134
|
if (cause instanceof ArtifactReaderFailure)
|
|
123
|
-
return new ArtifactOperationError(operation, cause.reason);
|
|
135
|
+
return new ArtifactOperationError(operation, cause.reason, cause.details);
|
|
124
136
|
return new ArtifactOperationError(operation, "io");
|
|
125
137
|
};
|
|
126
138
|
const subjectFor = (path, manifest) => ({
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/** Typed adapter failure translated at provider boundary. */
|
|
2
2
|
export class ArtifactReaderFailure extends Error {
|
|
3
3
|
reason;
|
|
4
|
-
|
|
4
|
+
details;
|
|
5
|
+
constructor(reason, message, options, details) {
|
|
5
6
|
super(message, options);
|
|
6
7
|
this.reason = reason;
|
|
8
|
+
this.details = details;
|
|
7
9
|
this.name = "ArtifactReaderFailure";
|
|
8
10
|
}
|
|
9
11
|
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { Readable } from "node:stream";
|
|
2
2
|
import { extractFile, listPackage, statFile } from "@electron/asar";
|
|
3
3
|
import { ArtifactReaderFailure, } from "./ArtifactReader.js";
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Official Electron ASAR adapter. Individual files remain caller-bounded.
|
|
6
|
+
*
|
|
7
|
+
* An entry marked `unpacked` is metadata, not an integrity exemption: Electron
|
|
8
|
+
* stores its bytes beside the archive in `<archive>.unpacked`, and callers must
|
|
9
|
+
* hash those companion bytes against the archive's declared integrity value.
|
|
10
|
+
*/
|
|
5
11
|
export class AsarArtifactReader {
|
|
6
12
|
path;
|
|
7
13
|
format = "asar";
|
|
@@ -34,6 +40,7 @@ export class AsarArtifactReader {
|
|
|
34
40
|
/^[a-f0-9]{64}$/u.test(metadata.integrity.hash)
|
|
35
41
|
? metadata.integrity.hash
|
|
36
42
|
: null,
|
|
43
|
+
unpacked: "unpacked" in metadata && metadata.unpacked === true,
|
|
37
44
|
limitations: kind === "symlink"
|
|
38
45
|
? ["ASAR symlink target was not followed or disclosed."]
|
|
39
46
|
: [
|