yaver-cli 1.99.9 → 1.99.11
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/package.json +1 -1
- package/src/agent-runtime.js +244 -6
package/package.json
CHANGED
package/src/agent-runtime.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const os = require('os');
|
|
3
3
|
const path = require('path');
|
|
4
|
-
const { spawn } = require('child_process');
|
|
4
|
+
const { spawn, spawnSync } = require('child_process');
|
|
5
5
|
const { pipeline } = require('stream/promises');
|
|
6
6
|
const https = require('https');
|
|
7
7
|
const semver = require('semver');
|
|
@@ -14,6 +14,17 @@ const CACHE_ROOT = process.env.YAVER_AGENT_CACHE_DIR || path.join(os.homedir(),
|
|
|
14
14
|
let resolvedAgentVersionPromise = null;
|
|
15
15
|
const resolvedAssetPromiseByKey = new Map();
|
|
16
16
|
|
|
17
|
+
// Magic bytes for executable formats. Used to sanity-check a downloaded
|
|
18
|
+
// binary before we spawn it — an HTML error page or a half-written file
|
|
19
|
+
// will fail this and we'll redownload instead of producing SIGKILL.
|
|
20
|
+
const EXECUTABLE_MAGICS = [
|
|
21
|
+
Buffer.from([0xcf, 0xfa, 0xed, 0xfe]), // Mach-O 64-bit LE (macOS)
|
|
22
|
+
Buffer.from([0xce, 0xfa, 0xed, 0xfe]), // Mach-O 32-bit LE
|
|
23
|
+
Buffer.from([0xca, 0xfe, 0xba, 0xbe]), // Mach-O fat binary
|
|
24
|
+
Buffer.from([0x7f, 0x45, 0x4c, 0x46]), // ELF (Linux) — \x7fELF
|
|
25
|
+
Buffer.from([0x4d, 0x5a]), // PE (Windows) — MZ
|
|
26
|
+
];
|
|
27
|
+
|
|
17
28
|
async function ensureAgentBinary({ quiet = false } = {}) {
|
|
18
29
|
const asset = await resolveAsset();
|
|
19
30
|
const localAgentPath = resolveLocalAgentBinary(asset);
|
|
@@ -58,25 +69,122 @@ async function ensureAgentBinary({ quiet = false } = {}) {
|
|
|
58
69
|
if (process.platform !== 'win32') {
|
|
59
70
|
fs.chmodSync(binaryPath, 0o755);
|
|
60
71
|
}
|
|
72
|
+
// Proactively strip macOS quarantine + re-adhoc-sign on
|
|
73
|
+
// freshly-downloaded binaries. Without this:
|
|
74
|
+
// - Gatekeeper SIGKILLs the first exec because Yaver release
|
|
75
|
+
// tarballs are not notarized (adhoc-signed at link time only).
|
|
76
|
+
// - If the Go linker's adhoc signature didn't survive the tarball
|
|
77
|
+
// round-trip, the kernel refuses to load it ("load code
|
|
78
|
+
// signature error 2"). codesign --force --sign - rebuilds a
|
|
79
|
+
// valid adhoc signature against the current bytes.
|
|
80
|
+
// Doing both here means the happy path never sees SIGKILL in the
|
|
81
|
+
// first place.
|
|
82
|
+
if (process.platform === 'darwin') {
|
|
83
|
+
try {
|
|
84
|
+
spawnSync('xattr', ['-dr', 'com.apple.quarantine', binaryPath], { stdio: 'ignore' });
|
|
85
|
+
} catch (_err) {}
|
|
86
|
+
try {
|
|
87
|
+
spawnSync('codesign', ['--force', '--sign', '-', binaryPath], { stdio: 'ignore' });
|
|
88
|
+
} catch (_err) {}
|
|
89
|
+
}
|
|
90
|
+
// Sanity-check the extracted binary. A truncated tarball or an
|
|
91
|
+
// HTML error page saved as ".tar.gz" can leave us with a file that
|
|
92
|
+
// exists but isn't actually executable — spawning it produces
|
|
93
|
+
// SIGKILL and a cryptic error. Fail loudly here with a message
|
|
94
|
+
// that points at the actual problem (bad download) so the caller
|
|
95
|
+
// can redownload.
|
|
96
|
+
if (!looksLikeExecutable(binaryPath)) {
|
|
97
|
+
try { fs.rmSync(binaryPath, { force: true }); } catch (_err) {}
|
|
98
|
+
throw new Error(
|
|
99
|
+
`downloaded agent binary at ${binaryPath} is not a valid executable ` +
|
|
100
|
+
`(likely a truncated download or GitHub rate-limit HTML page). ` +
|
|
101
|
+
`Retry in a minute, or set YAVER_AGENT_BIN to a local yaver binary.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
61
104
|
return binaryPath;
|
|
62
105
|
}
|
|
63
106
|
|
|
64
107
|
async function runAgentCommand(args, options = {}) {
|
|
108
|
+
// One auto-recovery retry on SIGKILL of a cached binary. SIGKILL on a
|
|
109
|
+
// freshly-downloaded binary almost always means macOS Gatekeeper
|
|
110
|
+
// quarantined it (no notarization), or the download was truncated.
|
|
111
|
+
// Both are self-healing: strip quarantine + redownload + retry once.
|
|
112
|
+
// We never retry for user-initiated signals or normal exits.
|
|
113
|
+
let attempt = 0;
|
|
114
|
+
const maxAttempts = 2;
|
|
115
|
+
|
|
116
|
+
while (true) {
|
|
117
|
+
attempt += 1;
|
|
118
|
+
const spawnSpec = await resolveSpawnSpec(args, options);
|
|
119
|
+
const isCachedBinary = spawnSpec.command.startsWith(CACHE_ROOT);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
await spawnAndWait(spawnSpec, args);
|
|
123
|
+
return;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const { signal, recoverable } = err;
|
|
126
|
+
if (
|
|
127
|
+
recoverable &&
|
|
128
|
+
signal === 'SIGKILL' &&
|
|
129
|
+
isCachedBinary &&
|
|
130
|
+
attempt < maxAttempts
|
|
131
|
+
) {
|
|
132
|
+
const healed = await attemptSigkillRecovery(spawnSpec.command, { quiet: options.quiet });
|
|
133
|
+
if (healed) {
|
|
134
|
+
console.error(`Yaver agent: recovered from SIGKILL (${healed}). Retrying...`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function resolveSpawnSpec(args, options) {
|
|
65
144
|
const localAgent = resolveLocalAgentCommand(args);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
145
|
+
if (localAgent) return localAgent;
|
|
146
|
+
return { command: await ensureAgentBinary(options), args };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function spawnAndWait(spawnSpec, args) {
|
|
69
150
|
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
70
151
|
stdio: 'inherit',
|
|
71
152
|
env: process.env,
|
|
72
153
|
cwd: spawnSpec.cwd || process.cwd(),
|
|
73
154
|
});
|
|
74
155
|
|
|
75
|
-
|
|
156
|
+
// Forward Ctrl-C / SIGTERM / SIGHUP from the wrapper to the child
|
|
157
|
+
// so the user's interrupt actually reaches the running binary
|
|
158
|
+
// (instead of killing the wrapper while leaving the child orphaned).
|
|
159
|
+
// Without this, Ctrl-C would just kill the Node wrapper and the
|
|
160
|
+
// wrapper's exit handler would then report "terminated by signal
|
|
161
|
+
// SIGINT" as if it were an error.
|
|
162
|
+
const forwarded = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
163
|
+
const forwarders = forwarded.map((sig) => {
|
|
164
|
+
const handler = () => {
|
|
165
|
+
try { child.kill(sig); } catch (_e) { /* child already gone */ }
|
|
166
|
+
};
|
|
167
|
+
process.on(sig, handler);
|
|
168
|
+
return [sig, handler];
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
76
172
|
child.on('error', reject);
|
|
77
173
|
child.on('exit', (code, signal) => {
|
|
174
|
+
for (const [sig, handler] of forwarders) {
|
|
175
|
+
process.removeListener(sig, handler);
|
|
176
|
+
}
|
|
177
|
+
// User-initiated interrupts — exit quietly. The user already
|
|
178
|
+
// knows they hit Ctrl-C; surfacing a red ❌ is noise.
|
|
179
|
+
if (signal === 'SIGINT' || signal === 'SIGTERM' || signal === 'SIGHUP') {
|
|
180
|
+
resolve();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
78
183
|
if (signal) {
|
|
79
|
-
|
|
184
|
+
const error = new Error(diagnoseAbnormalSignal(signal, args));
|
|
185
|
+
error.signal = signal;
|
|
186
|
+
error.recoverable = true;
|
|
187
|
+
reject(error);
|
|
80
188
|
return;
|
|
81
189
|
}
|
|
82
190
|
if (code && code !== 0) {
|
|
@@ -90,6 +198,136 @@ async function runAgentCommand(args, options = {}) {
|
|
|
90
198
|
});
|
|
91
199
|
}
|
|
92
200
|
|
|
201
|
+
// attemptSigkillRecovery tries the three things that actually fix a
|
|
202
|
+
// SIGKILL on a cached Yaver binary:
|
|
203
|
+
// 1. strip macOS quarantine xattrs (Gatekeeper-killed Mach-Os)
|
|
204
|
+
// 2. re-adhoc-sign the Mach-O (fixes broken signature from the
|
|
205
|
+
// agent's in-place auto-update — kernel refuses to exec, logs
|
|
206
|
+
// "load code signature error 2")
|
|
207
|
+
// 3. redownload the binary if the magic bytes don't look like an
|
|
208
|
+
// executable (truncated download / HTML error page)
|
|
209
|
+
// Returns the recovery action taken, or null if nothing could be done.
|
|
210
|
+
async function attemptSigkillRecovery(binaryPath, { quiet = false } = {}) {
|
|
211
|
+
if (!fs.existsSync(binaryPath)) {
|
|
212
|
+
// The binary went away mid-flight; re-download by returning null
|
|
213
|
+
// so the caller retries the full resolve path next loop.
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 1. Sanity check first — if the binary isn't even executable-shaped,
|
|
218
|
+
// nothing we do to it will help. Redownload.
|
|
219
|
+
if (!looksLikeExecutable(binaryPath)) {
|
|
220
|
+
try {
|
|
221
|
+
fs.rmSync(binaryPath, { force: true });
|
|
222
|
+
resolvedAssetPromiseByKey.clear();
|
|
223
|
+
} catch (_err) {}
|
|
224
|
+
if (!quiet) console.error('Yaver agent: cached binary was corrupt, redownloading...');
|
|
225
|
+
return 'redownload-corrupt-binary';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (process.platform === 'darwin') {
|
|
229
|
+
// 2. Strip quarantine. Unsigned binaries downloaded via https get
|
|
230
|
+
// tagged com.apple.quarantine and the kernel SIGKILLs them on
|
|
231
|
+
// first exec (no dialog, because the parent is a CLI).
|
|
232
|
+
try {
|
|
233
|
+
spawnSync('xattr', ['-dr', 'com.apple.quarantine', binaryPath], { stdio: 'ignore' });
|
|
234
|
+
} catch (_err) {}
|
|
235
|
+
try { fs.chmodSync(binaryPath, 0o755); } catch (_err) {}
|
|
236
|
+
|
|
237
|
+
// 3. Re-adhoc-sign. When the agent's own self-update rewrites the
|
|
238
|
+
// binary in place, the original adhoc signature no longer
|
|
239
|
+
// matches the new contents and the kernel refuses to exec it
|
|
240
|
+
// (dmesg: "load code signature error 2"). `codesign --force
|
|
241
|
+
// --sign -` rebuilds the adhoc signature against the current
|
|
242
|
+
// bytes, which is all the kernel needs to let exec proceed.
|
|
243
|
+
try {
|
|
244
|
+
const result = spawnSync('codesign', ['--force', '--sign', '-', binaryPath], {
|
|
245
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
246
|
+
encoding: 'utf8',
|
|
247
|
+
});
|
|
248
|
+
if (result.status === 0) {
|
|
249
|
+
// Verify the resigned binary actually passes the kernel check
|
|
250
|
+
// now. If codesign succeeded we're done — the retry will work.
|
|
251
|
+
return 'resign-macos-adhoc';
|
|
252
|
+
}
|
|
253
|
+
// If codesign failed (e.g. missing developer tools), fall through
|
|
254
|
+
// to the quarantine-only return below.
|
|
255
|
+
} catch (_err) {}
|
|
256
|
+
|
|
257
|
+
return 'strip-macos-quarantine';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// On Linux / Windows a SIGKILL of an intact binary usually means an
|
|
261
|
+
// OOM kill or external pkill; re-exec is unlikely to help, so
|
|
262
|
+
// return null and let the original diagnostic message stand.
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function looksLikeExecutable(binaryPath) {
|
|
267
|
+
try {
|
|
268
|
+
const fd = fs.openSync(binaryPath, 'r');
|
|
269
|
+
const buf = Buffer.alloc(4);
|
|
270
|
+
const bytesRead = fs.readSync(fd, buf, 0, 4, 0);
|
|
271
|
+
fs.closeSync(fd);
|
|
272
|
+
if (bytesRead < 2) return false;
|
|
273
|
+
return EXECUTABLE_MAGICS.some((magic) => {
|
|
274
|
+
if (magic.length > bytesRead) return false;
|
|
275
|
+
return buf.slice(0, magic.length).equals(magic);
|
|
276
|
+
});
|
|
277
|
+
} catch (_err) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// diagnoseAbnormalSignal turns a child-process termination signal into
|
|
283
|
+
// a concrete next-step the user can actually take. Each branch points
|
|
284
|
+
// at the most-common cause — pulled from real bug reports — and lists
|
|
285
|
+
// the exact command that resolves it.
|
|
286
|
+
function diagnoseAbnormalSignal(signal, args = []) {
|
|
287
|
+
const cmd = args[0] || 'agent';
|
|
288
|
+
switch (signal) {
|
|
289
|
+
case 'SIGKILL': {
|
|
290
|
+
const lines = [
|
|
291
|
+
`agent process was killed (SIGKILL) — this is usually NOT a bug, ` +
|
|
292
|
+
`the OS or another process forced it down.`,
|
|
293
|
+
``,
|
|
294
|
+
`Most-likely cause first:`,
|
|
295
|
+
` 1. Another yaver agent is already running (port 18080 is taken).`,
|
|
296
|
+
` check: lsof -i :18080`,
|
|
297
|
+
` fix: yaver stop (or: pkill -f 'yaver.*serve')`,
|
|
298
|
+
];
|
|
299
|
+
if (cmd === 'auth') {
|
|
300
|
+
lines.push(
|
|
301
|
+
` Note: \`yaver auth\` doesn't need a running agent. If one is`,
|
|
302
|
+
` already up in bootstrap mode, pair from your Yaver mobile app instead`,
|
|
303
|
+
` of running \`yaver auth\` again.`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
lines.push(
|
|
307
|
+
``,
|
|
308
|
+
`Other possibilities:`,
|
|
309
|
+
` 2. macOS Gatekeeper quarantined the binary. Run:`,
|
|
310
|
+
` xattr -dr com.apple.quarantine ~/.yaver/bin`,
|
|
311
|
+
` 3. The OS killed it for OOM / power. Check Console.app → Crash Reports.`,
|
|
312
|
+
` 4. The binary is corrupt. Reinstall: npm i -g yaver-cli@latest`,
|
|
313
|
+
);
|
|
314
|
+
return lines.join('\n');
|
|
315
|
+
}
|
|
316
|
+
case 'SIGABRT':
|
|
317
|
+
return `agent aborted (SIGABRT). Most likely a Go panic — check ~/.yaver/agent.log for the stack trace.`;
|
|
318
|
+
case 'SIGSEGV':
|
|
319
|
+
case 'SIGBUS':
|
|
320
|
+
return (
|
|
321
|
+
`agent crashed (${signal}). Likely binary corruption or an arch mismatch.\n` +
|
|
322
|
+
`Try: npm i -g yaver-cli@latest to reinstall a clean binary.`
|
|
323
|
+
);
|
|
324
|
+
case 'SIGPIPE':
|
|
325
|
+
return `agent's stdout/stderr pipe was closed. If you piped through head/less, that's expected — re-run without the pipe.`;
|
|
326
|
+
default:
|
|
327
|
+
return `agent terminated by signal ${signal}. Check ~/.yaver/agent.log for details.`;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
93
331
|
function resolveAgentInfo() {
|
|
94
332
|
return {
|
|
95
333
|
version: process.env.YAVER_AGENT_VERSION || PACKAGE.version,
|