@hermespilot/link 0.1.1 → 0.1.3
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 +8 -0
- package/dist/{chunk-4CDHEW3J.js → chunk-7M3UZCA7.js} +393 -46
- package/dist/chunk-7M3UZCA7.js.map +1 -0
- package/dist/cli/index.js +644 -107
- package/dist/cli/index.js.map +1 -1
- package/dist/http/app.d.ts +45 -2
- package/dist/http/app.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-4CDHEW3J.js.map +0 -1
package/README.md
CHANGED
|
@@ -22,11 +22,17 @@ hermeslink --version
|
|
|
22
22
|
hermeslink status
|
|
23
23
|
hermeslink pair
|
|
24
24
|
hermeslink start
|
|
25
|
+
hermeslink stop
|
|
26
|
+
hermeslink autostart on
|
|
27
|
+
hermeslink autostart off
|
|
25
28
|
hermeslink doctor
|
|
29
|
+
hermeslink logs
|
|
26
30
|
```
|
|
27
31
|
|
|
28
32
|
`hermeslink pair` requires HermesPilot Server and Relay to be available. The terminal side does not ask for a HermesPilot account; the App must be logged in before it scans or claims a pairing session.
|
|
29
33
|
|
|
34
|
+
After a successful QR claim, `hermeslink pair` starts Hermes Link in the background and enables boot autostart. Boot autostart does not configure launchd/systemd restart policies; if the user stops Hermes Link, the operating system should not automatically relaunch it until the next login/boot autostart cycle.
|
|
35
|
+
|
|
30
36
|
CLI output follows the current system language when it is Chinese or English. You can override it for a single command with `HERMESLINK_LANG=zh-CN` or `HERMESLINK_LANG=en`.
|
|
31
37
|
|
|
32
38
|
## Runtime data
|
|
@@ -38,3 +44,5 @@ Hermes Link keeps its local identity and runtime state under:
|
|
|
38
44
|
```
|
|
39
45
|
|
|
40
46
|
Uninstalling the npm package does not remove this directory, so the same Link ID can be reused after reinstalling.
|
|
47
|
+
|
|
48
|
+
Service logs are written as rotated JSONL files under `~/.hermeslink/logs/hermeslink.log`. A paired App can read the same service log stream through `GET /api/v1/logs` using the normal Link access token.
|
|
@@ -4,7 +4,7 @@ import Router from "@koa/router";
|
|
|
4
4
|
import { Readable } from "stream";
|
|
5
5
|
|
|
6
6
|
// src/constants.ts
|
|
7
|
-
var LINK_VERSION = "0.1.
|
|
7
|
+
var LINK_VERSION = "0.1.3";
|
|
8
8
|
var LINK_COMMAND = "hermeslink";
|
|
9
9
|
var LINK_DEFAULT_PORT = 52379;
|
|
10
10
|
var LINK_RUNTIME_DIR_NAME = ".hermeslink";
|
|
@@ -270,7 +270,7 @@ async function cancelHermesRun(runId, options = {}) {
|
|
|
270
270
|
}
|
|
271
271
|
fallbackRuns.delete(runId);
|
|
272
272
|
}
|
|
273
|
-
async function callHermesApi(
|
|
273
|
+
async function callHermesApi(path6, init, options) {
|
|
274
274
|
const config = await readHermesApiServerConfig();
|
|
275
275
|
if (!config.port || !config.key) {
|
|
276
276
|
return new Response(null, { status: 503 });
|
|
@@ -280,7 +280,7 @@ async function callHermesApi(path5, init, options) {
|
|
|
280
280
|
headers.set("accept", headers.get("accept") ?? "application/json");
|
|
281
281
|
headers.set("x-api-key", config.key);
|
|
282
282
|
headers.set("authorization", `Bearer ${config.key}`);
|
|
283
|
-
return await fetcher(`http://127.0.0.1:${config.port}${
|
|
283
|
+
return await fetcher(`http://127.0.0.1:${config.port}${path6}`, {
|
|
284
284
|
...init,
|
|
285
285
|
headers
|
|
286
286
|
}).catch(() => new Response(null, { status: 503 }));
|
|
@@ -672,33 +672,48 @@ function readErrorMessage(payload) {
|
|
|
672
672
|
|
|
673
673
|
// src/topology/network.ts
|
|
674
674
|
import os4 from "os";
|
|
675
|
+
var VIRTUAL_INTERFACE_NAME_PATTERN = /(docker|veth|vmnet|vmenet|vbox|virtualbox|vmware|tailscale|zerotier|wireguard|utun|virbr|hyper-v|vethernet|loopback|\blo\b|^lo\d*$|^br-|^bridge\d+$|^zt|^tun|^tap|awdl|llw|anpi|gif|stf|ipsec|ppp)/iu;
|
|
676
|
+
var MAX_LAN_IPS = 4;
|
|
677
|
+
var MAX_PUBLIC_IPV4S = 2;
|
|
678
|
+
var MAX_PUBLIC_IPV6S = 2;
|
|
675
679
|
async function discoverRouteCandidates(options) {
|
|
676
680
|
const lanIps = discoverLanIps();
|
|
677
681
|
const publicIps = options.relayBootstrapToken ? await observePublicRoute(options).catch(() => ({ publicIpv4s: [], publicIpv6s: [] })) : { publicIpv4s: [], publicIpv6s: [] };
|
|
682
|
+
const publicIpv4s = unique(publicIps.publicIpv4s.filter(isUsablePublicIpv4)).slice(0, MAX_PUBLIC_IPV4S);
|
|
683
|
+
const publicIpv6s = unique(publicIps.publicIpv6s.filter(isUsablePublicIpv6)).slice(0, MAX_PUBLIC_IPV6S);
|
|
678
684
|
const preferredUrls = [
|
|
679
685
|
...lanIps.map((ip) => buildDirectUrl(ip, options.port)),
|
|
680
|
-
...
|
|
681
|
-
...
|
|
686
|
+
...publicIpv4s.map((ip) => buildDirectUrl(ip, options.port)),
|
|
687
|
+
...publicIpv6s.map((ip) => buildDirectUrl(ip, options.port)),
|
|
682
688
|
`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/links/${options.linkId}`
|
|
683
689
|
];
|
|
684
690
|
return {
|
|
685
691
|
lanIps,
|
|
686
|
-
publicIpv4s
|
|
687
|
-
publicIpv6s
|
|
692
|
+
publicIpv4s,
|
|
693
|
+
publicIpv6s,
|
|
688
694
|
preferredUrls
|
|
689
695
|
};
|
|
690
696
|
}
|
|
691
697
|
function discoverLanIps() {
|
|
698
|
+
return discoverLanIpsFromInterfaces(os4.networkInterfaces());
|
|
699
|
+
}
|
|
700
|
+
function discoverLanIpsFromInterfaces(interfaces) {
|
|
692
701
|
const result = /* @__PURE__ */ new Set();
|
|
693
|
-
const
|
|
694
|
-
for (const items of Object.
|
|
702
|
+
const candidates = [];
|
|
703
|
+
for (const [name, items] of Object.entries(interfaces)) {
|
|
704
|
+
if (shouldIgnoreInterface(name)) {
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
695
707
|
for (const item of items ?? []) {
|
|
696
|
-
if (!item.internal && item.address &&
|
|
697
|
-
|
|
708
|
+
if (!item.internal && item.address && item.family === "IPv4" && isUsableLanIpv4(item.address, item.netmask)) {
|
|
709
|
+
candidates.push({ name, address: item.address });
|
|
698
710
|
}
|
|
699
711
|
}
|
|
700
712
|
}
|
|
701
|
-
|
|
713
|
+
for (const candidate of candidates.sort(compareLanCandidate)) {
|
|
714
|
+
result.add(candidate.address);
|
|
715
|
+
}
|
|
716
|
+
return [...result].slice(0, MAX_LAN_IPS);
|
|
702
717
|
}
|
|
703
718
|
async function observePublicRoute(options) {
|
|
704
719
|
const fetcher = options.fetchImpl ?? fetch;
|
|
@@ -723,8 +738,8 @@ async function observePublicRoute(options) {
|
|
|
723
738
|
typeof observed?.ip === "string" ? observed.ip : null
|
|
724
739
|
].filter((value) => Boolean(value));
|
|
725
740
|
return {
|
|
726
|
-
publicIpv4s: unique(values.filter(
|
|
727
|
-
publicIpv6s: unique(values.filter(
|
|
741
|
+
publicIpv4s: unique(values.filter(isUsablePublicIpv4)),
|
|
742
|
+
publicIpv6s: unique(values.filter(isUsablePublicIpv6))
|
|
728
743
|
};
|
|
729
744
|
}
|
|
730
745
|
function readIpRecord(value) {
|
|
@@ -737,6 +752,81 @@ function readIpRecord(value) {
|
|
|
737
752
|
function buildDirectUrl(ip, port) {
|
|
738
753
|
return `http://${ip.includes(":") ? `[${ip}]` : ip}:${port}`;
|
|
739
754
|
}
|
|
755
|
+
function shouldIgnoreInterface(name) {
|
|
756
|
+
return !name.trim() || VIRTUAL_INTERFACE_NAME_PATTERN.test(name);
|
|
757
|
+
}
|
|
758
|
+
function compareLanCandidate(left, right) {
|
|
759
|
+
const priority = interfacePriority(left.name) - interfacePriority(right.name);
|
|
760
|
+
return priority || left.name.localeCompare(right.name) || left.address.localeCompare(right.address);
|
|
761
|
+
}
|
|
762
|
+
function interfacePriority(name) {
|
|
763
|
+
if (/^(en|eth|wlan|wi-fi|wifi)/iu.test(name)) {
|
|
764
|
+
return 0;
|
|
765
|
+
}
|
|
766
|
+
return 1;
|
|
767
|
+
}
|
|
768
|
+
function isUsableLanIpv4(address, netmask) {
|
|
769
|
+
return isPrivateIpv4(address) && !isNetworkOrBroadcastIpv4Address(address, netmask);
|
|
770
|
+
}
|
|
771
|
+
function isUsablePublicIpv4(address) {
|
|
772
|
+
return isValidIpv4(address) && !isSpecialIpv4(address);
|
|
773
|
+
}
|
|
774
|
+
function isUsablePublicIpv6(address) {
|
|
775
|
+
const normalized = address.toLowerCase();
|
|
776
|
+
return normalized.includes(":") && !normalized.startsWith("fe80:") && !normalized.startsWith("fc") && !normalized.startsWith("fd") && !normalized.startsWith("ff") && normalized !== "::" && normalized !== "::1";
|
|
777
|
+
}
|
|
778
|
+
function isPrivateIpv4(address) {
|
|
779
|
+
const parts = parseIpv4Segments(address);
|
|
780
|
+
if (!parts) {
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
783
|
+
const [first, second] = parts;
|
|
784
|
+
return first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
|
|
785
|
+
}
|
|
786
|
+
function isSpecialIpv4(address) {
|
|
787
|
+
const parts = parseIpv4Segments(address);
|
|
788
|
+
if (!parts) {
|
|
789
|
+
return true;
|
|
790
|
+
}
|
|
791
|
+
const [first, second, third, fourth] = parts;
|
|
792
|
+
return first === 0 || first === 10 || first === 127 || first >= 224 || first === 100 && second >= 64 && second <= 127 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 192 && second === 0 && third === 2 || first === 198 && (second === 18 || second === 19) || first === 198 && second === 51 && third === 100 || first === 203 && second === 0 && third === 113 || first === 255 && second === 255 && third === 255 && fourth === 255;
|
|
793
|
+
}
|
|
794
|
+
function isNetworkOrBroadcastIpv4Address(address, netmask) {
|
|
795
|
+
const addressParts = parseIpv4Segments(address);
|
|
796
|
+
const netmaskParts = netmask ? parseIpv4Segments(netmask) : null;
|
|
797
|
+
if (!addressParts) {
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
if (!netmaskParts) {
|
|
801
|
+
const last = addressParts[3];
|
|
802
|
+
return last === 0 || last === 255;
|
|
803
|
+
}
|
|
804
|
+
const addressInt = ipv4SegmentsToInt(addressParts);
|
|
805
|
+
const netmaskInt = ipv4SegmentsToInt(netmaskParts);
|
|
806
|
+
const hostMask = ~netmaskInt >>> 0;
|
|
807
|
+
if (hostMask === 0) {
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
const networkInt = addressInt & netmaskInt;
|
|
811
|
+
const broadcastInt = (networkInt | hostMask) >>> 0;
|
|
812
|
+
return addressInt === networkInt || addressInt === broadcastInt;
|
|
813
|
+
}
|
|
814
|
+
function isValidIpv4(address) {
|
|
815
|
+
return Boolean(parseIpv4Segments(address));
|
|
816
|
+
}
|
|
817
|
+
function parseIpv4Segments(address) {
|
|
818
|
+
if (!/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(address)) {
|
|
819
|
+
return null;
|
|
820
|
+
}
|
|
821
|
+
const parts = address.split(".").map((part) => Number.parseInt(part, 10));
|
|
822
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
return parts;
|
|
826
|
+
}
|
|
827
|
+
function ipv4SegmentsToInt(parts) {
|
|
828
|
+
return (parts[0] << 24 >>> 0 | parts[1] << 16 | parts[2] << 8 | parts[3]) >>> 0;
|
|
829
|
+
}
|
|
740
830
|
function unique(values) {
|
|
741
831
|
return [...new Set(values)];
|
|
742
832
|
}
|
|
@@ -783,7 +873,7 @@ async function preparePairing(paths = resolveRuntimePaths()) {
|
|
|
783
873
|
display_name: defaultDisplayName(),
|
|
784
874
|
session_id: created.sessionId,
|
|
785
875
|
code: created.code,
|
|
786
|
-
preferred_urls: routes
|
|
876
|
+
preferred_urls: qrPreferredUrls(routes)
|
|
787
877
|
};
|
|
788
878
|
return {
|
|
789
879
|
sessionId: created.sessionId,
|
|
@@ -840,8 +930,8 @@ async function loadRequiredIdentity(paths) {
|
|
|
840
930
|
}
|
|
841
931
|
return identity;
|
|
842
932
|
}
|
|
843
|
-
async function postServerJson(serverBaseUrl,
|
|
844
|
-
const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${
|
|
933
|
+
async function postServerJson(serverBaseUrl, path6, body) {
|
|
934
|
+
const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${path6}`, {
|
|
845
935
|
method: "POST",
|
|
846
936
|
headers: {
|
|
847
937
|
accept: "application/json",
|
|
@@ -851,8 +941,8 @@ async function postServerJson(serverBaseUrl, path5, body) {
|
|
|
851
941
|
});
|
|
852
942
|
return readJsonResponse2(response);
|
|
853
943
|
}
|
|
854
|
-
async function patchServerJson(serverBaseUrl,
|
|
855
|
-
const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${
|
|
944
|
+
async function patchServerJson(serverBaseUrl, path6, token, body) {
|
|
945
|
+
const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${path6}`, {
|
|
856
946
|
method: "PATCH",
|
|
857
947
|
headers: {
|
|
858
948
|
accept: "application/json",
|
|
@@ -885,6 +975,9 @@ function readErrorMessage2(payload) {
|
|
|
885
975
|
function defaultDisplayName() {
|
|
886
976
|
return `Hermes Link ${process.platform}`;
|
|
887
977
|
}
|
|
978
|
+
function qrPreferredUrls(routes) {
|
|
979
|
+
return routes.preferredUrls.filter((url) => !url.includes("/api/v1/relay/links/")).slice(0, 1);
|
|
980
|
+
}
|
|
888
981
|
function formatDevice(device) {
|
|
889
982
|
return {
|
|
890
983
|
id: device.id,
|
|
@@ -955,11 +1048,217 @@ function base64UrlToBase64(value) {
|
|
|
955
1048
|
return normalized + "=".repeat((4 - normalized.length % 4) % 4);
|
|
956
1049
|
}
|
|
957
1050
|
|
|
1051
|
+
// src/runtime/logger.ts
|
|
1052
|
+
import { appendFile, mkdir as mkdir5, open as open2, readFile as readFile3, rename as rename3, rm as rm3, stat as stat2 } from "fs/promises";
|
|
1053
|
+
import path5 from "path";
|
|
1054
|
+
var DEFAULT_LOG_FILE = "hermeslink.log";
|
|
1055
|
+
var DEFAULT_MAX_FILE_BYTES = 1024 * 1024;
|
|
1056
|
+
var DEFAULT_MAX_FILES = 5;
|
|
1057
|
+
var DEFAULT_READ_LIMIT = 200;
|
|
1058
|
+
var MAX_READ_LIMIT = 1e3;
|
|
1059
|
+
var DEFAULT_MAX_BYTES_PER_FILE = 512 * 1024;
|
|
1060
|
+
var FileLogger = class {
|
|
1061
|
+
filePath;
|
|
1062
|
+
paths;
|
|
1063
|
+
maxFileBytes;
|
|
1064
|
+
maxFiles;
|
|
1065
|
+
now;
|
|
1066
|
+
queue = Promise.resolve();
|
|
1067
|
+
constructor(options = {}) {
|
|
1068
|
+
this.paths = options.paths ?? resolveRuntimePaths();
|
|
1069
|
+
this.filePath = getLinkLogFile(this.paths, options.fileName);
|
|
1070
|
+
this.maxFileBytes = Math.max(256, Math.floor(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES));
|
|
1071
|
+
this.maxFiles = Math.max(0, Math.floor(options.maxFiles ?? DEFAULT_MAX_FILES));
|
|
1072
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
1073
|
+
}
|
|
1074
|
+
debug(message, fields) {
|
|
1075
|
+
return this.write("debug", message, fields);
|
|
1076
|
+
}
|
|
1077
|
+
info(message, fields) {
|
|
1078
|
+
return this.write("info", message, fields);
|
|
1079
|
+
}
|
|
1080
|
+
warn(message, fields) {
|
|
1081
|
+
return this.write("warn", message, fields);
|
|
1082
|
+
}
|
|
1083
|
+
error(message, fields) {
|
|
1084
|
+
return this.write("error", message, fields);
|
|
1085
|
+
}
|
|
1086
|
+
write(level, message, fields) {
|
|
1087
|
+
const entry = {
|
|
1088
|
+
ts: this.now().toISOString(),
|
|
1089
|
+
level,
|
|
1090
|
+
message,
|
|
1091
|
+
...fields ? { fields: sanitizeFields(fields) } : {}
|
|
1092
|
+
};
|
|
1093
|
+
const next = this.queue.then(() => this.appendEntry(entry)).catch(() => void 0);
|
|
1094
|
+
this.queue = next;
|
|
1095
|
+
return next;
|
|
1096
|
+
}
|
|
1097
|
+
flush() {
|
|
1098
|
+
return this.queue;
|
|
1099
|
+
}
|
|
1100
|
+
async appendEntry(entry) {
|
|
1101
|
+
await mkdir5(this.paths.logsDir, { recursive: true, mode: 448 });
|
|
1102
|
+
const line = `${JSON.stringify(entry)}
|
|
1103
|
+
`;
|
|
1104
|
+
await this.rotateIfNeeded(Buffer.byteLength(line, "utf8"));
|
|
1105
|
+
await appendFile(this.filePath, line, { mode: 384 });
|
|
1106
|
+
}
|
|
1107
|
+
async rotateIfNeeded(nextBytes) {
|
|
1108
|
+
const current = await stat2(this.filePath).catch(() => null);
|
|
1109
|
+
if (!current || current.size === 0 || current.size + nextBytes <= this.maxFileBytes) {
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
if (this.maxFiles === 0) {
|
|
1113
|
+
await rm3(this.filePath, { force: true }).catch(() => void 0);
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
await rm3(rotatedLogFile(this.filePath, this.maxFiles), { force: true }).catch(() => void 0);
|
|
1117
|
+
for (let index = this.maxFiles - 1; index >= 1; index -= 1) {
|
|
1118
|
+
await moveIfExists(rotatedLogFile(this.filePath, index), rotatedLogFile(this.filePath, index + 1));
|
|
1119
|
+
}
|
|
1120
|
+
await moveIfExists(this.filePath, rotatedLogFile(this.filePath, 1));
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
function createFileLogger(options = {}) {
|
|
1124
|
+
return new FileLogger(options);
|
|
1125
|
+
}
|
|
1126
|
+
function getLinkLogFile(paths = resolveRuntimePaths(), fileName = DEFAULT_LOG_FILE) {
|
|
1127
|
+
return path5.join(paths.logsDir, fileName);
|
|
1128
|
+
}
|
|
1129
|
+
async function readRecentLogEntries(options = {}) {
|
|
1130
|
+
const paths = options.paths ?? resolveRuntimePaths();
|
|
1131
|
+
const filePath = getLinkLogFile(paths, options.fileName);
|
|
1132
|
+
const limit = clampLimit(options.limit);
|
|
1133
|
+
const maxFiles = Math.max(0, Math.floor(options.maxFiles ?? DEFAULT_MAX_FILES));
|
|
1134
|
+
const maxBytesPerFile = Math.max(1024, Math.floor(options.maxBytesPerFile ?? DEFAULT_MAX_BYTES_PER_FILE));
|
|
1135
|
+
const files = [filePath, ...Array.from({ length: maxFiles }, (_, index) => rotatedLogFile(filePath, index + 1))];
|
|
1136
|
+
const entries = [];
|
|
1137
|
+
for (const file of files) {
|
|
1138
|
+
const raw = await readTail(file, maxBytesPerFile);
|
|
1139
|
+
if (!raw) {
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
const lines = raw.split(/\r?\n/u).filter(Boolean);
|
|
1143
|
+
for (let index = lines.length - 1; index >= 0 && entries.length < limit; index -= 1) {
|
|
1144
|
+
const entry = parseLogLine(lines[index]);
|
|
1145
|
+
if (entry) {
|
|
1146
|
+
entries.push(entry);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
if (entries.length >= limit) {
|
|
1150
|
+
break;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return entries.reverse();
|
|
1154
|
+
}
|
|
1155
|
+
function clampLimit(value) {
|
|
1156
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
1157
|
+
return DEFAULT_READ_LIMIT;
|
|
1158
|
+
}
|
|
1159
|
+
return Math.min(MAX_READ_LIMIT, Math.max(1, Math.floor(value)));
|
|
1160
|
+
}
|
|
1161
|
+
function sanitizeFields(fields) {
|
|
1162
|
+
return sanitizeObject(fields, 0);
|
|
1163
|
+
}
|
|
1164
|
+
function sanitizeValue(value, depth) {
|
|
1165
|
+
if (value === null || typeof value === "boolean") {
|
|
1166
|
+
return value;
|
|
1167
|
+
}
|
|
1168
|
+
if (typeof value === "number") {
|
|
1169
|
+
return Number.isFinite(value) ? value : null;
|
|
1170
|
+
}
|
|
1171
|
+
if (typeof value === "string") {
|
|
1172
|
+
return value.length > 2e3 ? `${value.slice(0, 2e3)}...` : value;
|
|
1173
|
+
}
|
|
1174
|
+
if (Array.isArray(value)) {
|
|
1175
|
+
if (depth >= 3) {
|
|
1176
|
+
return "[array]";
|
|
1177
|
+
}
|
|
1178
|
+
return value.slice(0, 20).map((item) => sanitizeValue(item, depth + 1));
|
|
1179
|
+
}
|
|
1180
|
+
if (typeof value === "object" && value !== null) {
|
|
1181
|
+
if (depth >= 3) {
|
|
1182
|
+
return "[object]";
|
|
1183
|
+
}
|
|
1184
|
+
return sanitizeObject(value, depth + 1);
|
|
1185
|
+
}
|
|
1186
|
+
return String(value);
|
|
1187
|
+
}
|
|
1188
|
+
function sanitizeObject(value, depth) {
|
|
1189
|
+
const result = {};
|
|
1190
|
+
for (const [key, child] of Object.entries(value).slice(0, 50)) {
|
|
1191
|
+
if (isSensitiveKey(key)) {
|
|
1192
|
+
result[key] = "[redacted]";
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
result[key] = sanitizeValue(child, depth);
|
|
1196
|
+
}
|
|
1197
|
+
return result;
|
|
1198
|
+
}
|
|
1199
|
+
function isSensitiveKey(key) {
|
|
1200
|
+
return /(authorization|cookie|token|secret|password|private[_-]?key|api[_-]?key)/iu.test(key);
|
|
1201
|
+
}
|
|
1202
|
+
function parseLogLine(line) {
|
|
1203
|
+
try {
|
|
1204
|
+
const value = JSON.parse(line);
|
|
1205
|
+
if (!value || typeof value.ts !== "string" || !isLogLevel(value.level) || typeof value.message !== "string") {
|
|
1206
|
+
return null;
|
|
1207
|
+
}
|
|
1208
|
+
return {
|
|
1209
|
+
ts: value.ts,
|
|
1210
|
+
level: value.level,
|
|
1211
|
+
message: value.message,
|
|
1212
|
+
...value.fields && typeof value.fields === "object" ? { fields: value.fields } : {}
|
|
1213
|
+
};
|
|
1214
|
+
} catch {
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
function isLogLevel(value) {
|
|
1219
|
+
return value === "debug" || value === "info" || value === "warn" || value === "error";
|
|
1220
|
+
}
|
|
1221
|
+
async function readTail(filePath, maxBytes) {
|
|
1222
|
+
const info = await stat2(filePath).catch(() => null);
|
|
1223
|
+
if (!info || info.size <= 0) {
|
|
1224
|
+
return null;
|
|
1225
|
+
}
|
|
1226
|
+
if (info.size <= maxBytes) {
|
|
1227
|
+
return await readFile3(filePath, "utf8").catch(() => null);
|
|
1228
|
+
}
|
|
1229
|
+
const handle = await open2(filePath, "r").catch(() => null);
|
|
1230
|
+
if (!handle) {
|
|
1231
|
+
return null;
|
|
1232
|
+
}
|
|
1233
|
+
try {
|
|
1234
|
+
const length = Math.min(info.size, maxBytes);
|
|
1235
|
+
const buffer = Buffer.alloc(length);
|
|
1236
|
+
await handle.read(buffer, 0, length, info.size - length);
|
|
1237
|
+
return buffer.toString("utf8");
|
|
1238
|
+
} finally {
|
|
1239
|
+
await handle.close();
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
async function moveIfExists(from, to) {
|
|
1243
|
+
await rm3(to, { force: true }).catch(() => void 0);
|
|
1244
|
+
await rename3(from, to).catch((error) => {
|
|
1245
|
+
if (error.code !== "ENOENT") {
|
|
1246
|
+
throw error;
|
|
1247
|
+
}
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
function rotatedLogFile(filePath, index) {
|
|
1251
|
+
return `${filePath}.${index}`;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
958
1254
|
// src/http/app.ts
|
|
959
|
-
async function createApp() {
|
|
1255
|
+
async function createApp(options = {}) {
|
|
1256
|
+
const paths = options.paths ?? resolveRuntimePaths();
|
|
1257
|
+
const logger = options.logger ?? createFileLogger({ paths });
|
|
960
1258
|
const app = new Koa();
|
|
961
1259
|
const router = new Router();
|
|
962
1260
|
app.use(async (ctx, next) => {
|
|
1261
|
+
const startedAt = Date.now();
|
|
963
1262
|
try {
|
|
964
1263
|
await next();
|
|
965
1264
|
} catch (error) {
|
|
@@ -973,10 +1272,24 @@ async function createApp() {
|
|
|
973
1272
|
message: error instanceof Error ? error.message : "Internal error"
|
|
974
1273
|
}
|
|
975
1274
|
};
|
|
1275
|
+
void logger.write(status >= 500 ? "error" : "warn", "http_request_failed", {
|
|
1276
|
+
method: ctx.method,
|
|
1277
|
+
path: ctx.path,
|
|
1278
|
+
status,
|
|
1279
|
+
code: isLinkHttpError(error) ? error.code : status === 400 ? "invalid_profile_name" : "internal_error",
|
|
1280
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1281
|
+
});
|
|
1282
|
+
} finally {
|
|
1283
|
+
void logger.info("http_request", {
|
|
1284
|
+
method: ctx.method,
|
|
1285
|
+
path: ctx.path,
|
|
1286
|
+
status: ctx.status,
|
|
1287
|
+
duration_ms: Date.now() - startedAt
|
|
1288
|
+
});
|
|
976
1289
|
}
|
|
977
1290
|
});
|
|
978
1291
|
router.get("/api/v1/bootstrap", async (ctx) => {
|
|
979
|
-
const [identity, config] = await Promise.all([loadIdentity(), loadConfig()]);
|
|
1292
|
+
const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
|
|
980
1293
|
const routes = identity?.link_id ? await discoverRouteCandidates({
|
|
981
1294
|
port: config.port,
|
|
982
1295
|
relayBaseUrl: config.relayBaseUrl,
|
|
@@ -998,7 +1311,8 @@ async function createApp() {
|
|
|
998
1311
|
runs: true,
|
|
999
1312
|
sse: true,
|
|
1000
1313
|
relay: true,
|
|
1001
|
-
profiles: true
|
|
1314
|
+
profiles: true,
|
|
1315
|
+
logs: true
|
|
1002
1316
|
}
|
|
1003
1317
|
};
|
|
1004
1318
|
});
|
|
@@ -1009,16 +1323,28 @@ async function createApp() {
|
|
|
1009
1323
|
if (!sessionId || !claimToken) {
|
|
1010
1324
|
throw new LinkHttpError(400, "pairing_claim_invalid", "session_id and claim_token are required");
|
|
1011
1325
|
}
|
|
1012
|
-
|
|
1326
|
+
const claimed = await claimPairing({
|
|
1013
1327
|
sessionId,
|
|
1014
1328
|
claimToken,
|
|
1015
1329
|
deviceLabel: readString2(body, "device_label") ?? readString2(body, "deviceLabel") ?? "HermesPilot App",
|
|
1016
|
-
devicePlatform: readString2(body, "device_platform") ?? readString2(body, "devicePlatform") ?? "unknown"
|
|
1330
|
+
devicePlatform: readString2(body, "device_platform") ?? readString2(body, "devicePlatform") ?? "unknown",
|
|
1331
|
+
paths
|
|
1332
|
+
});
|
|
1333
|
+
ctx.body = claimed;
|
|
1334
|
+
void logger.info("pairing_claimed", {
|
|
1335
|
+
device_id: claimed.device.device_id,
|
|
1336
|
+
device_platform: claimed.device.platform
|
|
1017
1337
|
});
|
|
1338
|
+
if (options.onPairingClaimed) {
|
|
1339
|
+
const timer = setTimeout(() => {
|
|
1340
|
+
void options.onPairingClaimed?.();
|
|
1341
|
+
}, 250);
|
|
1342
|
+
timer.unref?.();
|
|
1343
|
+
}
|
|
1018
1344
|
});
|
|
1019
1345
|
router.get("/api/v1/auth/me", async (ctx) => {
|
|
1020
|
-
const auth = await authenticateRequest(ctx);
|
|
1021
|
-
const identity = await loadRequiredIdentity2();
|
|
1346
|
+
const auth = await authenticateRequest(ctx, paths);
|
|
1347
|
+
const identity = await loadRequiredIdentity2(paths);
|
|
1022
1348
|
ctx.body = {
|
|
1023
1349
|
ok: true,
|
|
1024
1350
|
auth: { kind: auth.kind, account_id: auth.accountId ?? null },
|
|
@@ -1041,7 +1367,7 @@ async function createApp() {
|
|
|
1041
1367
|
if (!refreshToken) {
|
|
1042
1368
|
throw new LinkHttpError(400, "refresh_token_required", "refresh_token is required");
|
|
1043
1369
|
}
|
|
1044
|
-
const session = await refreshDeviceSession(refreshToken);
|
|
1370
|
+
const session = await refreshDeviceSession(refreshToken, paths);
|
|
1045
1371
|
ctx.body = {
|
|
1046
1372
|
ok: true,
|
|
1047
1373
|
device: session.device,
|
|
@@ -1059,13 +1385,13 @@ async function createApp() {
|
|
|
1059
1385
|
const body = await readJsonBody(ctx.req);
|
|
1060
1386
|
const refreshToken = readString2(body, "refresh_token") ?? readString2(body, "refreshToken");
|
|
1061
1387
|
if (refreshToken) {
|
|
1062
|
-
await revokeDeviceRefreshToken(refreshToken);
|
|
1388
|
+
await revokeDeviceRefreshToken(refreshToken, paths);
|
|
1063
1389
|
}
|
|
1064
1390
|
ctx.body = { ok: true };
|
|
1065
1391
|
});
|
|
1066
1392
|
router.get("/api/v1/status", async (ctx) => {
|
|
1067
|
-
await authenticateRequest(ctx);
|
|
1068
|
-
const [identity, config] = await Promise.all([loadIdentity(), loadConfig()]);
|
|
1393
|
+
await authenticateRequest(ctx, paths);
|
|
1394
|
+
const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
|
|
1069
1395
|
ctx.body = {
|
|
1070
1396
|
ok: true,
|
|
1071
1397
|
version: LINK_VERSION,
|
|
@@ -1074,12 +1400,23 @@ async function createApp() {
|
|
|
1074
1400
|
port: config.port
|
|
1075
1401
|
};
|
|
1076
1402
|
});
|
|
1403
|
+
router.get("/api/v1/logs", async (ctx) => {
|
|
1404
|
+
await authenticateRequest(ctx, paths);
|
|
1405
|
+
ctx.set("cache-control", "no-store");
|
|
1406
|
+
ctx.body = {
|
|
1407
|
+
ok: true,
|
|
1408
|
+
logs: await readRecentLogEntries({
|
|
1409
|
+
paths,
|
|
1410
|
+
limit: readLimit(ctx.query.limit)
|
|
1411
|
+
})
|
|
1412
|
+
};
|
|
1413
|
+
});
|
|
1077
1414
|
router.get("/api/v1/models", async (ctx) => {
|
|
1078
|
-
await authenticateRequest(ctx);
|
|
1415
|
+
await authenticateRequest(ctx, paths);
|
|
1079
1416
|
ctx.body = await listHermesModels();
|
|
1080
1417
|
});
|
|
1081
1418
|
router.post("/api/v1/runs", async (ctx) => {
|
|
1082
|
-
await authenticateRequest(ctx);
|
|
1419
|
+
await authenticateRequest(ctx, paths);
|
|
1083
1420
|
const body = await readJsonBody(ctx.req);
|
|
1084
1421
|
const input = readString2(body, "input");
|
|
1085
1422
|
if (!input) {
|
|
@@ -1093,7 +1430,7 @@ async function createApp() {
|
|
|
1093
1430
|
});
|
|
1094
1431
|
});
|
|
1095
1432
|
router.get("/api/v1/runs/:runId/events", async (ctx) => {
|
|
1096
|
-
await authenticateRequest(ctx);
|
|
1433
|
+
await authenticateRequest(ctx, paths);
|
|
1097
1434
|
const response = await streamHermesRunEvents(ctx.params.runId);
|
|
1098
1435
|
ctx.status = response.status;
|
|
1099
1436
|
for (const [key, value] of response.headers.entries()) {
|
|
@@ -1112,12 +1449,12 @@ async function createApp() {
|
|
|
1112
1449
|
}
|
|
1113
1450
|
});
|
|
1114
1451
|
router.post("/api/v1/runs/:runId/cancel", async (ctx) => {
|
|
1115
|
-
await authenticateRequest(ctx);
|
|
1452
|
+
await authenticateRequest(ctx, paths);
|
|
1116
1453
|
await cancelHermesRun(ctx.params.runId);
|
|
1117
1454
|
ctx.body = { ok: true };
|
|
1118
1455
|
});
|
|
1119
1456
|
router.get("/api/v1/profiles", async (ctx) => {
|
|
1120
|
-
await authenticateRequest(ctx);
|
|
1457
|
+
await authenticateRequest(ctx, paths);
|
|
1121
1458
|
ctx.set("cache-control", "no-store");
|
|
1122
1459
|
ctx.body = {
|
|
1123
1460
|
ok: true,
|
|
@@ -1125,7 +1462,7 @@ async function createApp() {
|
|
|
1125
1462
|
};
|
|
1126
1463
|
});
|
|
1127
1464
|
router.get("/api/v1/profiles/:name/status", async (ctx) => {
|
|
1128
|
-
await authenticateRequest(ctx);
|
|
1465
|
+
await authenticateRequest(ctx, paths);
|
|
1129
1466
|
ctx.set("cache-control", "no-store");
|
|
1130
1467
|
ctx.body = {
|
|
1131
1468
|
ok: true,
|
|
@@ -1133,7 +1470,7 @@ async function createApp() {
|
|
|
1133
1470
|
};
|
|
1134
1471
|
});
|
|
1135
1472
|
router.post("/api/v1/profiles", async (ctx) => {
|
|
1136
|
-
await authenticateRequest(ctx);
|
|
1473
|
+
await authenticateRequest(ctx, paths);
|
|
1137
1474
|
const body = await readJsonBody(ctx.req);
|
|
1138
1475
|
const name = readProfileName(body);
|
|
1139
1476
|
ctx.status = 201;
|
|
@@ -1143,14 +1480,14 @@ async function createApp() {
|
|
|
1143
1480
|
};
|
|
1144
1481
|
});
|
|
1145
1482
|
router.post("/api/v1/profiles/:name/use", async (ctx) => {
|
|
1146
|
-
await authenticateRequest(ctx);
|
|
1483
|
+
await authenticateRequest(ctx, paths);
|
|
1147
1484
|
ctx.body = {
|
|
1148
1485
|
ok: true,
|
|
1149
1486
|
profile: await useHermesProfile(ctx.params.name)
|
|
1150
1487
|
};
|
|
1151
1488
|
});
|
|
1152
1489
|
router.patch("/api/v1/profiles/:name", async (ctx) => {
|
|
1153
|
-
await authenticateRequest(ctx);
|
|
1490
|
+
await authenticateRequest(ctx, paths);
|
|
1154
1491
|
const body = await readJsonBody(ctx.req);
|
|
1155
1492
|
const name = readProfileName(body);
|
|
1156
1493
|
ctx.body = {
|
|
@@ -1159,7 +1496,7 @@ async function createApp() {
|
|
|
1159
1496
|
};
|
|
1160
1497
|
});
|
|
1161
1498
|
router.delete("/api/v1/profiles/:name", async (ctx) => {
|
|
1162
|
-
await authenticateRequest(ctx);
|
|
1499
|
+
await authenticateRequest(ctx, paths);
|
|
1163
1500
|
await deleteHermesProfile(ctx.params.name);
|
|
1164
1501
|
ctx.status = 204;
|
|
1165
1502
|
});
|
|
@@ -1183,24 +1520,24 @@ function readProfileName(body) {
|
|
|
1183
1520
|
}
|
|
1184
1521
|
return body.name;
|
|
1185
1522
|
}
|
|
1186
|
-
async function authenticateRequest(ctx) {
|
|
1523
|
+
async function authenticateRequest(ctx, paths) {
|
|
1187
1524
|
const token = readBearerToken(ctx.get("authorization"));
|
|
1188
1525
|
if (!token) {
|
|
1189
1526
|
throw new LinkHttpError(401, "auth_required", "Authorization bearer token is required");
|
|
1190
1527
|
}
|
|
1191
|
-
const device = await authenticateDeviceAccessToken(token);
|
|
1528
|
+
const device = await authenticateDeviceAccessToken(token, paths);
|
|
1192
1529
|
if (device) {
|
|
1193
1530
|
return { kind: "device", device };
|
|
1194
1531
|
}
|
|
1195
|
-
const [identity, config] = await Promise.all([loadRequiredIdentity2(), loadConfig()]);
|
|
1532
|
+
const [identity, config] = await Promise.all([loadRequiredIdentity2(paths), loadConfig(paths)]);
|
|
1196
1533
|
const claims = await verifyAppConnectToken(token, {
|
|
1197
1534
|
config,
|
|
1198
1535
|
linkId: identity.link_id
|
|
1199
1536
|
});
|
|
1200
1537
|
return { kind: "app-connect", accountId: claims.sub };
|
|
1201
1538
|
}
|
|
1202
|
-
async function loadRequiredIdentity2() {
|
|
1203
|
-
const identity = await loadIdentity();
|
|
1539
|
+
async function loadRequiredIdentity2(paths) {
|
|
1540
|
+
const identity = await loadIdentity(paths);
|
|
1204
1541
|
if (!identity?.link_id) {
|
|
1205
1542
|
throw new LinkHttpError(409, "link_not_paired", "Hermes Link is not paired");
|
|
1206
1543
|
}
|
|
@@ -1218,6 +1555,14 @@ function readString2(body, key) {
|
|
|
1218
1555
|
const value = body[key];
|
|
1219
1556
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
1220
1557
|
}
|
|
1558
|
+
function readLimit(value) {
|
|
1559
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
1560
|
+
if (typeof raw !== "string") {
|
|
1561
|
+
return void 0;
|
|
1562
|
+
}
|
|
1563
|
+
const parsed = Number.parseInt(raw, 10);
|
|
1564
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
1565
|
+
}
|
|
1221
1566
|
function readConversationHistory(value) {
|
|
1222
1567
|
if (!Array.isArray(value)) {
|
|
1223
1568
|
return [];
|
|
@@ -1252,6 +1597,8 @@ export {
|
|
|
1252
1597
|
ensureIdentity,
|
|
1253
1598
|
getIdentityStatus,
|
|
1254
1599
|
preparePairing,
|
|
1600
|
+
createFileLogger,
|
|
1601
|
+
getLinkLogFile,
|
|
1255
1602
|
createApp
|
|
1256
1603
|
};
|
|
1257
|
-
//# sourceMappingURL=chunk-
|
|
1604
|
+
//# sourceMappingURL=chunk-7M3UZCA7.js.map
|