@nocoo/eagle-agent 0.6.0 → 0.7.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 +12 -4
- package/dist/agent/realtime.js +50 -7
- package/dist/package.json +1 -1
- package/dist/src/shared/realtime.js +6 -1
- package/dist/src/shared/terminal.js +193 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,16 +6,16 @@ Read-only Herdr inventory, task evidence, machine resources and named TCP port c
|
|
|
6
6
|
|
|
7
7
|
Check `node --version`, `npm --version` and `herdr --version` first. Install Node.js 24+ from https://nodejs.org/en/download if needed, and have Herdr installed and running. No Eagle repository checkout, TypeScript compiler or npm login is required to install this public package.
|
|
8
8
|
|
|
9
|
-
Install Agent v0.
|
|
9
|
+
Install Agent v0.7.0 from npm. The Agent and Eagle website share the same release version:
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
npm install -g @nocoo/eagle-agent@0.
|
|
12
|
+
npm install -g @nocoo/eagle-agent@0.7.0 --registry=https://registry.npmjs.org
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
**If npm downloads time out, use the Tencent Cloud mirror / 下载超时时首选腾讯云镜像:**
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
|
-
npm install -g @nocoo/eagle-agent@0.
|
|
18
|
+
npm install -g @nocoo/eagle-agent@0.7.0 --registry=https://mirrors.cloud.tencent.com/npm/
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
`--registry` applies only to this installation; it does not change your global npm configuration. Mirrors may lag (`404` / `ETARGET`); retry later or use the official registry when reachable. Keep the pinned version, HTTPS and certificate verification. Eagle credentials are unrelated to npm and must never be sent to a registry.
|
|
@@ -23,7 +23,7 @@ npm install -g @nocoo/eagle-agent@0.6.0 --registry=https://mirrors.cloud.tencent
|
|
|
23
23
|
Verify the installation before configuring the agent:
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
|
-
eagle-agent --version # expected: 0.
|
|
26
|
+
eagle-agent --version # expected: 0.7.0
|
|
27
27
|
eagle-agent --help
|
|
28
28
|
```
|
|
29
29
|
|
|
@@ -31,6 +31,14 @@ If the command is missing, ensure npm's global `bin` directory is on your PATH.
|
|
|
31
31
|
|
|
32
32
|
## Connect a machine
|
|
33
33
|
|
|
34
|
+
When an Agent is already configured, check the service's `EAGLE_CONFIG`, machine
|
|
35
|
+
ID **and URL origin** first. Only a matching ID and origin permits token-only
|
|
36
|
+
rotation. Moving to another deployment requires a separate private config
|
|
37
|
+
directory and explicit new `spoolDir`; preserve, but do not replay, the old queue,
|
|
38
|
+
cache or Manager state. Verify the new destination before switching the service,
|
|
39
|
+
and set its `EAGLE_CONFIG` explicitly. See `docs/AGENT.md` in the repository for
|
|
40
|
+
the rotation/migration procedure. Do not overwrite a different machine identity.
|
|
41
|
+
|
|
34
42
|
Open Eagle's **Connect** page, create a machine and copy its onboarding prompt. Give the prompt to the management agent on that machine. It contains a machine-scoped credential shown only once.
|
|
35
43
|
|
|
36
44
|
Store configuration at `~/.config/eagle/agent.json` (directory 0700, file 0600). `EAGLE_CONFIG` selects another file. `eagle-agent init` reads JSON from stdin and creates this file securely; it refuses to overwrite an existing file. Never pass a token in command arguments, commit it, print it, or include it in reports. Rotation requires updating the token in the existing secure configuration.
|
package/dist/agent/realtime.js
CHANGED
|
@@ -5,6 +5,7 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import WebSocket from "ws";
|
|
7
7
|
import { AgentMessageSchema, BridgeMessageSchema, TopologySchema, } from "../src/shared/realtime.js";
|
|
8
|
+
import { parseTerminalScreen, restyleRedactedText, } from "../src/shared/terminal.js";
|
|
8
9
|
import { checkUrl, normalizeSnapshot, redact, } from "./collector.js";
|
|
9
10
|
import { submitTerminalInput } from "./terminal-input.js";
|
|
10
11
|
export function redactScreen(value, secrets) {
|
|
@@ -40,6 +41,44 @@ export function redactScreen(value, secrets) {
|
|
|
40
41
|
.join("")
|
|
41
42
|
.replace(/\*{8,}/g, "[REDACTED]");
|
|
42
43
|
}
|
|
44
|
+
export function terminalScreen(value, secrets) {
|
|
45
|
+
const parsed = parseTerminalScreen(value);
|
|
46
|
+
const redacted = redactScreen(parsed.text, secrets);
|
|
47
|
+
const text = redacted.slice(-32000);
|
|
48
|
+
// Rebuild runs from already redacted characters, copying only style metadata.
|
|
49
|
+
// The schema also enforces that joined runs equal the safe plain text.
|
|
50
|
+
const styled = restyleRedactedText(parsed.runs, redacted);
|
|
51
|
+
let skip = Math.max(0, redacted.length - 32000);
|
|
52
|
+
const runs = styled.flatMap((run) => {
|
|
53
|
+
const cut = Math.min(skip, run.text.length);
|
|
54
|
+
skip -= cut;
|
|
55
|
+
return cut === run.text.length
|
|
56
|
+
? []
|
|
57
|
+
: [{ ...run, text: run.text.slice(cut) }];
|
|
58
|
+
});
|
|
59
|
+
// Keep the newest visible styles first; simplify older styling rather than
|
|
60
|
+
// dropping all colors because one screen exceeds the run/byte budget.
|
|
61
|
+
let keep = Math.min(runs.length, 512);
|
|
62
|
+
while (keep > 0) {
|
|
63
|
+
const bounded = keep === runs.length
|
|
64
|
+
? runs
|
|
65
|
+
: [
|
|
66
|
+
{
|
|
67
|
+
text: runs
|
|
68
|
+
.slice(0, runs.length - keep + 1)
|
|
69
|
+
.map((run) => run.text)
|
|
70
|
+
.join(""),
|
|
71
|
+
},
|
|
72
|
+
...runs.slice(runs.length - keep + 1),
|
|
73
|
+
];
|
|
74
|
+
if (!bounded.some((run) => Object.keys(run).length > 1))
|
|
75
|
+
break;
|
|
76
|
+
if (new TextEncoder().encode(JSON.stringify(bounded)).length <= 64000)
|
|
77
|
+
return { text, runs: bounded };
|
|
78
|
+
keep = Math.floor(keep / 2);
|
|
79
|
+
}
|
|
80
|
+
return { text };
|
|
81
|
+
}
|
|
43
82
|
export function socketRequest(path, method, params, signal) {
|
|
44
83
|
return new Promise((resolve, reject) => {
|
|
45
84
|
if (signal.aborted) {
|
|
@@ -95,6 +134,7 @@ export function socketRequest(path, method, params, signal) {
|
|
|
95
134
|
});
|
|
96
135
|
}
|
|
97
136
|
export class LiveBridge {
|
|
137
|
+
styledFrames = false;
|
|
98
138
|
watches = new Map();
|
|
99
139
|
secrets;
|
|
100
140
|
send;
|
|
@@ -110,6 +150,7 @@ export class LiveBridge {
|
|
|
110
150
|
throw new Error("Invalid relay message");
|
|
111
151
|
const message = parsed.data;
|
|
112
152
|
if (message.type === "subscriptions") {
|
|
153
|
+
this.styledFrames = message.format === "styled-text-v1";
|
|
113
154
|
const wanted = new Map(message.spaces.map((s) => [s.spaceId, s]));
|
|
114
155
|
for (const [key, watch] of this.watches)
|
|
115
156
|
if (wanted.get(key)?.subscriptionId !== watch.subscription.subscriptionId) {
|
|
@@ -232,8 +273,8 @@ export class LiveBridge {
|
|
|
232
273
|
const result = await socketRequest(watch.path ?? "", "pane.read", {
|
|
233
274
|
pane_id: p.id,
|
|
234
275
|
source: "visible",
|
|
235
|
-
format: "
|
|
236
|
-
strip_ansi:
|
|
276
|
+
format: "ansi",
|
|
277
|
+
strip_ansi: false,
|
|
237
278
|
}, signal);
|
|
238
279
|
const read = result.read;
|
|
239
280
|
const original = raw.panes.find((r) => r.pane_id === p.id);
|
|
@@ -243,14 +284,14 @@ export class LiveBridge {
|
|
|
243
284
|
return;
|
|
244
285
|
pending.push({
|
|
245
286
|
pane: p,
|
|
246
|
-
|
|
287
|
+
screen: terminalScreen(read.text, this.secrets),
|
|
247
288
|
tab: read.tab_id,
|
|
248
289
|
});
|
|
249
290
|
}));
|
|
250
291
|
const after = (await socketRequest(watch.path, "session.snapshot", {}, signal)).snapshot;
|
|
251
292
|
if (signal.aborted)
|
|
252
293
|
return;
|
|
253
|
-
for (const { pane: p,
|
|
294
|
+
for (const { pane: p, screen, tab } of pending) {
|
|
254
295
|
const current = after.panes.find((r) => r.pane_id === p.id &&
|
|
255
296
|
r.terminal_id === p.terminalId &&
|
|
256
297
|
r.tab_id === tab &&
|
|
@@ -259,7 +300,8 @@ export class LiveBridge {
|
|
|
259
300
|
watch.force = true;
|
|
260
301
|
continue;
|
|
261
302
|
}
|
|
262
|
-
|
|
303
|
+
const fingerprint = JSON.stringify(screen);
|
|
304
|
+
if (!force && watch.screens.get(p.terminalId) === fingerprint)
|
|
263
305
|
continue;
|
|
264
306
|
this.send(AgentMessageSchema.parse({
|
|
265
307
|
type: "frame",
|
|
@@ -267,10 +309,10 @@ export class LiveBridge {
|
|
|
267
309
|
paneId: p.id,
|
|
268
310
|
terminalId: p.terminalId,
|
|
269
311
|
revision: ++watch.frameSequence,
|
|
270
|
-
text,
|
|
312
|
+
...(this.styledFrames ? screen : { text: screen.text }),
|
|
271
313
|
observedAt: new Date().toISOString(),
|
|
272
314
|
}));
|
|
273
|
-
watch.screens.set(p.terminalId,
|
|
315
|
+
watch.screens.set(p.terminalId, fingerprint);
|
|
274
316
|
}
|
|
275
317
|
for (const key of watch.screens.keys())
|
|
276
318
|
if (!panes.some((p) => p.terminalId === key))
|
|
@@ -305,6 +347,7 @@ export async function realtimeWatch(config, signal) {
|
|
|
305
347
|
headers: {
|
|
306
348
|
Authorization: `Bearer ${config.token}`,
|
|
307
349
|
"X-Eagle-Machine": config.machineId,
|
|
350
|
+
"X-Eagle-Realtime-Format": "styled-text-v1",
|
|
308
351
|
},
|
|
309
352
|
handshakeTimeout: 10000,
|
|
310
353
|
maxPayload: 262144,
|
package/dist/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { TerminalRunSchema } from "./terminal.js";
|
|
2
3
|
const id = z.string().min(1).max(160);
|
|
3
4
|
export const SubscriptionSchema = z.strictObject({
|
|
4
5
|
spaceId: id,
|
|
@@ -62,10 +63,13 @@ export const FrameSchema = SubscriptionSchema.extend({
|
|
|
62
63
|
terminalId: id,
|
|
63
64
|
revision: z.number().int().nonnegative(),
|
|
64
65
|
text: z.string().max(32000),
|
|
66
|
+
runs: z.array(TerminalRunSchema).max(512).optional(),
|
|
65
67
|
observedAt: z.string().datetime(),
|
|
66
68
|
deliveryId: z.number().int().positive().optional(),
|
|
67
69
|
deliveryBytes: z.number().int().positive().optional(),
|
|
68
|
-
})
|
|
70
|
+
}).refine((v) => !v.runs ||
|
|
71
|
+
(v.runs.map((r) => r.text).join("") === v.text &&
|
|
72
|
+
new TextEncoder().encode(JSON.stringify(v.runs)).length <= 64000), "Styled runs must match the redacted text and stay within the transport budget");
|
|
69
73
|
export const AckSchema = z.strictObject({
|
|
70
74
|
type: z.literal("ack"),
|
|
71
75
|
clientId: id,
|
|
@@ -83,6 +87,7 @@ export const BridgeMessageSchema = z.union([
|
|
|
83
87
|
z.strictObject({
|
|
84
88
|
type: z.literal("subscriptions"),
|
|
85
89
|
spaces: z.array(SubscriptionSchema).max(4),
|
|
90
|
+
format: z.literal("styled-text-v1").optional(),
|
|
86
91
|
}),
|
|
87
92
|
InputSchema.safeExtend({ ...SubscriptionSchema.shape, clientId: id }),
|
|
88
93
|
z.strictObject({ type: z.literal("pong") }),
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const channel = z.number().int().min(0).max(255);
|
|
3
|
+
export const TerminalColorSchema = z.union([
|
|
4
|
+
channel,
|
|
5
|
+
z.tuple([channel, channel, channel]),
|
|
6
|
+
]);
|
|
7
|
+
export const TerminalRunSchema = z.strictObject({
|
|
8
|
+
text: z.string().min(1).max(32000),
|
|
9
|
+
fg: TerminalColorSchema.optional(),
|
|
10
|
+
bg: TerminalColorSchema.optional(),
|
|
11
|
+
bold: z.literal(true).optional(),
|
|
12
|
+
dim: z.literal(true).optional(),
|
|
13
|
+
italic: z.literal(true).optional(),
|
|
14
|
+
underline: z.literal(true).optional(),
|
|
15
|
+
inverse: z.literal(true).optional(),
|
|
16
|
+
});
|
|
17
|
+
/** Reuse styles only. Every emitted character comes from the redacted string. */
|
|
18
|
+
export function restyleRedactedText(original, redacted) {
|
|
19
|
+
let boundary = 0;
|
|
20
|
+
const source = original.map((run) => {
|
|
21
|
+
const start = boundary;
|
|
22
|
+
boundary += run.text.length;
|
|
23
|
+
const { text: _text, ...style } = run;
|
|
24
|
+
return { start, end: boundary, style };
|
|
25
|
+
});
|
|
26
|
+
const plain = original.map((run) => run.text).join("");
|
|
27
|
+
const output = [];
|
|
28
|
+
let cursor = 0, runIndex = 0;
|
|
29
|
+
const append = (text, style = {}) => {
|
|
30
|
+
if (!text)
|
|
31
|
+
return;
|
|
32
|
+
const previous = output.at(-1);
|
|
33
|
+
const { text: _text, ...previousStyle } = previous ?? { text: "" };
|
|
34
|
+
if (previous && JSON.stringify(previousStyle) === JSON.stringify(style))
|
|
35
|
+
previous.text += text;
|
|
36
|
+
else
|
|
37
|
+
output.push({ ...style, text });
|
|
38
|
+
};
|
|
39
|
+
if (plain === redacted) {
|
|
40
|
+
for (let i = 0; i < original.length; i++)
|
|
41
|
+
append(original[i].text, source[i].style);
|
|
42
|
+
return output;
|
|
43
|
+
}
|
|
44
|
+
// Replacement markers have neutral styling. Exact surviving segments may
|
|
45
|
+
// borrow source styling, but never source text (even for ambiguous matches).
|
|
46
|
+
for (const part of redacted.split(/(\[REDACTED(?: KEY)?\]|\*+)/g)) {
|
|
47
|
+
if (!part)
|
|
48
|
+
continue;
|
|
49
|
+
if (/^(?:\[REDACTED(?: KEY)?\]|\*+)$/.test(part)) {
|
|
50
|
+
append(part);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const start = plain.indexOf(part, cursor);
|
|
54
|
+
if (start < 0) {
|
|
55
|
+
append(part);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
let at = start;
|
|
59
|
+
const end = start + part.length;
|
|
60
|
+
while (at < end) {
|
|
61
|
+
while (runIndex < source.length && source[runIndex].end <= at)
|
|
62
|
+
runIndex++;
|
|
63
|
+
const run = source[runIndex];
|
|
64
|
+
const next = Math.min(end, run?.end ?? end);
|
|
65
|
+
append(part.slice(at - start, next - start), run?.style);
|
|
66
|
+
at = next;
|
|
67
|
+
}
|
|
68
|
+
cursor = end;
|
|
69
|
+
}
|
|
70
|
+
return output;
|
|
71
|
+
}
|
|
72
|
+
/** Parse styling only: OSC, cursor commands and all executable controls are discarded. */
|
|
73
|
+
export function parseTerminalScreen(value) {
|
|
74
|
+
let style = {};
|
|
75
|
+
let hidden = false;
|
|
76
|
+
const runs = [];
|
|
77
|
+
const append = (text) => {
|
|
78
|
+
const clean = text.replace(
|
|
79
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Remove terminal control bytes, preserving tabs and line breaks.
|
|
80
|
+
/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
|
|
81
|
+
if (clean)
|
|
82
|
+
runs.push({
|
|
83
|
+
...style,
|
|
84
|
+
text: hidden ? clean.replace(/[^\n\t]/g, "•") : clean,
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
// Unterminated OSC/DCS strings are discarded through the end, never rendered as links/HTML.
|
|
88
|
+
const escapes =
|
|
89
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Recognize ANSI escapes in untrusted terminal text.
|
|
90
|
+
/\u001b(?:\][\s\S]*?(?:\u0007|\u001b\\|$)|[PX^_][\s\S]*?(?:\u001b\\|$)|\[[0-?]*[ -/]*[@-~]|[ -/]*[@-~]|$)/g;
|
|
91
|
+
let offset = 0;
|
|
92
|
+
for (const match of value.matchAll(escapes)) {
|
|
93
|
+
append(value.slice(offset, match.index));
|
|
94
|
+
offset = match.index + match[0].length;
|
|
95
|
+
const code = match[0];
|
|
96
|
+
if (!code.startsWith("\u001b[") || !code.endsWith("m"))
|
|
97
|
+
continue;
|
|
98
|
+
const raw = code.slice(2, -1);
|
|
99
|
+
if (!/^[\d;]*$/.test(raw))
|
|
100
|
+
continue;
|
|
101
|
+
const params = raw.split(";").map(Number);
|
|
102
|
+
for (let i = 0; i < params.length; i++) {
|
|
103
|
+
const p = params[i];
|
|
104
|
+
if (p === 0) {
|
|
105
|
+
style = {};
|
|
106
|
+
hidden = false;
|
|
107
|
+
}
|
|
108
|
+
else if (p === 1)
|
|
109
|
+
style.bold = true;
|
|
110
|
+
else if (p === 2)
|
|
111
|
+
style.dim = true;
|
|
112
|
+
else if (p === 3)
|
|
113
|
+
style.italic = true;
|
|
114
|
+
else if (p === 4)
|
|
115
|
+
style.underline = true;
|
|
116
|
+
else if (p === 7)
|
|
117
|
+
style.inverse = true;
|
|
118
|
+
else if (p === 8)
|
|
119
|
+
hidden = true;
|
|
120
|
+
else if (p === 22) {
|
|
121
|
+
delete style.bold;
|
|
122
|
+
delete style.dim;
|
|
123
|
+
}
|
|
124
|
+
else if (p === 23)
|
|
125
|
+
delete style.italic;
|
|
126
|
+
else if (p === 24)
|
|
127
|
+
delete style.underline;
|
|
128
|
+
else if (p === 27)
|
|
129
|
+
delete style.inverse;
|
|
130
|
+
else if (p === 28)
|
|
131
|
+
hidden = false;
|
|
132
|
+
else if (p === 39)
|
|
133
|
+
delete style.fg;
|
|
134
|
+
else if (p === 49)
|
|
135
|
+
delete style.bg;
|
|
136
|
+
else if (p >= 30 && p <= 37)
|
|
137
|
+
style.fg = p - 30;
|
|
138
|
+
else if (p >= 90 && p <= 97)
|
|
139
|
+
style.fg = p - 90 + 8;
|
|
140
|
+
else if (p >= 40 && p <= 47)
|
|
141
|
+
style.bg = p - 40;
|
|
142
|
+
else if (p >= 100 && p <= 107)
|
|
143
|
+
style.bg = p - 100 + 8;
|
|
144
|
+
else if (p === 38 || p === 48) {
|
|
145
|
+
const field = p === 38 ? "fg" : "bg";
|
|
146
|
+
const mode = params[++i];
|
|
147
|
+
const size = mode === 5 ? 1 : mode === 2 ? 3 : 0;
|
|
148
|
+
const values = params.slice(i + 1, i + 1 + size);
|
|
149
|
+
if (size &&
|
|
150
|
+
values.length === size &&
|
|
151
|
+
values.every((v) => v >= 0 && v <= 255))
|
|
152
|
+
style[field] =
|
|
153
|
+
size === 1 ? values[0] : values;
|
|
154
|
+
i += size;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
append(value.slice(offset));
|
|
159
|
+
return { text: runs.map((r) => r.text).join(""), runs };
|
|
160
|
+
}
|
|
161
|
+
/** Standard terminal palette; only validated integers can reach CSS. */
|
|
162
|
+
export function terminalColor(color) {
|
|
163
|
+
if (color === undefined)
|
|
164
|
+
return undefined;
|
|
165
|
+
if (Array.isArray(color))
|
|
166
|
+
return `rgb(${color.join(", ")})`;
|
|
167
|
+
const basic = [
|
|
168
|
+
"#000000",
|
|
169
|
+
"#cd3131",
|
|
170
|
+
"#0dbc79",
|
|
171
|
+
"#e5e510",
|
|
172
|
+
"#2472c8",
|
|
173
|
+
"#bc3fbc",
|
|
174
|
+
"#11a8cd",
|
|
175
|
+
"#e5e5e5",
|
|
176
|
+
"#666666",
|
|
177
|
+
"#f14c4c",
|
|
178
|
+
"#23d18b",
|
|
179
|
+
"#f5f543",
|
|
180
|
+
"#3b8eea",
|
|
181
|
+
"#d670d6",
|
|
182
|
+
"#29b8db",
|
|
183
|
+
"#ffffff",
|
|
184
|
+
];
|
|
185
|
+
if (color < 16)
|
|
186
|
+
return `var(--terminal-color-${color}, ${basic[color]})`;
|
|
187
|
+
if (color >= 232)
|
|
188
|
+
return `rgb(${Array(3)
|
|
189
|
+
.fill(8 + (color - 232) * 10)
|
|
190
|
+
.join(", ")})`;
|
|
191
|
+
const n = color - 16, levels = [0, 95, 135, 175, 215, 255];
|
|
192
|
+
return `rgb(${[levels[Math.floor(n / 36)], levels[Math.floor(n / 6) % 6], levels[n % 6]].join(", ")})`;
|
|
193
|
+
}
|