local-agent-dashboard 0.0.0-stage → 0.3.2
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 +13 -2
- package/native/qr +0 -0
- package/package.json +30 -4
- package/public/app.js +110 -0
- package/public/index.html +8 -0
- package/public/style.css +7 -0
- package/src/adapters/agents/codex-input.js +55 -0
- package/src/adapters/agents/log-collector.js +282 -0
- package/src/adapters/agents/log-state.js +165 -0
- package/src/adapters/agents/native-unread.js +45 -0
- package/src/application/pairing.js +83 -0
- package/src/application/snapshots.js +40 -0
- package/src/cli.js +54 -0
- package/src/collector.js +2 -0
- package/src/composition/main.js +165 -0
- package/src/domain/sessions.js +31 -0
- package/src/infrastructure/certificates.js +52 -0
- package/src/infrastructure/pairing-link.js +3 -0
- package/src/infrastructure/qr.js +22 -0
- package/src/infrastructure/runtime-root.js +6 -0
- package/src/server.js +74 -0
- package/src/state.js +2 -0
- package/src/transport/lan/server.js +98 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
const object = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
3
|
+
const text = (value) => typeof value === 'string' ? value : '';
|
|
4
|
+
const cleanLabel = (value, fallback) => text(value).replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 120) || fallback;
|
|
5
|
+
const time = (value, now) => {
|
|
6
|
+
const at = typeof value === 'string' ? Date.parse(value) : NaN;
|
|
7
|
+
return Number.isFinite(at) && at <= now + 60_000 ? at : 0;
|
|
8
|
+
};
|
|
9
|
+
function finish(task) {
|
|
10
|
+
task.reviewKey = `${task.provider}:${task.id}:${task.turnId}:${task.status}:${task.updatedAt}`;
|
|
11
|
+
return task;
|
|
12
|
+
}
|
|
13
|
+
function base(id, provider, now) {
|
|
14
|
+
return { id, provider, title: `${provider === 'codex' ? 'Codex' : 'Claude'} · ${id.slice(-8)}`, project: '未知项目', status: 'unknown', evidence: '缺少明确生命周期事件', updatedAt: 0, observedAt: now, turnId: '', reviewKey: '' };
|
|
15
|
+
}
|
|
16
|
+
// A terminal record is authoritative; command exits/retry diagnostics are not.
|
|
17
|
+
// The collector copies only this boolean, never the raw error message/details.
|
|
18
|
+
export function codexTerminalFailure(payload) {
|
|
19
|
+
const error = object(payload.error);
|
|
20
|
+
return payload.terminalFailure === true || Object.keys(error).length > 0 || typeof payload.error === 'string' && payload.error.trim().length > 0;
|
|
21
|
+
}
|
|
22
|
+
export function projectCodex(records, filename, now = Date.now(), previous) {
|
|
23
|
+
const metadata = object(records.find(r => object(r).type === 'session_meta'));
|
|
24
|
+
const meta = object(metadata.payload);
|
|
25
|
+
// Fail closed: without the header we cannot distinguish child agents from user threads.
|
|
26
|
+
if (!Object.keys(meta).length || meta.parent_thread_id || typeof meta.source === 'object' ||
|
|
27
|
+
(meta.thread_source && !['user', 'agent_created_thread', 'automation'].includes(meta.thread_source)))
|
|
28
|
+
return null;
|
|
29
|
+
const id = text(meta.id) || text(meta.session_id) || basename(filename, '.jsonl');
|
|
30
|
+
const task = previous?.id === id ? { ...previous, observedAt: now } : base(id, 'codex', now);
|
|
31
|
+
task.applicationId = meta.originator === 'Codex Desktop' ? 'codex-desktop' : 'codex-unidentified';
|
|
32
|
+
task.project = cleanLabel(basename(text(meta.cwd)), '未知项目');
|
|
33
|
+
const pendingInputs = new Set(previous?.pendingInputIds ?? []);
|
|
34
|
+
let sawLifecycle = !!previous && previous.status !== 'unknown';
|
|
35
|
+
for (const value of records) {
|
|
36
|
+
const r = object(value), p = object(r.payload), at = time(r.timestamp, now);
|
|
37
|
+
if (!at)
|
|
38
|
+
continue;
|
|
39
|
+
const type = text(p.type);
|
|
40
|
+
if (r.type === 'event_msg' && type === 'task_started') {
|
|
41
|
+
if (at < task.updatedAt && text(p.turn_id) !== task.turnId)
|
|
42
|
+
continue;
|
|
43
|
+
pendingInputs.clear();
|
|
44
|
+
task.status = 'active';
|
|
45
|
+
task.turnId = text(p.turn_id) || String(at);
|
|
46
|
+
task.evidence = 'task_started';
|
|
47
|
+
sawLifecycle = true;
|
|
48
|
+
}
|
|
49
|
+
else if (r.type === 'event_msg' && (type === 'task_complete' || type === 'turn_aborted')) {
|
|
50
|
+
if (p.turn_id && task.turnId && p.turn_id !== task.turnId)
|
|
51
|
+
continue;
|
|
52
|
+
task.turnId ||= text(p.turn_id) || String(at);
|
|
53
|
+
pendingInputs.clear();
|
|
54
|
+
task.status = type === 'task_complete' ? codexTerminalFailure(p) ? 'failed' : 'ended' : 'interrupted';
|
|
55
|
+
task.evidence = task.status === 'failed' ? 'task_complete with explicit error' : type;
|
|
56
|
+
sawLifecycle = true;
|
|
57
|
+
}
|
|
58
|
+
if (r.type === 'event_msg' && ['input_requested', 'input_resolved'].includes(type) && at >= task.updatedAt && ['active', 'attention'].includes(task.status)) {
|
|
59
|
+
if (type === 'input_requested' && pendingInputs.size < 8)
|
|
60
|
+
pendingInputs.add(text(p.call_id));
|
|
61
|
+
else if (type === 'input_resolved')
|
|
62
|
+
pendingInputs.delete(text(p.call_id));
|
|
63
|
+
task.status = pendingInputs.size ? 'attention' : 'active';
|
|
64
|
+
task.evidence = pendingInputs.size ? 'explicit request_user_input pending' : 'input reply observed';
|
|
65
|
+
task.updatedAt = at;
|
|
66
|
+
}
|
|
67
|
+
if (sawLifecycle && task.status === 'active')
|
|
68
|
+
task.updatedAt = Math.max(task.updatedAt, at);
|
|
69
|
+
else if (r.type === 'event_msg' && ['task_complete', 'turn_aborted'].includes(type))
|
|
70
|
+
task.updatedAt = Math.max(task.updatedAt, at);
|
|
71
|
+
}
|
|
72
|
+
task.pendingInputIds = [...pendingInputs];
|
|
73
|
+
return finish(task);
|
|
74
|
+
}
|
|
75
|
+
export function projectClaude(records, filename, now = Date.now(), previous) {
|
|
76
|
+
const first = records.map(object).find(r => r.sessionId || r.session_id);
|
|
77
|
+
if (first?.isSidechain)
|
|
78
|
+
return previous ?? null;
|
|
79
|
+
if (!first && !previous)
|
|
80
|
+
return null;
|
|
81
|
+
const task = previous ? { ...previous, observedAt: now } : base(text(first.sessionId) || text(first.session_id) || basename(filename, '.jsonl'), 'claude', now);
|
|
82
|
+
task.applicationId = 'claude-code';
|
|
83
|
+
const questions = new Set(previous?.pendingQuestions ?? []);
|
|
84
|
+
let lifecycle = !!previous;
|
|
85
|
+
for (const value of records) {
|
|
86
|
+
const r = object(value);
|
|
87
|
+
if (r.isSidechain)
|
|
88
|
+
continue;
|
|
89
|
+
if (r.cwd)
|
|
90
|
+
task.project = cleanLabel(basename(text(r.cwd)), task.project);
|
|
91
|
+
if (r.type === 'ai-title' && text(r.aiTitle).trim()) {
|
|
92
|
+
task.title = cleanLabel(r.aiTitle, task.title);
|
|
93
|
+
task.titleOrigin = 'native';
|
|
94
|
+
}
|
|
95
|
+
const at = time(r.timestamp, now);
|
|
96
|
+
if (!at)
|
|
97
|
+
continue;
|
|
98
|
+
const m = object(r.message);
|
|
99
|
+
const content = Array.isArray(m.content) ? m.content.map(object) : [];
|
|
100
|
+
if (r.type === 'user') {
|
|
101
|
+
const results = content.filter(c => c.type === 'tool_result');
|
|
102
|
+
if (results.length) {
|
|
103
|
+
for (const c of results)
|
|
104
|
+
questions.delete(text(c.tool_use_id));
|
|
105
|
+
if (task.status === 'attention' && !questions.size) {
|
|
106
|
+
task.status = 'active';
|
|
107
|
+
task.evidence = '问题已收到对应 tool_result';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else if (!r.isMeta && (typeof m.content === 'string' || content.some(c => c.type === 'text'))) {
|
|
111
|
+
// Claude may never append ai-title (not merely append it late). Retain only
|
|
112
|
+
// a bounded first ordinary user line, never commands/attachments or bodies.
|
|
113
|
+
if (!task.titleOrigin) {
|
|
114
|
+
const prompt = typeof m.content === 'string' ? m.content : text(content.find(c => c.type === 'text')?.text);
|
|
115
|
+
const line = prompt.trim().split(/\r?\n/, 1)[0] ?? '';
|
|
116
|
+
if (line && !/^[</]/.test(line)) {
|
|
117
|
+
task.title = cleanLabel(line, task.title);
|
|
118
|
+
task.titleOrigin = 'prompt';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
task.turnId = text(r.uuid) || text(r.promptId) || String(at);
|
|
122
|
+
task.status = 'active';
|
|
123
|
+
task.evidence = 'user message';
|
|
124
|
+
questions.clear();
|
|
125
|
+
lifecycle = true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else if (r.type === 'assistant') {
|
|
129
|
+
lifecycle = true;
|
|
130
|
+
task.turnId ||= text(r.parentUuid) || String(at);
|
|
131
|
+
for (const c of content) {
|
|
132
|
+
if (c.type === 'tool_use' && ['AskUserQuestion', 'ExitPlanMode'].includes(text(c.name)))
|
|
133
|
+
questions.add(text(c.id));
|
|
134
|
+
}
|
|
135
|
+
if (questions.size) {
|
|
136
|
+
task.status = 'attention';
|
|
137
|
+
task.evidence = '等待交互工具的对应结果';
|
|
138
|
+
}
|
|
139
|
+
else if (m.stop_reason === 'end_turn') {
|
|
140
|
+
task.status = 'ended';
|
|
141
|
+
task.evidence = 'assistant stop_reason=end_turn';
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
task.status = 'active';
|
|
145
|
+
task.evidence = 'assistant / tool_use';
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else if (r.type === 'system' && r.subtype === 'turn_duration' && !questions.size) {
|
|
149
|
+
task.status = 'ended';
|
|
150
|
+
task.evidence = 'system turn_duration';
|
|
151
|
+
lifecycle = true;
|
|
152
|
+
}
|
|
153
|
+
if (lifecycle && (['user', 'assistant'].includes(r.type) || (r.type === 'system' && r.subtype === 'turn_duration')))
|
|
154
|
+
task.updatedAt = Math.max(task.updatedAt, at);
|
|
155
|
+
}
|
|
156
|
+
task.pendingQuestions = [...questions];
|
|
157
|
+
return finish(task);
|
|
158
|
+
}
|
|
159
|
+
export function ageTask(task, now = Date.now()) {
|
|
160
|
+
const age = now - task.updatedAt;
|
|
161
|
+
if ((task.status === 'active' && age > 5 * 60_000) || (task.status === 'attention' && age > 30 * 60_000)) {
|
|
162
|
+
return { ...task, status: 'unknown', evidence: `上次事件:${task.evidence};当前状态待确认` };
|
|
163
|
+
}
|
|
164
|
+
return task;
|
|
165
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
4
|
+
const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
5
|
+
export function parseNativeUnread(value) {
|
|
6
|
+
const state = record(record(value)?.['electron-thread-read-state-v1']);
|
|
7
|
+
if (state?.version !== 1)
|
|
8
|
+
return null;
|
|
9
|
+
const identities = record(state.unreadByIdentity);
|
|
10
|
+
if (!identities || Object.keys(identities).length !== 1)
|
|
11
|
+
return null;
|
|
12
|
+
const [identity] = Object.keys(identities);
|
|
13
|
+
if (!/^[a-f\d]{64}$/.test(identity))
|
|
14
|
+
return null;
|
|
15
|
+
const hosts = record(identities[identity]);
|
|
16
|
+
if (!hosts || Object.keys(hosts).length !== 1)
|
|
17
|
+
return null;
|
|
18
|
+
const [host] = Object.keys(hosts);
|
|
19
|
+
if (!/^local:[a-f\d]{64}$/.test(host))
|
|
20
|
+
return null;
|
|
21
|
+
const ids = hosts[host];
|
|
22
|
+
if (!Array.isArray(ids) || ids.length > 10000 || ids.some(id => typeof id !== 'string' || !/^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(id)))
|
|
23
|
+
return null;
|
|
24
|
+
return new Set(ids);
|
|
25
|
+
}
|
|
26
|
+
export async function readNativeUnread(file) {
|
|
27
|
+
try {
|
|
28
|
+
const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
29
|
+
try {
|
|
30
|
+
const info = await handle.stat();
|
|
31
|
+
if (!info.isFile() || info.size > MAX_BYTES)
|
|
32
|
+
return null;
|
|
33
|
+
const buffer = Buffer.alloc(info.size + 1), result = await handle.read(buffer, 0, buffer.length, 0);
|
|
34
|
+
if (result.bytesRead !== info.size)
|
|
35
|
+
return null;
|
|
36
|
+
return parseNativeUnread(JSON.parse(buffer.subarray(0, result.bytesRead).toString('utf8')));
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
await handle.close();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
const secret = () => randomBytes(32).toString('base64url');
|
|
3
|
+
export const hash = (s) => createHash('sha256').update(s).digest('hex');
|
|
4
|
+
const equal = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
5
|
+
export class ProtocolError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
code;
|
|
8
|
+
constructor(status, code) { super(code); this.status = status; this.code = code; }
|
|
9
|
+
}
|
|
10
|
+
export class Pairing {
|
|
11
|
+
invite;
|
|
12
|
+
requests = new Map();
|
|
13
|
+
devices = new Map();
|
|
14
|
+
revokeListeners = new Set();
|
|
15
|
+
now;
|
|
16
|
+
onRevoke;
|
|
17
|
+
constructor(now = Date.now, onRevoke = () => { }) { this.now = now; this.onRevoke = onRevoke; }
|
|
18
|
+
createInvite() {
|
|
19
|
+
this.requests.clear();
|
|
20
|
+
return this.invite = { secret: secret(), expiresAt: this.now() + 120_000, consumed: false };
|
|
21
|
+
}
|
|
22
|
+
request(invitation, challenge, name) {
|
|
23
|
+
const i = this.invite;
|
|
24
|
+
if (!i || i.consumed || i.expiresAt <= this.now() || !equal(hash(invitation), hash(i.secret)))
|
|
25
|
+
throw new ProtocolError(410, 'invite_expired');
|
|
26
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(challenge) || !name.trim() || name.length > 60 || /[\x00-\x1f]/.test(name))
|
|
27
|
+
throw new ProtocolError(400, 'invalid_request');
|
|
28
|
+
const challengeHash = hash(challenge);
|
|
29
|
+
const previous = [...this.requests.values()].find(r => r.challengeHash === challengeHash);
|
|
30
|
+
if (previous)
|
|
31
|
+
return this.publicRequest(previous);
|
|
32
|
+
if ([...this.requests.values()].filter(r => r.state === 'pending').length >= 8)
|
|
33
|
+
throw new ProtocolError(429, 'pairing_busy');
|
|
34
|
+
const id = randomUUID(), code = hash(`${id}:${challenge}`).slice(0, 8).toUpperCase();
|
|
35
|
+
const r = { id, name, challengeHash, code, expiresAt: i.expiresAt, state: 'pending' };
|
|
36
|
+
this.requests.set(id, r);
|
|
37
|
+
return this.publicRequest(r);
|
|
38
|
+
}
|
|
39
|
+
publicRequest(r) { return { requestId: r.id, name: r.name, code: r.code, expiresAt: r.expiresAt, state: r.state }; }
|
|
40
|
+
approve(id, code) {
|
|
41
|
+
const r = this.requests.get(id);
|
|
42
|
+
if (!r || r.code !== code || r.expiresAt <= this.now() || r.state !== 'pending' || this.invite?.consumed)
|
|
43
|
+
throw new ProtocolError(409, 'request_changed');
|
|
44
|
+
r.state = 'approved';
|
|
45
|
+
for (const other of this.requests.values())
|
|
46
|
+
if (other.id !== id)
|
|
47
|
+
other.state = 'rejected';
|
|
48
|
+
}
|
|
49
|
+
claim(id, challenge) {
|
|
50
|
+
const r = this.requests.get(id);
|
|
51
|
+
if (!r || !equal(hash(challenge), r.challengeHash))
|
|
52
|
+
throw new ProtocolError(403, 'challenge_rejected');
|
|
53
|
+
if (r.expiresAt <= this.now() || this.invite?.consumed || ['claimed', 'rejected'].includes(r.state))
|
|
54
|
+
throw new ProtocolError(410, 'pairing_expired');
|
|
55
|
+
if (r.state === 'pending')
|
|
56
|
+
return { state: 'pending' };
|
|
57
|
+
if (this.devices.size >= 64)
|
|
58
|
+
throw new ProtocolError(429, 'device_limit');
|
|
59
|
+
const token = secret(), deviceId = randomUUID();
|
|
60
|
+
this.devices.set(deviceId, { id: deviceId, name: r.name, tokenHash: hash(token), createdAt: this.now(), revoked: false });
|
|
61
|
+
r.state = 'claimed';
|
|
62
|
+
this.invite.consumed = true;
|
|
63
|
+
return { state: 'paired', deviceId, token };
|
|
64
|
+
}
|
|
65
|
+
authenticate(token) {
|
|
66
|
+
const d = [...this.devices.values()].find(d => !d.revoked && equal(d.tokenHash, hash(token)));
|
|
67
|
+
if (!d)
|
|
68
|
+
throw new ProtocolError(401, 'repair_required');
|
|
69
|
+
return d;
|
|
70
|
+
}
|
|
71
|
+
subscribeRevoke(listener) { this.revokeListeners.add(listener); return () => this.revokeListeners.delete(listener); }
|
|
72
|
+
revoke(id) { const d = this.devices.get(id); if (!d)
|
|
73
|
+
throw new ProtocolError(404, 'unknown_device'); d.revoked = true; this.onRevoke(id); for (const listener of this.revokeListeners)
|
|
74
|
+
listener(id); }
|
|
75
|
+
ack(d, instanceId, revision) {
|
|
76
|
+
if (d.revoked)
|
|
77
|
+
throw new ProtocolError(401, 'repair_required');
|
|
78
|
+
if (!d.issued || instanceId !== d.issued.instanceId || revision !== d.issued.revision || (d.ack?.instanceId === instanceId && d.ack.revision > revision))
|
|
79
|
+
throw new ProtocolError(409, 'snapshot_mismatch');
|
|
80
|
+
d.ack = { instanceId, revision, receivedAt: this.now() };
|
|
81
|
+
}
|
|
82
|
+
status() { return { requests: [...this.requests.values()].filter(r => r.expiresAt > this.now()).map(r => this.publicRequest(r)), devices: [...this.devices.values()].map(d => ({ id: d.id, name: d.name, revoked: d.revoked, createdAt: d.createdAt, ack: d.ack, online: !!d.ack && this.now() - d.ack.receivedAt < 15000 })) }; }
|
|
83
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
export class Snapshots {
|
|
3
|
+
instanceId = randomUUID();
|
|
4
|
+
revision = 0;
|
|
5
|
+
signature = '';
|
|
6
|
+
inFlight = null;
|
|
7
|
+
last;
|
|
8
|
+
collect;
|
|
9
|
+
constructor(collect) { this.collect = collect; }
|
|
10
|
+
async get() {
|
|
11
|
+
if (this.last && Date.now() - this.last.generatedAt < 1500)
|
|
12
|
+
return this.last;
|
|
13
|
+
return this.inFlight ??= this.build().finally(() => { this.inFlight = null; });
|
|
14
|
+
}
|
|
15
|
+
async build() {
|
|
16
|
+
const raw = await this.collect();
|
|
17
|
+
const rows = raw.tasks.filter(t => t.provider !== 'codex' || t.applicationId === 'codex-desktop' || raw.mode === 'demo').map(t => ({
|
|
18
|
+
key: createHash('sha256').update(`${t.provider}:${t.id}`).digest('hex').slice(0, 32),
|
|
19
|
+
applicationId: t.applicationId ?? (t.provider === 'codex' ? 'codex-desktop' : 'claude-code'), title: t.title,
|
|
20
|
+
lifecycle: { active: 'running', attention: 'running', ended: 'completed', interrupted: 'cancelled', failed: 'failed', unknown: 'unknown' }[t.status],
|
|
21
|
+
...(t.status === 'attention' ? { runningPhase: 'awaiting_feedback' } : t.status === 'active' ? { runningPhase: 'executing' } : {}),
|
|
22
|
+
...(t.status === 'ended' ? { readState: t.provider === 'codex' && t.applicationId === 'codex-desktop' && t.nativeUnread && !t.sourceStale ? 'unread' : 'unconfirmed' } : {}),
|
|
23
|
+
activityAt: t.updatedAt || null, freshness: t.sourceStale ? 'stale' : 'fresh',
|
|
24
|
+
}));
|
|
25
|
+
const sources = raw.sources.map(s => ({ applicationId: s.provider === 'codex' ? 'codex-desktop' : 'claude-code',
|
|
26
|
+
displayName: s.provider === 'codex' ? 'Codex Desktop' : 'Claude Code',
|
|
27
|
+
health: s.errors ? 'unavailable' : 'partial',
|
|
28
|
+
capabilities: { lifecycle: 'partial', approvalWaiting: 'unsupported', feedbackWaiting: s.provider === 'claude' ? 'unverified' : 'partial', terminalFailure: s.provider === 'codex' ? 'partial' : 'unsupported', desktopViewing: s.provider === 'codex' && s.nativeUnreadAvailable ? 'partial' : 'unsupported' },
|
|
29
|
+
evidence: raw.mode === 'demo' ? 'synthetic' : s.provider === 'codex' ? 'local-log' : 'local-log-unverified',
|
|
30
|
+
coverage: `最近 ${raw.hours} 小时 / 最多 64 份日志;${s.coverage};${s.provider === 'codex' && s.nativeUnreadAvailable ? '原生线程未读标记部分支持,缺失标记不证明已读' : '查看状态未接入'};${s.files ?? 0} 份文件${s.errors ? ';采集不可用,保留旧数据' : ''}${s.capped ? ';已截断' : ''}${s.incompleteLines ? ';存在不完整行' : ''}`,
|
|
31
|
+
excludedUnidentified: s.provider === 'codex' ? raw.tasks.filter(t => t.applicationId === 'codex-unidentified').length : 0,
|
|
32
|
+
}));
|
|
33
|
+
const signature = createHash('sha256').update(JSON.stringify({ rows, sources, mode: raw.mode })).digest('hex');
|
|
34
|
+
if (signature !== this.signature) {
|
|
35
|
+
this.signature = signature;
|
|
36
|
+
this.revision++;
|
|
37
|
+
}
|
|
38
|
+
return this.last = { schemaVersion: 1, instanceId: this.instanceId, revision: this.revision, generatedAt: Date.now(), mode: raw.mode, sources, rows };
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { userRuntimeRoot } from "./infrastructure/runtime-root.js";
|
|
6
|
+
import { qrExecutable } from "./infrastructure/qr.js";
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const metadata = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
9
|
+
const command = Object.keys(metadata.bin ?? {})[0] ?? 'local-agent-dashboard';
|
|
10
|
+
const value = (key) => { const i = args.indexOf(key); if (i < 0)
|
|
11
|
+
return undefined; if (!args[i + 1] || args[i + 1].startsWith('--'))
|
|
12
|
+
throw Error(`${key} 需要参数`); return args[i + 1]; };
|
|
13
|
+
async function run() {
|
|
14
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
15
|
+
console.log(`Agent Signal ${metadata.version}\n用法:${command} [--demo] [--no-open] [--state-dir <目录>] [--port <端口>] [--lan-port <端口>] [--hours <1..168>] [--lan <IPv4,IPv4>]\n默认打开本机任务/配置页;在“连接手机”中应用网卡后启用 LAN TLS。\n--doctor 检查依赖;--version 查看版本。\n运行数据:${userRuntimeRoot()}\n依赖:macOS 13+、Node >=22.18、OpenSSL 3(可用 brew install openssl@3)。\n手机使用独立 Android APK;扫码后仍须电脑批准。`);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (args.includes('--version')) {
|
|
19
|
+
console.log(metadata.version);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const allowed = new Set(['--demo', '--no-open', '--doctor', '--state-dir', '--port', '--lan-port', '--hours', '--lan', '--url-file']);
|
|
23
|
+
for (let i = 0; i < args.length; i++) {
|
|
24
|
+
if (!allowed.has(args[i]))
|
|
25
|
+
throw Error(`未知参数:${args[i]}`);
|
|
26
|
+
if (['--state-dir', '--port', '--lan-port', '--hours', '--lan', '--url-file'].includes(args[i])) {
|
|
27
|
+
value(args[i]);
|
|
28
|
+
i++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (process.platform !== 'darwin')
|
|
32
|
+
throw Error('当前电脑服务包仅支持 macOS;Android APK 单独下载。');
|
|
33
|
+
const openssl = process.env.AGENT_SIGNAL_OPENSSL ?? ['/opt/homebrew/opt/openssl@3/bin/openssl', '/usr/local/opt/openssl@3/bin/openssl'].find(p => existsSync(p)) ?? 'openssl';
|
|
34
|
+
let version;
|
|
35
|
+
try {
|
|
36
|
+
version = execFileSync(openssl, ['version'], { encoding: 'utf8', timeout: 5000 }).trim();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw Error('找不到 OpenSSL 3,请先安装:brew install openssl@3;也可设置 AGENT_SIGNAL_OPENSSL。');
|
|
40
|
+
}
|
|
41
|
+
if (!/^OpenSSL 3\./.test(version))
|
|
42
|
+
throw Error('需要 OpenSSL 3;请安装 brew install openssl@3。');
|
|
43
|
+
process.env.AGENT_SIGNAL_OPENSSL = openssl;
|
|
44
|
+
if (!existsSync(qrExecutable()))
|
|
45
|
+
throw Error('二维码组件缺失,请重新下载完整服务端包。');
|
|
46
|
+
const runtime = resolve(value('--state-dir') ?? userRuntimeRoot());
|
|
47
|
+
if (args.includes('--doctor')) {
|
|
48
|
+
console.log(`依赖检查通过:Node ${process.version};${version}\n数据目录:${runtime}\n二维码组件:可用`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const { main } = await import("./composition/main.js");
|
|
52
|
+
await main({ runtimeRoot: runtime, openBrowser: true });
|
|
53
|
+
}
|
|
54
|
+
run().catch(e => { console.error(`Agent Signal:${e.message}`); process.exitCode = 1; });
|
package/src/collector.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { networkInterfaces, hostname } from 'node:os';
|
|
2
|
+
import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { X509Certificate } from 'node:crypto';
|
|
6
|
+
import { Collector, demoSnapshot } from "../adapters/agents/log-collector.js";
|
|
7
|
+
import { Snapshots } from "../application/snapshots.js";
|
|
8
|
+
import { Pairing } from "../application/pairing.js";
|
|
9
|
+
import { certificates } from "../infrastructure/certificates.js";
|
|
10
|
+
import { pairingLink } from "../infrastructure/pairing-link.js";
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { qr } from "../infrastructure/qr.js";
|
|
13
|
+
import { createLan, tlsPolicy } from "../transport/lan/server.js";
|
|
14
|
+
import { createDashboard } from "../server.js";
|
|
15
|
+
export function interfaces() { return Object.entries(networkInterfaces()).flatMap(([name, addresses]) => (addresses ?? []).filter(a => a.family === 'IPv4' && !a.internal).map(a => ({ name, address: a.address }))); }
|
|
16
|
+
export async function main(options = {}) {
|
|
17
|
+
const args = process.argv.slice(2), value = (key) => { const i = args.indexOf(key); return i < 0 ? undefined : args[i + 1]; };
|
|
18
|
+
const port = Number(value('--port') ?? 0), lanPort = Number(value('--lan-port') ?? 63982), hours = Number(value('--hours') ?? 24);
|
|
19
|
+
if (![port, lanPort].every(p => Number.isInteger(p) && p >= 0 && p <= 65535) || !Number.isFinite(hours) || hours < 1 || hours > 168)
|
|
20
|
+
throw new Error('端口或 hours 参数无效');
|
|
21
|
+
const initial = value('--lan')?.split(',') ?? [];
|
|
22
|
+
const runtime = options.runtimeRoot ?? (value('--state-dir') ? resolve(value('--state-dir')) : fileURLToPath(new URL('../../.runtime/', import.meta.url)));
|
|
23
|
+
let interfacesConfigured = args.includes('--lan');
|
|
24
|
+
await mkdir(runtime, { recursive: true, mode: 0o700 });
|
|
25
|
+
const lock = join(runtime, 'host.lock');
|
|
26
|
+
try {
|
|
27
|
+
await writeFile(lock, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
const pid = Number(await readFile(lock, 'utf8'));
|
|
31
|
+
if (!Number.isInteger(pid) || pid < 1)
|
|
32
|
+
throw new Error('未知 writer 锁;请检查 .runtime/host.lock');
|
|
33
|
+
try {
|
|
34
|
+
process.kill(pid, 0);
|
|
35
|
+
throw new Error('已有采集服务 writer 正在运行');
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
if (e.code !== 'ESRCH')
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
await unlink(lock);
|
|
42
|
+
await writeFile(lock, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
43
|
+
}
|
|
44
|
+
const collector = new Collector({ hours });
|
|
45
|
+
const snapshots = new Snapshots(() => args.includes('--demo') ? demoSnapshot() : collector.snapshot());
|
|
46
|
+
const pairing = new Pairing();
|
|
47
|
+
const lanServers = new Map();
|
|
48
|
+
let localServer;
|
|
49
|
+
let identity;
|
|
50
|
+
let addresses = [], endpoints = [], servicePort = lanPort, changing = false, stopping = false;
|
|
51
|
+
let certificateWarning = '';
|
|
52
|
+
const close = (s) => { s.closeAllConnections(); s.close(); };
|
|
53
|
+
async function chooseInterfaces(next) {
|
|
54
|
+
const available = interfaces();
|
|
55
|
+
if (changing || stopping || next.length > 8 || new Set(next).size !== next.length || next.some(a => typeof a !== 'string' || !available.some(i => i.address === a)))
|
|
56
|
+
throw new Error('请选择本机现有的明确 IPv4 地址');
|
|
57
|
+
changing = true;
|
|
58
|
+
const additions = new Map();
|
|
59
|
+
try {
|
|
60
|
+
if (next.length) {
|
|
61
|
+
const renewed = await certificates(join(runtime, 'identity'), next);
|
|
62
|
+
if (identity && renewed.computerId !== identity.computerId)
|
|
63
|
+
throw new Error('电脑根已改变,需要明确重新配对');
|
|
64
|
+
for (const address of next)
|
|
65
|
+
if (!lanServers.has(address)) {
|
|
66
|
+
const server = createLan(renewed, pairing, snapshots, () => [`${address}:${servicePort}`]);
|
|
67
|
+
additions.set(address, server);
|
|
68
|
+
await new Promise((done, reject) => { server.once('error', reject); server.listen(servicePort, address, () => { servicePort = server.address().port; done(); }); });
|
|
69
|
+
}
|
|
70
|
+
// A failure to bind any new interface leaves every prior listener/context intact.
|
|
71
|
+
for (const [address, server] of lanServers)
|
|
72
|
+
if (next.includes(address))
|
|
73
|
+
server.setSecureContext({ key: renewed.key, cert: renewed.cert, ...tlsPolicy });
|
|
74
|
+
identity = renewed;
|
|
75
|
+
certificateWarning = '';
|
|
76
|
+
}
|
|
77
|
+
for (const [address, server] of lanServers)
|
|
78
|
+
if (!next.includes(address)) {
|
|
79
|
+
close(server);
|
|
80
|
+
lanServers.delete(address);
|
|
81
|
+
}
|
|
82
|
+
for (const [address, server] of additions)
|
|
83
|
+
lanServers.set(address, server);
|
|
84
|
+
addresses = [...next];
|
|
85
|
+
endpoints = next.map(a => `https://${a}:${servicePort}`);
|
|
86
|
+
return { endpoints };
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
for (const s of additions.values())
|
|
90
|
+
close(s);
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
changing = false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const renewal = setInterval(async () => {
|
|
98
|
+
if (changing || stopping || !identity || !addresses.length || Date.parse(new X509Certificate(identity.cert).validTo) - Date.now() > 2 * 86400000)
|
|
99
|
+
return;
|
|
100
|
+
try {
|
|
101
|
+
await chooseInterfaces(addresses);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
certificateWarning = '服务证书续签失败;不会关闭验证。请检查电脑身份文件及 OpenSSL。';
|
|
105
|
+
}
|
|
106
|
+
}, 3600000);
|
|
107
|
+
renewal.unref();
|
|
108
|
+
try {
|
|
109
|
+
await chooseInterfaces(initial);
|
|
110
|
+
const local = createDashboard(() => snapshots.get(), async (action, input) => {
|
|
111
|
+
if (action === 'status')
|
|
112
|
+
return { ...pairing.status(), available: interfaces(), selectedAddresses: addresses, interfacesConfigured, endpoints, computerId: identity?.computerId, name: hostname(), restartRequiresApproval: true, certificateWarning };
|
|
113
|
+
if (action === 'interfaces') {
|
|
114
|
+
if (!Array.isArray(input.addresses))
|
|
115
|
+
throw new Error('invalid addresses');
|
|
116
|
+
const result = await chooseInterfaces(input.addresses);
|
|
117
|
+
interfacesConfigured = true;
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
if (action === 'invite') {
|
|
121
|
+
if (!identity || !endpoints.length || changing)
|
|
122
|
+
throw new Error('LAN disabled or changing');
|
|
123
|
+
const i = pairing.createInvite();
|
|
124
|
+
const descriptor = JSON.stringify({ protocol: 1, computerId: identity.computerId, name: hostname().slice(0, 60), endpoints, ca: identity.caDer, fingerprint: identity.fingerprint, invitation: i.secret, expiresAt: i.expiresAt });
|
|
125
|
+
const link = pairingLink(descriptor);
|
|
126
|
+
return { expiresAt: i.expiresAt, descriptor, link, image: `data:image/png;base64,${(await qr(link)).toString('base64')}` };
|
|
127
|
+
}
|
|
128
|
+
if (action === 'approve') {
|
|
129
|
+
pairing.approve(String(input.requestId), String(input.code));
|
|
130
|
+
return { approved: true };
|
|
131
|
+
}
|
|
132
|
+
if (action === 'revoke') {
|
|
133
|
+
pairing.revoke(String(input.deviceId));
|
|
134
|
+
return { revoked: true };
|
|
135
|
+
}
|
|
136
|
+
throw new Error('unknown action');
|
|
137
|
+
});
|
|
138
|
+
localServer = local.server;
|
|
139
|
+
await new Promise((done, reject) => { local.server.once('error', reject); local.server.listen(port, '127.0.0.1', done); });
|
|
140
|
+
const url = `http://127.0.0.1:${local.server.address().port}/`;
|
|
141
|
+
if (value('--url-file'))
|
|
142
|
+
await writeFile(value('--url-file'), `${url}\n`, { flag: 'wx', mode: 0o600 });
|
|
143
|
+
if ((options.openBrowser ?? !options.runtimeRoot) && !args.includes('--no-open') && process.platform === 'darwin') {
|
|
144
|
+
const browser = spawn('/usr/bin/open', [url + '#tasks'], { stdio: 'ignore' });
|
|
145
|
+
browser.on('error', () => { });
|
|
146
|
+
browser.unref();
|
|
147
|
+
}
|
|
148
|
+
console.log(`Agent Signal(${args.includes('--demo') ? '明确模拟模式' : '只读原生日志'})\n电脑本机管理:${url}\n${endpoints.length ? `LAN TLS:${endpoints.join(', ')}\n首版每次重启需重新批准手机;运行中切换地址保留授权。` : 'LAN 未开启。请在电脑管理页明确选择地址后启用。手机仅使用 Android App。'}`);
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
clearInterval(renewal);
|
|
152
|
+
for (const s of lanServers.values())
|
|
153
|
+
close(s);
|
|
154
|
+
if (localServer)
|
|
155
|
+
close(localServer);
|
|
156
|
+
await unlink(lock);
|
|
157
|
+
throw e;
|
|
158
|
+
}
|
|
159
|
+
const stop = async () => { if (stopping)
|
|
160
|
+
return; stopping = true; clearInterval(renewal); for (const s of lanServers.values())
|
|
161
|
+
close(s); if (localServer)
|
|
162
|
+
close(localServer); await unlink(lock); };
|
|
163
|
+
process.once('SIGINT', stop);
|
|
164
|
+
process.once('SIGTERM', stop);
|
|
165
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The result version is an evidence binding, never a wall-clock approximation.
|
|
2
|
+
export function reduceSession(s, e) {
|
|
3
|
+
if (e.type === 'title')
|
|
4
|
+
return { ...s, title: e.title ?? s.title };
|
|
5
|
+
if (e.type === 'viewed')
|
|
6
|
+
return e.turn === s.turn && s.lifecycle === 'completed' && !!e.version && e.version === s.resultVersion ? { ...s, readState: 'read' } : s;
|
|
7
|
+
if (e.type === 'started')
|
|
8
|
+
return e.at < (s.activityAt ?? 0) ? s : { ...s, turn: e.turn, lifecycle: 'running', runningPhase: 'executing', activityAt: e.at, readState: undefined, resultVersion: undefined };
|
|
9
|
+
if (s.turn !== e.turn || e.at < (s.activityAt ?? 0))
|
|
10
|
+
return s;
|
|
11
|
+
if (e.type === 'activity' && s.lifecycle !== 'running')
|
|
12
|
+
return s;
|
|
13
|
+
return { ...s, activityAt: e.at, lifecycle: { completed: 'completed', failed: 'failed', cancelled: 'cancelled' }[e.type] ?? s.lifecycle,
|
|
14
|
+
runningPhase: e.type === 'waiting' ? e.phase : s.runningPhase,
|
|
15
|
+
...(e.type === 'completed' ? { readState: 'unconfirmed', resultVersion: e.version } : {}) };
|
|
16
|
+
}
|
|
17
|
+
export function queue(r) { return r.lifecycle === 'running' ? 'running' : r.lifecycle === 'failed' ? 'failed' : r.lifecycle === 'completed' && r.readState === 'unread' ? 'completed_unread' : null; }
|
|
18
|
+
export function select(rows, applications, filter = 'all') {
|
|
19
|
+
const selected = rows.filter(r => applications.includes(r.applicationId));
|
|
20
|
+
const counts = { running: 0, completed_unread: 0, failed: 0, awaiting_approval: 0, awaiting_feedback: 0, unconfirmed: 0 };
|
|
21
|
+
for (const r of selected) {
|
|
22
|
+
const q = queue(r);
|
|
23
|
+
if (q)
|
|
24
|
+
counts[q]++;
|
|
25
|
+
if (q === 'running' && r.runningPhase && r.runningPhase !== 'executing')
|
|
26
|
+
counts[r.runningPhase]++;
|
|
27
|
+
if (r.lifecycle === 'completed' && r.readState === 'unconfirmed')
|
|
28
|
+
counts.unconfirmed++;
|
|
29
|
+
}
|
|
30
|
+
return { counts, rows: selected.filter(r => filter === 'all' || queue(r) === filter || (r.lifecycle === 'running' && r.runningPhase === filter)).sort((a, b) => (b.activityAt ?? -1) - (a.activityAt ?? -1) || a.key.localeCompare(b.key)) };
|
|
31
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, writeFile, chmod, lstat } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { X509Certificate, randomBytes, createHash } from 'node:crypto';
|
|
7
|
+
import { isIP } from 'node:net';
|
|
8
|
+
const run = promisify(execFile);
|
|
9
|
+
export async function certificates(root, addresses, openssl = process.env.AGENT_SIGNAL_OPENSSL ?? ['/opt/homebrew/opt/openssl@3/bin/openssl', '/usr/local/opt/openssl@3/bin/openssl'].find(p => existsSync(p)) ?? 'openssl') {
|
|
10
|
+
if (!addresses.length || addresses.length > 8 || addresses.some(a => isIP(a) !== 4))
|
|
11
|
+
throw new Error('Explicit IPv4 addresses required');
|
|
12
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
13
|
+
if ((await lstat(root)).isSymbolicLink())
|
|
14
|
+
throw new Error('Identity directory cannot be a symlink');
|
|
15
|
+
await chmod(root, 0o700);
|
|
16
|
+
const caKey = join(root, 'ca.key'), caFile = join(root, 'ca.pem');
|
|
17
|
+
const exists = async (p) => { try {
|
|
18
|
+
if ((await lstat(p)).isSymbolicLink())
|
|
19
|
+
throw new Error('Identity symlink rejected');
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
if (e.code === 'ENOENT')
|
|
24
|
+
return false;
|
|
25
|
+
throw e;
|
|
26
|
+
} };
|
|
27
|
+
const hasKey = await exists(caKey), hasCA = await exists(caFile);
|
|
28
|
+
if (hasKey !== hasCA)
|
|
29
|
+
throw new Error('Incomplete identity; explicit new pairing identity required');
|
|
30
|
+
if (!hasKey) {
|
|
31
|
+
await run(openssl, ['genpkey', '-algorithm', 'EC', '-pkeyopt', 'ec_paramgen_curve:P-256', '-out', caKey]);
|
|
32
|
+
await chmod(caKey, 0o600);
|
|
33
|
+
await run(openssl, ['req', '-new', '-x509', '-key', caKey, '-sha256', '-days', '730', '-subj', '/CN=Agent Signal private CA', '-addext', 'basicConstraints=critical,CA:TRUE,pathlen:0', '-addext', 'keyUsage=critical,keyCertSign,cRLSign', '-out', caFile]);
|
|
34
|
+
await chmod(caFile, 0o600);
|
|
35
|
+
}
|
|
36
|
+
const ca = await readFile(caFile, 'utf8'), parsedCA = new X509Certificate(ca);
|
|
37
|
+
if (!parsedCA.ca || Date.parse(parsedCA.validTo) < Date.now() + 86400000 || !parsedCA.verify(parsedCA.publicKey))
|
|
38
|
+
throw new Error('Root invalid/expiring; explicit re-pair required');
|
|
39
|
+
// Unique generation directory: never update a live key/certificate pair piecemeal.
|
|
40
|
+
const generation = join(root, `leaf-${randomBytes(8).toString('hex')}`);
|
|
41
|
+
await mkdir(generation, { mode: 0o700 });
|
|
42
|
+
const keyFile = join(generation, 'server.key'), csr = join(generation, 'server.csr'), certFile = join(generation, 'server.pem'), config = join(generation, 'extensions.cnf');
|
|
43
|
+
await writeFile(config, `basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=serverAuth\nsubjectAltName=${addresses.map(a => `IP:${a}`).join(',')}\n`, { mode: 0o600 });
|
|
44
|
+
await run(openssl, ['genpkey', '-algorithm', 'EC', '-pkeyopt', 'ec_paramgen_curve:P-256', '-out', keyFile]);
|
|
45
|
+
await chmod(keyFile, 0o600);
|
|
46
|
+
await run(openssl, ['req', '-new', '-key', keyFile, '-subj', '/CN=Agent Signal LAN', '-out', csr]);
|
|
47
|
+
await run(openssl, ['x509', '-req', '-in', csr, '-CA', caFile, '-CAkey', caKey, '-set_serial', `0x${randomBytes(16).toString('hex')}`, '-days', '7', '-sha256', '-extfile', config, '-out', certFile]);
|
|
48
|
+
await run(openssl, ['verify', '-purpose', 'sslserver', '-CAfile', caFile, certFile]);
|
|
49
|
+
const key = await readFile(keyFile, 'utf8'), cert = await readFile(certFile, 'utf8');
|
|
50
|
+
const fingerprint = createHash('sha256').update(parsedCA.raw).digest('hex');
|
|
51
|
+
return { key, cert, ca, caDer: parsedCA.raw.toString('base64'), fingerprint, computerId: fingerprint, addresses };
|
|
52
|
+
}
|