@passioncode-ai/fabric-agent-adapter 0.73.0 → 0.74.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/CHANGELOG.md +114 -0
- package/README.md +4 -3
- package/SECURITY.md +17 -0
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +6 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +14 -15
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +10 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +33 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +6 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +19 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +37 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +38 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +53 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +545 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +43 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +29 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +13 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +845 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +35 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +122 -9
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +6 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
// Which OS account owns each end of a loopback TCP connection (DEC-0154 HA-2, FAA-226, layer L0); the twin of
|
|
2
|
+
// fabric_peer_owner.py, which is Project Observatory's peer_owner 1.1.0 vendored by commit. Node 18+, no dependency;
|
|
3
|
+
// nothing here imports another kit module.
|
|
4
|
+
//
|
|
5
|
+
// WHY. Loopback is one per computer, not one per OS account: every account's processes reach every listener on
|
|
6
|
+
// 127.0.0.1, so a loopback socket proves nothing about who is on the other end. A client about to send a credential
|
|
7
|
+
// to http://127.0.0.1:<port>, and a host about to call a local service healthy, first tells the owner of the server
|
|
8
|
+
// end of its own connection from the computer's socket table, read without privileges:
|
|
9
|
+
//
|
|
10
|
+
// await listener(port) who listens on 127.0.0.1:<port> (also 0.0.0.0, ::1, ::)
|
|
11
|
+
// await serverEnd(socket) for a client's connected socket: who accepted it
|
|
12
|
+
// await peer(socket) for a server's accepted socket: who connected
|
|
13
|
+
// await peerAt(server, client) the same as peer, from two [host, port] addresses
|
|
14
|
+
// await checkedSocket(port, opts) a connected net.Socket whose server end is this account's, or ListenerRefused
|
|
15
|
+
// (the socket destroyed) before a byte is written; hand it to http.request through
|
|
16
|
+
// `createConnection: () => socket`
|
|
17
|
+
// new CheckedAgent(opts) an http.Agent whose every new socket is a checkedSocket: a kept-alive socket is
|
|
18
|
+
// checked once, when it is made, since an established connection's server end
|
|
19
|
+
// cannot change (a reading of the table costs about 35 ms here, Python's sysctl 4)
|
|
20
|
+
//
|
|
21
|
+
// `owner.kind` is this-account, another-account, system (uid 0, pid 1, Windows' pid 0 and 4 and its own accounts),
|
|
22
|
+
// unknown (`reason` says why) or none (listener only: nothing listens). A caller that must not leak treats everything
|
|
23
|
+
// but this-account as a refusal, unknown included: fail closed.
|
|
24
|
+
//
|
|
25
|
+
// HOW, ON EACH OS. Node has no ctypes, so it reads what the Python twin keeps as its fallback or reads the same files:
|
|
26
|
+
// - macOS: `/usr/sbin/netstat -anv -p tcp`, its process:pid column, then `ps -o uid= -p PID` for the one row a
|
|
27
|
+
// question selects (Observatory measured about 20 ms over 846 sockets; it names root's listeners too). The uid is
|
|
28
|
+
// read a few milliseconds after the pid: a pid reused in between would be misread, a window the Python twin's sysctl
|
|
29
|
+
// reader does not have (it reads both from one record).
|
|
30
|
+
// - Linux: `/proc/net/tcp` and `/proc/net/tcp6`, whose uid column every account may read.
|
|
31
|
+
// - Windows: `netstat -ano -p TCP` and `-p TCPv6` for the owning pid (a row whose peer port is 0 listens; a connected
|
|
32
|
+
// row of pid 0 is TIME_WAIT, the idle process's, never an answer; the state column is localized and not read), then
|
|
33
|
+
// the owner's SID from PowerShell (`Win32_Process`'s `GetOwnerSid`), compared with this user's from
|
|
34
|
+
// `whoami /user /fo csv /nh`. SIDs, never names: a name in the console's code page can decode to the same string for
|
|
35
|
+
// two accounts. A process whose SID cannot be read (another account's, usually, without the right to open it) is
|
|
36
|
+
// unknown.
|
|
37
|
+
// Every read is bounded (MAX_TABLE_BYTES, a timeout per program) and never cached across connections; only listener
|
|
38
|
+
// is cached, for LISTENER_TTL_MS. A TIME_WAIT row is never an answer; two live rows with the same addresses are
|
|
39
|
+
// unknown (several-rows), never a guess. It never reads a byte from the socket it is asked about and needs no
|
|
40
|
+
// privilege. The parsers are pure functions over captured text (test/fixtures/peer-owner/).
|
|
41
|
+
|
|
42
|
+
import { execFile } from 'node:child_process';
|
|
43
|
+
import { open } from 'node:fs/promises';
|
|
44
|
+
import http from 'node:http';
|
|
45
|
+
import net from 'node:net';
|
|
46
|
+
import os from 'node:os';
|
|
47
|
+
|
|
48
|
+
export const PEER_OWNER_VERSION = '1.1.0+kit.1'; // the Python twin's, Observatory's 1.1.0 with the kit's differences
|
|
49
|
+
export const THIS_ACCOUNT = 'this-account';
|
|
50
|
+
export const ANOTHER_ACCOUNT = 'another-account';
|
|
51
|
+
export const SYSTEM = 'system';
|
|
52
|
+
export const UNKNOWN = 'unknown';
|
|
53
|
+
export const NONE = 'none';
|
|
54
|
+
export const KINDS = Object.freeze([THIS_ACCOUNT, ANOTHER_ACCOUNT, SYSTEM, UNKNOWN, NONE]);
|
|
55
|
+
export const MAX_TABLE_BYTES = 4 * 1024 * 1024;
|
|
56
|
+
export const TIMEOUT_MS = 2000;
|
|
57
|
+
export const WINDOWS_TIMEOUT_MS = 5000;
|
|
58
|
+
export const LISTENER_TTL_MS = 1000;
|
|
59
|
+
export const NETSTAT = '/usr/sbin/netstat';
|
|
60
|
+
export const PS = '/bin/ps';
|
|
61
|
+
export const SERVER_NOT_OWN = 'server-not-own';
|
|
62
|
+
export const SERVER_UNVERIFIED = 'server-unverified';
|
|
63
|
+
export const SERVER_NOT_THIS_WORKSPACE = 'server-not-this-workspace';
|
|
64
|
+
|
|
65
|
+
export function owner(kind, { uid = null, pid = null, method = 'none', reason = null } = {}) {
|
|
66
|
+
return Object.freeze({ kind, uid, pid, method, reason });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class TableUnreadable extends Error {
|
|
70
|
+
constructor(message) { super(message); this.name = 'TableUnreadable'; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class ListenerRefused extends Error {
|
|
74
|
+
constructor(code, message, who = null) { super(message); this.name = 'ListenerRefused'; this.code = code; this.owner = who; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// -- addresses --------------------------------------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
// One spelling per address: an IPv4-mapped IPv6 address is its IPv4 form, the unspecified address is `*`, localhost is
|
|
80
|
+
// 127.0.0.1, an IPv6 address is RFC 5952's (as the Python twin's ipaddress prints it). Anything else stays as it is.
|
|
81
|
+
export function normHost(host) {
|
|
82
|
+
let h = String(host ?? '');
|
|
83
|
+
if (h === '*' || h === '') return '*';
|
|
84
|
+
if (h === 'localhost') return '127.0.0.1';
|
|
85
|
+
h = h.split('%', 1)[0];
|
|
86
|
+
if (net.isIPv4(h)) return h === '0.0.0.0' ? '*' : h.split('.').map((x) => String(Number(x))).join('.');
|
|
87
|
+
if (!net.isIPv6(h)) return h;
|
|
88
|
+
const text = new URL(`http://[${h}]/`).hostname.slice(1, -1);
|
|
89
|
+
if (text === '::') return '*';
|
|
90
|
+
const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(text);
|
|
91
|
+
if (mapped) {
|
|
92
|
+
const hi = parseInt(mapped[1], 16);
|
|
93
|
+
const lo = parseInt(mapped[2], 16);
|
|
94
|
+
const v4 = [hi >> 8, hi & 255, lo >> 8, lo & 255].join('.');
|
|
95
|
+
return v4 === '0.0.0.0' ? '*' : v4;
|
|
96
|
+
}
|
|
97
|
+
return text;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const address = (host, port) => [normHost(host), Number(port)];
|
|
101
|
+
const same = (a, b) => a[0] === b[0] && a[1] === b[1];
|
|
102
|
+
|
|
103
|
+
// -- classification ---------------------------------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
function ownUid() {
|
|
106
|
+
return typeof process.getuid === 'function' ? process.getuid() : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function classify(uid, pid, { method, ownUid: own } = {}) {
|
|
110
|
+
if (uid === null || uid === undefined) return owner(UNKNOWN, { pid, method, reason: 'no-owner' });
|
|
111
|
+
const mine = own === undefined || own === null ? ownUid() : own;
|
|
112
|
+
// this process's own uid first: a client running as root owns root's listener (pid 1, launchd, stays system)
|
|
113
|
+
if (mine !== null && uid === mine && pid !== 1) return owner(THIS_ACCOUNT, { uid, pid, method });
|
|
114
|
+
if (uid === 0 || pid === 1) return owner(SYSTEM, { uid, pid, method });
|
|
115
|
+
return owner(ANOTHER_ACCOUNT, { uid, pid, method });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Windows, as the Python twin's classify_sid: the System process (pid 0 and 4) and the system's own accounts
|
|
119
|
+
// (LocalSystem, LocalService, NetworkService) are system, this process's SID this-account, any other SID
|
|
120
|
+
// another-account, a SID that could not be read unknown.
|
|
121
|
+
const SYSTEM_SIDS = new Set(['S-1-5-18', 'S-1-5-19', 'S-1-5-20']);
|
|
122
|
+
const SID = /^S-1-[0-9]+(-[0-9]+)+$/;
|
|
123
|
+
|
|
124
|
+
export function classifySid(sid, pid, { ownSid, method = 'netstat-windows' } = {}) {
|
|
125
|
+
if (pid === 0 || pid === 4) return owner(SYSTEM, { pid, method });
|
|
126
|
+
if (typeof sid !== 'string' || !SID.test(sid)) return owner(UNKNOWN, { pid, method, reason: 'process-not-readable' });
|
|
127
|
+
if (typeof ownSid === 'string' && SID.test(ownSid) && sid === ownSid) return owner(THIS_ACCOUNT, { pid, method });
|
|
128
|
+
if (SYSTEM_SIDS.has(sid)) return owner(SYSTEM, { pid, method });
|
|
129
|
+
return owner(ANOTHER_ACCOUNT, { pid, method });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// -- parsers: pure functions over captured text (the fixtures) --------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
function netstatAddress(text) {
|
|
135
|
+
const at = text.lastIndexOf('.');
|
|
136
|
+
if (at < 0) return null;
|
|
137
|
+
const port = text.slice(at + 1);
|
|
138
|
+
if (port !== '*' && !/^[0-9]+$/.test(port)) return null;
|
|
139
|
+
return [normHost(text.slice(0, at)), port === '*' ? 0 : Number(port)];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// `netstat -anv -p tcp` on macOS. Process names may hold spaces, so the process:pid column is what is left between the
|
|
143
|
+
// leading and the trailing columns, both counted from the header; a header without process:pid is unreadable.
|
|
144
|
+
export function parseNetstat(text) {
|
|
145
|
+
const lines = String(text).split(/\r?\n/);
|
|
146
|
+
const header = lines.findIndex((line) => line.includes('process:pid') && line.includes('Proto'));
|
|
147
|
+
if (header < 0) throw new TableUnreadable('netstat-format: no process:pid column');
|
|
148
|
+
const names = lines[header].split(/\s+/).filter(Boolean);
|
|
149
|
+
const at = names.indexOf('process:pid');
|
|
150
|
+
const leading = at - names.slice(0, at).filter((w) => w === 'Address').length;
|
|
151
|
+
const trailing = names.length - at - 1;
|
|
152
|
+
const rows = [];
|
|
153
|
+
for (const line of lines.slice(header + 1)) {
|
|
154
|
+
const tok = line.split(/\s+/).filter(Boolean);
|
|
155
|
+
if (tok.length < leading + trailing + 1 || !tok[0].startsWith('tcp')) continue;
|
|
156
|
+
const local = netstatAddress(tok[3]);
|
|
157
|
+
const remote = netstatAddress(tok[4]);
|
|
158
|
+
if (!local || !remote) continue;
|
|
159
|
+
const proc = tok.slice(leading, tok.length - trailing).join(' ');
|
|
160
|
+
let pid = null;
|
|
161
|
+
const colon = proc.lastIndexOf(':');
|
|
162
|
+
if (colon >= 0 && /^[0-9]+$/.test(proc.slice(colon + 1))) pid = Number(proc.slice(colon + 1));
|
|
163
|
+
rows.push({ local, remote, listening: tok[5] === 'LISTEN', uid: null, pid, timeWait: tok[5] === 'TIME_WAIT' });
|
|
164
|
+
}
|
|
165
|
+
return rows;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// `ps -o uid= -p PID`: one number, or nothing when the process is gone.
|
|
169
|
+
export function parsePsUid(text) {
|
|
170
|
+
const t = String(text).trim();
|
|
171
|
+
return /^[0-9]+$/.test(t) ? Number(t) : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function procAddress(text, family, order) {
|
|
175
|
+
const [host, port] = text.split(':');
|
|
176
|
+
if (port === undefined || !/^[0-9A-Fa-f]+$/.test(host) || !/^[0-9A-Fa-f]+$/.test(port)) throw new Error('address');
|
|
177
|
+
let raw = Buffer.from(host, 'hex');
|
|
178
|
+
if (order === 'little') {
|
|
179
|
+
const out = Buffer.alloc(raw.length);
|
|
180
|
+
for (let i = 0; i < raw.length; i += 4) raw.subarray(i, i + 4).reverse().copy(out, i);
|
|
181
|
+
raw = out;
|
|
182
|
+
}
|
|
183
|
+
if (raw.length !== (family === 4 ? 4 : 16)) throw new Error('address length');
|
|
184
|
+
let text4or6;
|
|
185
|
+
if (family === 4) text4or6 = [...raw].join('.');
|
|
186
|
+
else text4or6 = Array.from({ length: 8 }, (_, i) => raw.readUInt16BE(i * 2).toString(16)).join(':');
|
|
187
|
+
return [normHost(text4or6), parseInt(port, 16)];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// `/proc/net/tcp` (family 4) or `/proc/net/tcp6` (6). The kernel prints each 32-bit word of an address in host byte
|
|
191
|
+
// order and each port in network order, in hex. A connected row with inode 0 has no socket yet (not accept()ed, or
|
|
192
|
+
// already closed) and the kernel prints uid 0 for it: its uid is not read (null), and ownerOf takes its listener's.
|
|
193
|
+
export function parseProcNetTcp(text, { family, byteorder = os.endianness() === 'LE' ? 'little' : 'big' } = {}) {
|
|
194
|
+
const rows = [];
|
|
195
|
+
for (const line of String(text).split(/\r?\n/).slice(1)) {
|
|
196
|
+
const tok = line.split(/\s+/).filter(Boolean);
|
|
197
|
+
if (tok.length < 10 || !tok[0].endsWith(':')) continue;
|
|
198
|
+
let local;
|
|
199
|
+
let remote;
|
|
200
|
+
try {
|
|
201
|
+
local = procAddress(tok[1], family, byteorder);
|
|
202
|
+
remote = procAddress(tok[2], family, byteorder);
|
|
203
|
+
} catch {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (!/^[0-9]+$/.test(tok[7])) continue;
|
|
207
|
+
const st = tok[3].toUpperCase();
|
|
208
|
+
const listening = st === '0A';
|
|
209
|
+
rows.push({ local, remote, listening, uid: !listening && tok[9] === '0' ? null : Number(tok[7]), pid: null, timeWait: st === '06' });
|
|
210
|
+
}
|
|
211
|
+
return rows;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function windowsAddress(text) {
|
|
215
|
+
const m = /^\[(.*)\]:([0-9]+)$/.exec(text) ?? /^([^:]*):([0-9]+)$/.exec(text);
|
|
216
|
+
return m ? [normHost(m[1]), Number(m[2])] : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// `netstat -ano -p TCP` (or TCPv6) on Windows: proto, local, foreign, a localized state, pid. The state is not read:
|
|
220
|
+
// a row whose peer port is 0 listens, and a connected row of pid 0 is TIME_WAIT (the idle process holds those).
|
|
221
|
+
export function parseWindowsNetstat(text) {
|
|
222
|
+
const rows = [];
|
|
223
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
224
|
+
const tok = line.trim().split(/\s+/);
|
|
225
|
+
if (tok.length < 4 || tok[0].toUpperCase() !== 'TCP') continue;
|
|
226
|
+
const local = windowsAddress(tok[1]);
|
|
227
|
+
const remote = windowsAddress(tok[2]);
|
|
228
|
+
const last = tok[tok.length - 1];
|
|
229
|
+
if (!local || !remote || !/^[0-9]+$/.test(last)) continue;
|
|
230
|
+
const pid = Number(last);
|
|
231
|
+
const listening = remote[1] === 0;
|
|
232
|
+
rows.push({ local, remote, listening, uid: null, pid, timeWait: !listening && pid === 0 });
|
|
233
|
+
}
|
|
234
|
+
return rows;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// `whoami /user /fo csv /nh`: `"<domain>\\<name>","S-1-5-..."`. The name is read in the console's code page and never
|
|
238
|
+
// used; the SID is ASCII. Null when no SID is there.
|
|
239
|
+
export function parseWhoamiSid(text) {
|
|
240
|
+
const m = /"(S-1-[0-9]+(?:-[0-9]+)+)"\s*$/m.exec(String(text));
|
|
241
|
+
return m ? m[1] : null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// What the owner-SID script prints: one SID, or nothing when the process is gone or cannot be opened.
|
|
245
|
+
export function parseOwnerSid(text) {
|
|
246
|
+
const t = String(text).trim();
|
|
247
|
+
return SID.test(t) ? t : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The PowerShell that prints a process's owner SID (pid an integer, checked by the caller).
|
|
251
|
+
export function ownerSidScript(pid) {
|
|
252
|
+
return `$p = Get-CimInstance -ClassName Win32_Process -Filter 'ProcessId=${Number(pid)}'; `
|
|
253
|
+
+ 'if ($p) { $r = Invoke-CimMethod -InputObject $p -MethodName GetOwnerSid; if ($r.ReturnValue -eq 0) { $r.Sid } }';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// -- readers: the live table on this host ------------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
// Run one program, bounded; the tests pass their own `run` with the same shape.
|
|
259
|
+
export function runProgram(file, args, timeout = TIMEOUT_MS) {
|
|
260
|
+
return new Promise((resolve, reject) => {
|
|
261
|
+
execFile(file, args, { timeout, maxBuffer: MAX_TABLE_BYTES, windowsHide: true, encoding: 'utf8' },
|
|
262
|
+
(err, stdout) => {
|
|
263
|
+
const base = file.split(/[\\/]/).pop();
|
|
264
|
+
let why = typeof err?.code === 'number' ? `exit ${err.code}` : (err?.killed ? 'timeout' : (err?.code ?? 'error'));
|
|
265
|
+
if (err?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') why = `table over ${MAX_TABLE_BYTES} bytes`;
|
|
266
|
+
if (err) reject(new TableUnreadable(`${base}: ${why}`));
|
|
267
|
+
else resolve(stdout);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function method() {
|
|
273
|
+
if (process.platform === 'darwin') return 'netstat';
|
|
274
|
+
if (process.platform === 'linux') return 'proc';
|
|
275
|
+
if (process.platform === 'win32') return 'netstat-windows';
|
|
276
|
+
return 'none';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// A /proc file read to its end. Node 18's readFile stops after the first page of a file that reports size 0, about
|
|
280
|
+
// 4 KB, so every socket past the 26th or so read as no-row (0.74.0 seam reading 5); read until EOF, one byte past the
|
|
281
|
+
// cap at most.
|
|
282
|
+
async function readProc(name) {
|
|
283
|
+
const fh = await open(name, 'r');
|
|
284
|
+
const chunks = [];
|
|
285
|
+
let total = 0;
|
|
286
|
+
try {
|
|
287
|
+
for (;;) {
|
|
288
|
+
const buf = Buffer.alloc(65536);
|
|
289
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, null);
|
|
290
|
+
if (!bytesRead) break;
|
|
291
|
+
chunks.push(buf.subarray(0, bytesRead));
|
|
292
|
+
total += bytesRead;
|
|
293
|
+
if (total > MAX_TABLE_BYTES) break;
|
|
294
|
+
}
|
|
295
|
+
} finally {
|
|
296
|
+
await fh.close();
|
|
297
|
+
}
|
|
298
|
+
return Buffer.concat(chunks);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function readTable({ run = runProgram } = {}) {
|
|
302
|
+
const how = method();
|
|
303
|
+
if (how === 'netstat') return { rows: parseNetstat(await run(NETSTAT, ['-anv', '-p', 'tcp'])), how };
|
|
304
|
+
if (how === 'proc') {
|
|
305
|
+
const rows = [];
|
|
306
|
+
for (const [name, family] of [['/proc/net/tcp', 4], ['/proc/net/tcp6', 6]]) {
|
|
307
|
+
let raw;
|
|
308
|
+
try {
|
|
309
|
+
raw = await readProc(name);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
if (err.code === 'ENOENT') continue; // no IPv6 on this kernel
|
|
312
|
+
throw new TableUnreadable(`proc: ${err.code ?? 'error'}`);
|
|
313
|
+
}
|
|
314
|
+
if (raw.length > MAX_TABLE_BYTES) throw new TableUnreadable(`proc: table over ${MAX_TABLE_BYTES} bytes`);
|
|
315
|
+
rows.push(...parseProcNetTcp(raw.toString('latin1'), { family }));
|
|
316
|
+
}
|
|
317
|
+
return { rows, how };
|
|
318
|
+
}
|
|
319
|
+
if (how === 'netstat-windows') {
|
|
320
|
+
const rows = [];
|
|
321
|
+
for (const proto of ['TCP', 'TCPv6']) rows.push(...parseWindowsNetstat(await run('netstat', ['-ano', '-p', proto], WINDOWS_TIMEOUT_MS)));
|
|
322
|
+
return { rows, how };
|
|
323
|
+
}
|
|
324
|
+
throw new TableUnreadable('unsupported-platform');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let windowsSid = null;
|
|
328
|
+
|
|
329
|
+
async function windowsOwner(pid, how, run, ownSid) {
|
|
330
|
+
if (pid === 0 || pid === 4) return classifySid(null, pid, { method: how });
|
|
331
|
+
if (!Number.isInteger(pid) || pid < 0) return owner(UNKNOWN, { pid, method: how, reason: 'no-pid' });
|
|
332
|
+
try {
|
|
333
|
+
if (ownSid === undefined) {
|
|
334
|
+
if (run === runProgram) ownSid = windowsSid ??= parseWhoamiSid(await run('whoami', ['/user', '/fo', 'csv', '/nh'], WINDOWS_TIMEOUT_MS));
|
|
335
|
+
else ownSid = parseWhoamiSid(await run('whoami', ['/user', '/fo', 'csv', '/nh'], WINDOWS_TIMEOUT_MS));
|
|
336
|
+
}
|
|
337
|
+
} catch (err) {
|
|
338
|
+
return owner(UNKNOWN, { pid, method: how, reason: `own-sid: ${err.message}` });
|
|
339
|
+
}
|
|
340
|
+
if (!ownSid) return owner(UNKNOWN, { pid, method: how, reason: 'own-sid: unreadable' });
|
|
341
|
+
let sid = null;
|
|
342
|
+
try {
|
|
343
|
+
sid = parseOwnerSid(await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ownerSidScript(pid)], WINDOWS_TIMEOUT_MS));
|
|
344
|
+
} catch {
|
|
345
|
+
sid = null;
|
|
346
|
+
}
|
|
347
|
+
return classifySid(sid, pid, { ownSid, method: how });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// The owner of one selected row: its uid when the table carried one, else the pid's. `run`, `ownUid` and `ownSid`
|
|
351
|
+
// (Windows) are the tests' fixtures; by default the programs themselves and this process's own.
|
|
352
|
+
export async function resolve(row, how, { run = runProgram, ownUid: mine, ownSid } = {}) {
|
|
353
|
+
if (how === 'netstat-windows') return windowsOwner(row.pid, how, run, ownSid);
|
|
354
|
+
if (row.uid !== null && row.uid !== undefined) return classify(row.uid, row.pid, { method: how, ownUid: mine });
|
|
355
|
+
if (row.pid === null || row.pid <= 0) return owner(UNKNOWN, { pid: row.pid, method: how, reason: 'no-pid' });
|
|
356
|
+
if (row.pid === 1) return owner(SYSTEM, { pid: 1, method: how });
|
|
357
|
+
let uid;
|
|
358
|
+
try {
|
|
359
|
+
uid = parsePsUid(await run(PS, ['-o', 'uid=', '-p', String(row.pid)]));
|
|
360
|
+
} catch (err) {
|
|
361
|
+
return owner(UNKNOWN, { pid: row.pid, method: how, reason: `ps: ${err.message}` });
|
|
362
|
+
}
|
|
363
|
+
if (uid === null) return owner(UNKNOWN, { pid: row.pid, method: how, reason: 'process-gone' });
|
|
364
|
+
return classify(uid, row.pid, { method: how, ownUid: mine });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// -- the questions ----------------------------------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
// The live connected rows with exactly this local and remote address: one, or the caller cannot tell.
|
|
370
|
+
// A row printed twice is one socket: Linux resumes a /proc read that sockets came and went under by position, and can
|
|
371
|
+
// print the same row again (0.74.0 seam reading 6); rows equal in every field count once.
|
|
372
|
+
export function find(rows, local, remote) {
|
|
373
|
+
const seen = new Set();
|
|
374
|
+
return rows.filter((r) => {
|
|
375
|
+
if (r.listening || r.timeWait || !same(r.local, local) || !same(r.remote, remote)) return false;
|
|
376
|
+
const key = JSON.stringify(r);
|
|
377
|
+
if (seen.has(key)) return false;
|
|
378
|
+
seen.add(key);
|
|
379
|
+
return true;
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function ownerOf(local, remote, opts = {}, server = false) {
|
|
384
|
+
let table;
|
|
385
|
+
let found;
|
|
386
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
387
|
+
try {
|
|
388
|
+
table = await (opts.read ?? readTable)(opts);
|
|
389
|
+
} catch (err) {
|
|
390
|
+
return owner(UNKNOWN, { method: method(), reason: err instanceof TableUnreadable ? err.message : `table: ${err.name}` });
|
|
391
|
+
}
|
|
392
|
+
found = find(table.rows, local, remote);
|
|
393
|
+
// A /proc read under churn can skip a row or print one twice; one more read before answering unknown.
|
|
394
|
+
if (table.how !== 'proc' || found.length === 1) break;
|
|
395
|
+
}
|
|
396
|
+
if (found.length === 0) return owner(UNKNOWN, { method: table.how, reason: 'no-row' });
|
|
397
|
+
if (found.length > 1) return owner(UNKNOWN, { method: table.how, reason: 'several-rows' });
|
|
398
|
+
if (table.how === 'proc' && server && found[0].uid === null) {
|
|
399
|
+
// Linux: a connection not yet accept()ed is printed with uid 0 and inode 0; it belongs to the listener on its local
|
|
400
|
+
// address and dies with it, so that listener answers. An accepted connection's own row carries the uid of the
|
|
401
|
+
// process that accepted it: it answers itself, so a listener swapped after another account accepted the client
|
|
402
|
+
// cannot pass (Project Observatory's 1.2.0 reading, board thread ced00f6e; the contract's pending CT-254, FAA-258).
|
|
403
|
+
// A server row its owner already closed (an orphan) also has inode 0 and reads so: no process holds it, so nothing
|
|
404
|
+
// sent to it reaches anyone (0.74.0 unit reading 6).
|
|
405
|
+
return oneOwner(listenersOf(table.rows, found[0].local), table.how, opts, 'not-accepted');
|
|
406
|
+
}
|
|
407
|
+
// A client's socket with inode 0 has already closed (0.74.0 unit reading 4): never another socket's owner.
|
|
408
|
+
if (table.how === 'proc' && found[0].uid === null) return owner(UNKNOWN, { method: table.how, reason: 'closed' });
|
|
409
|
+
return resolve(found[0], table.how, opts);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// The listeners that took a connection to `local`: on its exact address, else the wildcard.
|
|
413
|
+
function listenersOf(rows, local) {
|
|
414
|
+
for (const host of [local[0], '*']) {
|
|
415
|
+
const hits = rows.filter((r) => r.listening && r.local[0] === host && r.local[1] === local[1]);
|
|
416
|
+
if (hits.length) return hits;
|
|
417
|
+
}
|
|
418
|
+
return [];
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// The owner of the rows a question selected: one row, or several of one owner (SO_REUSEPORT workers, an IPv4 and an
|
|
422
|
+
// IPv6 socket of one process). Several owners cannot be told apart: unknown (several-rows).
|
|
423
|
+
async function oneOwner(found, how, opts, noneReason = null) {
|
|
424
|
+
if (found.length === 0) return noneReason ? owner(UNKNOWN, { method: how, reason: noneReason }) : owner(NONE, { method: how });
|
|
425
|
+
const keys = new Set(found.map((r) => `${r.uid}/${r.pid}`));
|
|
426
|
+
const uids = new Set(found.map((r) => r.uid));
|
|
427
|
+
if (keys.size === 1 || (!uids.has(null) && !uids.has(undefined) && uids.size === 1)) return resolve(found[0], how, opts);
|
|
428
|
+
return owner(UNKNOWN, { method: how, reason: 'several-rows' });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// The owner of the CLIENT socket of the connection between `server` and `client` ([host, port] each), as a server sees
|
|
432
|
+
// them: the row whose local address is the client's and whose remote address is the server's.
|
|
433
|
+
export function peerAt(server, client, opts) {
|
|
434
|
+
return ownerOf(address(client[0], client[1]), address(server[0], server[1]), opts);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// For a server's accepted socket: who connected. Reads nothing from the socket.
|
|
438
|
+
export function peer(socket, opts) {
|
|
439
|
+
if (!socket.remoteAddress || !socket.localAddress) return Promise.resolve(owner(UNKNOWN, { reason: 'socket: not connected' }));
|
|
440
|
+
return peerAt([socket.localAddress, socket.localPort], [socket.remoteAddress, socket.remotePort], opts);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// For a client's connected socket: who accepted it. The row whose local address is the server's and whose remote
|
|
444
|
+
// address is this socket's own, the server process's accepted socket, so a listener swapped after listen cannot pass
|
|
445
|
+
// for the one that answers. On Linux a connection not yet accept()ed has no owner of its own (inode 0): its listener
|
|
446
|
+
// answers (the contract's pending CT-254, FAA-258).
|
|
447
|
+
export function serverEnd(socket, opts) {
|
|
448
|
+
if (!socket.remoteAddress || !socket.localAddress) return Promise.resolve(owner(UNKNOWN, { reason: 'socket: not connected' }));
|
|
449
|
+
return ownerOf(address(socket.remoteAddress, socket.remotePort), address(socket.localAddress, socket.localPort), opts, true);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const LISTEN_ORDER = ['127.0.0.1', '*', '::1'];
|
|
453
|
+
const listeners = new Map();
|
|
454
|
+
|
|
455
|
+
// The listeners a connection to 127.0.0.1:<port> reaches: those on the most specific bind that has any.
|
|
456
|
+
export function pickListener(rows, port) {
|
|
457
|
+
for (const host of LISTEN_ORDER) {
|
|
458
|
+
const hits = rows.filter((r) => r.listening && r.local[0] === host && r.local[1] === port);
|
|
459
|
+
if (hits.length) return hits;
|
|
460
|
+
}
|
|
461
|
+
return [];
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Who listens on 127.0.0.1:<port>, or on 0.0.0.0 or ::1. none when nothing does, unknown (several-rows) when sockets of
|
|
465
|
+
// two owners do. Cached for LISTENER_TTL_MS (never when `read`, `run`, `ownUid` or `ownSid` is given). `read` answers
|
|
466
|
+
// { rows, how } as readTable does; `run`, `ownUid` and `ownSid` go to resolve.
|
|
467
|
+
export async function listener(port, opts = {}) {
|
|
468
|
+
const { read, now = () => performance.now() } = opts;
|
|
469
|
+
const injected = Boolean(read || opts.run || opts.ownUid !== undefined || opts.ownSid !== undefined);
|
|
470
|
+
const at = now();
|
|
471
|
+
const hit = injected ? undefined : listeners.get(port);
|
|
472
|
+
if (hit && at - hit[0] < LISTENER_TTL_MS) return hit[1];
|
|
473
|
+
let found;
|
|
474
|
+
let how;
|
|
475
|
+
let table;
|
|
476
|
+
try {
|
|
477
|
+
table = await (read ?? readTable)(opts);
|
|
478
|
+
how = table.how;
|
|
479
|
+
found = pickListener(table.rows, port);
|
|
480
|
+
} catch (err) {
|
|
481
|
+
const who = owner(UNKNOWN, { method: method(), reason: err instanceof TableUnreadable ? err.message : `table: ${err.name}` });
|
|
482
|
+
if (!injected) listeners.set(port, [at, who]);
|
|
483
|
+
return who;
|
|
484
|
+
}
|
|
485
|
+
const who = await oneOwner(found, how, opts);
|
|
486
|
+
if (!injected) listeners.set(port, [at, who]);
|
|
487
|
+
return who;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// [code, why] when a client must not send on a connection whose server end is `who`, else null.
|
|
491
|
+
export function refusal(who, port, expectPid = null) {
|
|
492
|
+
if (who.kind === ANOTHER_ACCOUNT || who.kind === SYSTEM) return [SERVER_NOT_OWN, `the server on port ${port} belongs to ${who.kind}`];
|
|
493
|
+
if (who.kind !== THIS_ACCOUNT) return [SERVER_UNVERIFIED, `the owner of the server on port ${port} could not be read (${who.reason})`];
|
|
494
|
+
if (expectPid !== null && who.pid !== null && who.pid !== expectPid) {
|
|
495
|
+
return [SERVER_NOT_THIS_WORKSPACE, `the server on port ${port} is pid ${who.pid}, not the expected pid ${expectPid}`];
|
|
496
|
+
}
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// A connected socket to host:port whose server end is this account's (and, with expectPid, that process's). The check
|
|
501
|
+
// runs on the very socket the caller then writes on, so no listener can change between the check and the request.
|
|
502
|
+
// Rejects with ListenerRefused (the socket destroyed, nothing written) or with the connection's own error.
|
|
503
|
+
export function checkedSocket(port, { host = '127.0.0.1', timeoutMs = 10000, expectPid = null, ...ask } = {}) {
|
|
504
|
+
return new Promise((resolveSocket, reject) => {
|
|
505
|
+
const socket = net.connect({ host, port, noDelay: true }); // as a plain http.Agent's socket
|
|
506
|
+
const timer = setTimeout(() => socket.destroy(Object.assign(new Error('connect timeout'), { code: 'ETIMEDOUT' })), timeoutMs);
|
|
507
|
+
socket.once('error', (err) => { clearTimeout(timer); reject(err); });
|
|
508
|
+
socket.once('connect', async () => {
|
|
509
|
+
clearTimeout(timer);
|
|
510
|
+
socket.pause();
|
|
511
|
+
let who;
|
|
512
|
+
try {
|
|
513
|
+
who = await serverEnd(socket, ask);
|
|
514
|
+
} catch (err) {
|
|
515
|
+
// A reader that throws (an injected one; the readers themselves answer unknown) fails closed.
|
|
516
|
+
who = owner(UNKNOWN, { method: method(), reason: `check: ${err?.name ?? 'Error'}` });
|
|
517
|
+
}
|
|
518
|
+
const refused = refusal(who, port, expectPid);
|
|
519
|
+
if (refused) {
|
|
520
|
+
socket.destroy();
|
|
521
|
+
reject(new ListenerRefused(refused[0], `${refused[1]}; nothing was sent`, who));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
socket.removeAllListeners('error');
|
|
525
|
+
socket.resume();
|
|
526
|
+
resolveSocket(socket);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// An http.Agent whose every new connection is checked before a byte is written; with keepAlive a socket is checked once,
|
|
532
|
+
// when it is made. A refusal reaches the request as its 'error', a ListenerRefused. Free sockets do not keep the process
|
|
533
|
+
// alive (Node unrefs them).
|
|
534
|
+
export class CheckedAgent extends http.Agent {
|
|
535
|
+
constructor({ expectPid = null, ask = {}, ...options } = {}) {
|
|
536
|
+
super(options);
|
|
537
|
+
this.expectPid = expectPid;
|
|
538
|
+
this.ask = ask;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
createConnection(options, callback) {
|
|
542
|
+
checkedSocket(Number(options.port), { host: options.host ?? '127.0.0.1', timeoutMs: options.timeout ?? 10000,
|
|
543
|
+
expectPid: this.expectPid, ...this.ask }).then((socket) => callback(null, socket), (err) => callback(err));
|
|
544
|
+
}
|
|
545
|
+
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs
CHANGED
|
@@ -9,6 +9,8 @@ import { DRAIN_SECONDS, SUPERVISOR_ENV, ServiceError, TASK, UNIT } from './fabri
|
|
|
9
9
|
import { atomicWrite, instanceDirs } from './fabric-paths.mjs';
|
|
10
10
|
import { SUPERVISOR_ARG, markerArguments } from './fabric-lock.mjs';
|
|
11
11
|
import { URI } from './fabric-backups.mjs';
|
|
12
|
+
import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs';
|
|
13
|
+
import http from 'node:http';
|
|
12
14
|
|
|
13
15
|
export const DEFAULT_EXIT_TIMEOUT = 15;
|
|
14
16
|
|
|
@@ -316,13 +318,41 @@ export function launchdOverride(label, { run = defaultRun, platform = process.pl
|
|
|
316
318
|
return null;
|
|
317
319
|
}
|
|
318
320
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
321
|
+
// A refused listener, as the installer's poll sees it: never a document, whatever keys a service's JSON carries.
|
|
322
|
+
export class ForeignUserAnswer {
|
|
323
|
+
constructor(reason) { this.reason = reason; }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// The well-known answer at a loopback origin, read only on a connection whose server end is this OS account's process
|
|
327
|
+
// (DEC-0154 HA-2): null when nothing answers or the answer is not JSON, a ForeignUserAnswer when the listener is another
|
|
328
|
+
// account's or its owner cannot be told.
|
|
329
|
+
export async function wellKnownOf(origin) {
|
|
330
|
+
const m = /^http:\/\/127\.0\.0\.1:([0-9]{1,5})$/.exec(origin);
|
|
331
|
+
if (!m) {
|
|
332
|
+
try {
|
|
333
|
+
const response = await fetch(`${origin}/.well-known/fabric-service`, { signal: AbortSignal.timeout(2000) });
|
|
334
|
+
return response.ok ? await response.json() : null;
|
|
335
|
+
} catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
325
338
|
}
|
|
339
|
+
// A CheckedAgent without keep-alive: the request goes on the very socket it checked, and the socket closes after the
|
|
340
|
+
// answer, so a poll leaves no connection open behind it.
|
|
341
|
+
const agent = new CheckedAgent({ keepAlive: false });
|
|
342
|
+
return new Promise((resolve) => {
|
|
343
|
+
const req = http.request({ host: '127.0.0.1', port: Number(m[1]), path: '/.well-known/fabric-service', agent,
|
|
344
|
+
timeout: 2000 }, (res) => {
|
|
345
|
+
const chunks = [];
|
|
346
|
+
res.on('data', (c) => chunks.push(c));
|
|
347
|
+
res.on('end', () => {
|
|
348
|
+
try { resolve(res.statusCode === 200 ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null); } catch { resolve(null); }
|
|
349
|
+
});
|
|
350
|
+
res.on('error', () => resolve(null));
|
|
351
|
+
});
|
|
352
|
+
req.on('timeout', () => req.destroy());
|
|
353
|
+
req.on('error', (err) => resolve(err instanceof ListenerRefused ? new ForeignUserAnswer(`foreign-user: ${err.message}`) : null));
|
|
354
|
+
req.end();
|
|
355
|
+
}).finally(() => agent.destroy());
|
|
326
356
|
}
|
|
327
357
|
|
|
328
358
|
/**
|
|
@@ -359,11 +389,17 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
|
|
|
359
389
|
await sleep(1000);
|
|
360
390
|
}
|
|
361
391
|
const deadline = Date.now() + timeout * 1000;
|
|
392
|
+
let foreign = null;
|
|
362
393
|
while (Date.now() < deadline) {
|
|
363
394
|
const doc = await fetchWellKnown(origin);
|
|
364
|
-
|
|
395
|
+
foreign = doc instanceof ForeignUserAnswer ? doc.reason : null;
|
|
396
|
+
if (!foreign && doc?.service?.id === serviceId && doc?.service?.instance === instance) return doc;
|
|
365
397
|
await sleep(500);
|
|
366
398
|
}
|
|
399
|
+
// DEC-0154 HA-2 and service.md's Install row: a listener of another account is never a successful install.
|
|
400
|
+
if (foreign) {
|
|
401
|
+
throw new ServiceError(`${origin} is not this service's: ${foreign} (DEC-0154 HA-2); the port is another OS account's or its owner cannot be told.`);
|
|
402
|
+
}
|
|
367
403
|
throw new ServiceError(`${origin} did not answer as ${serviceId}.${instance} within ${timeout} s; read its log.`);
|
|
368
404
|
}
|
|
369
405
|
|