@sentropic/h2a-runtime 0.98.0 → 0.98.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/dist/index.d.ts.map +1 -1
- package/dist/index.js +52 -18
- package/dist/index.js.map +1 -1
- package/dist/launch-guard.d.ts +3 -6
- package/dist/launch-guard.d.ts.map +1 -1
- package/dist/launch-guard.js +6 -4
- package/dist/launch-guard.js.map +1 -1
- package/dist/launch-layout.d.ts +2 -0
- package/dist/launch-layout.d.ts.map +1 -1
- package/dist/launch-layout.js +15 -2
- package/dist/launch-layout.js.map +1 -1
- package/dist/native-host.d.ts +79 -14
- package/dist/native-host.d.ts.map +1 -1
- package/dist/native-host.js +218 -64
- package/dist/native-host.js.map +1 -1
- package/dist/native-restart.d.ts +1 -0
- package/dist/native-restart.d.ts.map +1 -1
- package/dist/native-restart.js.map +1 -1
- package/dist/native-terminal/attach-recovery.d.ts +9 -0
- package/dist/native-terminal/attach-recovery.d.ts.map +1 -0
- package/dist/native-terminal/attach-recovery.js +54 -0
- package/dist/native-terminal/attach-recovery.js.map +1 -0
- package/dist/native-terminal/fleet.d.ts +32 -0
- package/dist/native-terminal/fleet.d.ts.map +1 -0
- package/dist/native-terminal/fleet.js +40 -0
- package/dist/native-terminal/fleet.js.map +1 -0
- package/dist/native-terminal/host.d.ts +1 -0
- package/dist/native-terminal/host.d.ts.map +1 -1
- package/dist/native-terminal/host.js +5 -2
- package/dist/native-terminal/host.js.map +1 -1
- package/dist/native-terminal/journal.d.ts +48 -0
- package/dist/native-terminal/journal.d.ts.map +1 -0
- package/dist/native-terminal/journal.js +305 -0
- package/dist/native-terminal/journal.js.map +1 -0
- package/dist/native-terminal/op.d.ts +3 -0
- package/dist/native-terminal/op.d.ts.map +1 -1
- package/dist/native-terminal/op.js +279 -93
- package/dist/native-terminal/op.js.map +1 -1
- package/dist/native-terminal/process.d.ts.map +1 -1
- package/dist/native-terminal/process.js +134 -46
- package/dist/native-terminal/process.js.map +1 -1
- package/dist/native-terminal/replay-buffer.d.ts +3 -0
- package/dist/native-terminal/replay-buffer.d.ts.map +1 -1
- package/dist/native-terminal/replay-buffer.js +11 -0
- package/dist/native-terminal/replay-buffer.js.map +1 -1
- package/dist/native-terminal/reset-relay.d.ts +11 -0
- package/dist/native-terminal/reset-relay.d.ts.map +1 -0
- package/dist/native-terminal/reset-relay.js +38 -0
- package/dist/native-terminal/reset-relay.js.map +1 -0
- package/dist/native-terminal/server.d.ts +8 -0
- package/dist/native-terminal/server.d.ts.map +1 -1
- package/dist/native-terminal/server.js +535 -58
- package/dist/native-terminal/server.js.map +1 -1
- package/dist/native-terminal/socket-path.d.ts +3 -0
- package/dist/native-terminal/socket-path.d.ts.map +1 -1
- package/dist/native-terminal/socket-path.js +21 -1
- package/dist/native-terminal/socket-path.js.map +1 -1
- package/dist/native-terminal/terminal-modes.d.ts +15 -0
- package/dist/native-terminal/terminal-modes.d.ts.map +1 -0
- package/dist/native-terminal/terminal-modes.js +180 -0
- package/dist/native-terminal/terminal-modes.js.map +1 -0
- package/dist/restore.d.ts.map +1 -1
- package/dist/restore.js +8 -2
- package/dist/restore.js.map +1 -1
- package/dist/tmux.d.ts +20 -2
- package/dist/tmux.d.ts.map +1 -1
- package/dist/tmux.js +14 -6
- package/dist/tmux.js.map +1 -1
- package/package.json +1 -1
|
@@ -29,15 +29,20 @@
|
|
|
29
29
|
* (detach: Ctrl-\\ ; exits when session exits)
|
|
30
30
|
* host-stop SIGTERM the host process (sessions stop)
|
|
31
31
|
*/
|
|
32
|
-
import { readFileSync } from "node:fs";
|
|
32
|
+
import { existsSync, readFileSync, writeSync } from "node:fs";
|
|
33
33
|
import { dirname } from "node:path";
|
|
34
34
|
import { fileURLToPath } from "node:url";
|
|
35
35
|
import { NativeTerminalClient } from "./client.js";
|
|
36
36
|
import { loadRegistry, registryEntriesForNativeTarget } from "../registry.js";
|
|
37
|
-
import { NATIVE_TERMINAL_INTERACTIVE_READ_TIMEOUT_MS, NativeTerminalRemoteError, } from "./protocol.js";
|
|
37
|
+
import { NATIVE_TERMINAL_INTERACTIVE_READ_TIMEOUT_MS, NATIVE_TERMINAL_PROTOCOL_VERSION, NativeTerminalRemoteError, } from "./protocol.js";
|
|
38
38
|
import { NativeTerminalHostSupervisor } from "./supervisor.js";
|
|
39
|
-
import {
|
|
39
|
+
import { proveNativeTerminalEndpointAbsent } from "./server.js";
|
|
40
|
+
import { defaultNativeTerminalSocketPath, knownNativeTerminalSocketPaths, inspectPrivateNativeTerminalSocket, sameNativeTerminalSocket } from "./socket-path.js";
|
|
41
|
+
import { collectNativeInventory, resolveNativeOwner } from "./fleet.js";
|
|
40
42
|
import { renderTerminalScreen } from "./screen.js";
|
|
43
|
+
import { TerminalModeTracker } from "./terminal-modes.js";
|
|
44
|
+
import { publishAttachReset } from "./attach-recovery.js";
|
|
45
|
+
import { TerminalResetRelay } from "./reset-relay.js";
|
|
41
46
|
const DETACH_BYTE = 0x1c; // Ctrl-\
|
|
42
47
|
function parseArgv(argv) {
|
|
43
48
|
const op = argv[2];
|
|
@@ -95,11 +100,81 @@ export function closeAllNativeTerminalOpClients() {
|
|
|
95
100
|
client.close();
|
|
96
101
|
openClients.clear();
|
|
97
102
|
}
|
|
98
|
-
async function connectExisting(socketPath) {
|
|
103
|
+
async function connectExisting(socketPath, tracked = true) {
|
|
104
|
+
const identity = await inspectPrivateNativeTerminalSocket(socketPath);
|
|
99
105
|
const client = await NativeTerminalClient.connect(socketPath);
|
|
100
|
-
|
|
106
|
+
if (tracked)
|
|
107
|
+
openClients.add(client);
|
|
108
|
+
try {
|
|
109
|
+
const ping = await client.ping();
|
|
110
|
+
if (ping.protocolVersion !== NATIVE_TERMINAL_PROTOCOL_VERSION || !ping.generation?.trim() ||
|
|
111
|
+
!Number.isSafeInteger(ping.hostPid) || ping.hostPid <= 0 ||
|
|
112
|
+
!sameNativeTerminalSocket(identity, await inspectPrivateNativeTerminalSocket(socketPath))) {
|
|
113
|
+
throw new Error("native host identity changed or handshake is invalid");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
client.close();
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
101
120
|
return client;
|
|
102
121
|
}
|
|
122
|
+
class NativeOwnerError extends Error {
|
|
123
|
+
refusal;
|
|
124
|
+
constructor(refusal) {
|
|
125
|
+
super(`native session ${refusal.id}: ${refusal.code}${refusal.reason ? ` (${refusal.reason})` : ""}`);
|
|
126
|
+
this.refusal = refusal;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function inventoryFor(parsed) {
|
|
130
|
+
const pinned = parsed.flags.get("socket");
|
|
131
|
+
return collectNativeInventory(pinned ? [pinned] : knownNativeTerminalSocketPaths(), connectExisting, proveNativeTerminalEndpointAbsent);
|
|
132
|
+
}
|
|
133
|
+
async function owningClient(parsed, id) {
|
|
134
|
+
const owner = resolveNativeOwner(id, await inventoryFor(parsed));
|
|
135
|
+
if (owner.state !== "found")
|
|
136
|
+
throw new NativeOwnerError({ code: owner.state, id,
|
|
137
|
+
...(owner.state === "unknown" ? { reason: owner.reason } : {}),
|
|
138
|
+
...(owner.state === "ambiguous-owner" ? { sockets: owner.sockets } : {}) });
|
|
139
|
+
return owner;
|
|
140
|
+
}
|
|
141
|
+
async function selectLaunchClient(fenced) {
|
|
142
|
+
const paths = knownNativeTerminalSocketPaths();
|
|
143
|
+
const imposed = process.env["H2A_NATIVE_SOCKET"];
|
|
144
|
+
if (imposed) {
|
|
145
|
+
const client = await launchClient(imposed);
|
|
146
|
+
// An imposed endpoint can never silently redirect, even when its mate is
|
|
147
|
+
// available. Capability certification is performed by the caller.
|
|
148
|
+
return { client, socketPath: imposed };
|
|
149
|
+
}
|
|
150
|
+
const historical = paths[0];
|
|
151
|
+
const client = await launchClient(historical);
|
|
152
|
+
const ping = await client.ping();
|
|
153
|
+
if (!fenced || paths.length === 1)
|
|
154
|
+
return { client, socketPath: historical };
|
|
155
|
+
const compatible = paths[1];
|
|
156
|
+
// Initialize the second known endpoint so admission obtains a complete
|
|
157
|
+
// inventory even when the historical host already provides the fence.
|
|
158
|
+
const second = await launchClient(compatible);
|
|
159
|
+
return ping.launchFence === true ? { client, socketPath: historical } : { client: second, socketPath: compatible };
|
|
160
|
+
}
|
|
161
|
+
async function launchClient(socketPath) {
|
|
162
|
+
try {
|
|
163
|
+
return await connectExisting(socketPath);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
let absent = false;
|
|
167
|
+
try {
|
|
168
|
+
absent = await proveNativeTerminalEndpointAbsent(socketPath, error);
|
|
169
|
+
}
|
|
170
|
+
catch (proofError) {
|
|
171
|
+
error = new Error(`${error instanceof Error ? error.message : String(error)}; absence proof failed: ${proofError instanceof Error ? proofError.message : String(proofError)}`);
|
|
172
|
+
}
|
|
173
|
+
if (absent)
|
|
174
|
+
return ensureClient(socketPath);
|
|
175
|
+
throw Object.assign(new Error(`${socketPath}: ${error instanceof Error ? error.message : String(error)}; owner death is unproven. Inspect endpoint permissions and the owning process identity, restore probe access, then retry; do not remove a socket or lock while ownership is unproven.`), { socketPath });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
103
178
|
async function ensureClient(socketPath) {
|
|
104
179
|
const supervisor = new NativeTerminalHostSupervisor({
|
|
105
180
|
socketPath,
|
|
@@ -176,17 +251,37 @@ const ATTACH_RECONNECT_MIN_MS = 100;
|
|
|
176
251
|
const ATTACH_RECONNECT_MAX_MS = 2_000;
|
|
177
252
|
const ATTACH_PENDING_INPUT_MAX_BYTES = 64 * 1024;
|
|
178
253
|
function defaultAttachRuntime(socketPath) {
|
|
254
|
+
const signals = { SIGTERM: 15, SIGHUP: 1, SIGINT: 2, SIGQUIT: 3 };
|
|
255
|
+
const handlers = new Map();
|
|
179
256
|
return {
|
|
180
257
|
// runAttach owns and closes every reconnecting client. Keeping these
|
|
181
258
|
// long-lived clients in the one-shot op registry would retain each closed
|
|
182
259
|
// connection until the user eventually detaches.
|
|
183
|
-
connect: () =>
|
|
260
|
+
connect: () => connectExisting(socketPath, false),
|
|
184
261
|
stdin: process.stdin,
|
|
185
262
|
stdout: process.stdout,
|
|
186
263
|
now: () => Date.now(),
|
|
187
264
|
delay,
|
|
188
265
|
onResize: (listener) => process.on("SIGWINCH", listener),
|
|
189
266
|
offResize: (listener) => process.removeListener("SIGWINCH", listener),
|
|
267
|
+
saveTerminalReset: publishAttachReset,
|
|
268
|
+
onTerminate: (listener) => {
|
|
269
|
+
for (const [signal, number] of Object.entries(signals)) {
|
|
270
|
+
const handler = () => { listener(); process.exit(128 + number); };
|
|
271
|
+
handlers.set(signal, handler);
|
|
272
|
+
process.on(signal, handler);
|
|
273
|
+
}
|
|
274
|
+
process.on("exit", listener);
|
|
275
|
+
// Observe the fatal path without suppressing Node's error/exit behavior.
|
|
276
|
+
process.on("uncaughtExceptionMonitor", listener);
|
|
277
|
+
},
|
|
278
|
+
offTerminate: (listener) => {
|
|
279
|
+
for (const [signal, handler] of handlers)
|
|
280
|
+
process.removeListener(signal, handler);
|
|
281
|
+
handlers.clear();
|
|
282
|
+
process.removeListener("exit", listener);
|
|
283
|
+
process.removeListener("uncaughtExceptionMonitor", listener);
|
|
284
|
+
},
|
|
190
285
|
};
|
|
191
286
|
}
|
|
192
287
|
function isDefinitiveNativeTerminalAbsence(error) {
|
|
@@ -205,10 +300,50 @@ function isDefinitiveNativeTerminalAbsence(error) {
|
|
|
205
300
|
export async function runAttach(id, runtime = defaultAttachRuntime(socketPathFromEnv())) {
|
|
206
301
|
const stdin = runtime.stdin;
|
|
207
302
|
const stdout = runtime.stdout;
|
|
303
|
+
let modes = new TerminalModeTracker();
|
|
304
|
+
let modesSuspended = false;
|
|
305
|
+
let savedReset = "";
|
|
306
|
+
const saveReset = () => {
|
|
307
|
+
const reset = modesSuspended ? "" : modes.resetSequence();
|
|
308
|
+
if (reset === savedReset)
|
|
309
|
+
return;
|
|
310
|
+
runtime.saveTerminalReset?.(reset);
|
|
311
|
+
savedReset = reset;
|
|
312
|
+
};
|
|
313
|
+
const relayOutput = (data) => {
|
|
314
|
+
modes.feed(data);
|
|
315
|
+
saveReset();
|
|
316
|
+
stdout.write(data);
|
|
317
|
+
};
|
|
318
|
+
// Older protocol-v1 hosts relay the wrapper marker verbatim. New hosts
|
|
319
|
+
// consume it themselves so detached sessions also return to a clean shell.
|
|
320
|
+
let resetRelay = new TerminalResetRelay(relayOutput, () => modes.resetSequence());
|
|
321
|
+
const resetModes = () => {
|
|
322
|
+
if (modesSuspended)
|
|
323
|
+
return;
|
|
324
|
+
modesSuspended = true;
|
|
325
|
+
const reset = modes.resetSequence();
|
|
326
|
+
if (!reset) {
|
|
327
|
+
saveReset();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// Signals and fatal exits cannot wait for an asynchronous pipe flush.
|
|
331
|
+
const fd = stdout.fd;
|
|
332
|
+
if (typeof fd === "number")
|
|
333
|
+
writeSync(fd, reset);
|
|
334
|
+
else
|
|
335
|
+
stdout.write(reset);
|
|
336
|
+
saveReset();
|
|
337
|
+
};
|
|
338
|
+
const resumeModes = () => {
|
|
339
|
+
if (!modesSuspended)
|
|
340
|
+
return;
|
|
341
|
+
modesSuspended = false;
|
|
342
|
+
saveReset();
|
|
343
|
+
stdout.write(modes.restoreSequence() + modes.replayPrefix());
|
|
344
|
+
};
|
|
208
345
|
const isRawCapable = stdin.isTTY === true;
|
|
209
|
-
|
|
210
|
-
stdin.setRawMode(true);
|
|
211
|
-
stdin.resume();
|
|
346
|
+
const wasRaw = stdin.isRaw === true;
|
|
212
347
|
let detached = false;
|
|
213
348
|
let terminalEnded = false;
|
|
214
349
|
let exitCode = 0;
|
|
@@ -223,6 +358,20 @@ export async function runAttach(id, runtime = defaultAttachRuntime(socketPathFro
|
|
|
223
358
|
active = undefined;
|
|
224
359
|
current?.client.close();
|
|
225
360
|
};
|
|
361
|
+
const terminate = () => {
|
|
362
|
+
detached = true;
|
|
363
|
+
try {
|
|
364
|
+
resetRelay.flush();
|
|
365
|
+
resetModes();
|
|
366
|
+
}
|
|
367
|
+
finally {
|
|
368
|
+
if (isRawCapable)
|
|
369
|
+
stdin.setRawMode(wasRaw);
|
|
370
|
+
stdin.pause();
|
|
371
|
+
closeActive();
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
runtime.onTerminate?.(terminate);
|
|
226
375
|
const markTransportFailure = (connection, error, inputDeliveryUncertain = false) => {
|
|
227
376
|
if (inputDeliveryUncertain && !detached) {
|
|
228
377
|
stdout.write("\r\n[h2a] terminal input delivery became uncertain during reconnect; " +
|
|
@@ -287,6 +436,8 @@ export async function runAttach(id, runtime = defaultAttachRuntime(socketPathFro
|
|
|
287
436
|
pumpInput();
|
|
288
437
|
};
|
|
289
438
|
stdin.on("data", onInput);
|
|
439
|
+
const onEnd = () => { detached = true; closeActive(); };
|
|
440
|
+
stdin.on("end", onEnd);
|
|
290
441
|
const resize = () => {
|
|
291
442
|
const current = active;
|
|
292
443
|
if (current && stdout.columns && stdout.rows) {
|
|
@@ -319,6 +470,9 @@ export async function runAttach(id, runtime = defaultAttachRuntime(socketPathFro
|
|
|
319
470
|
// Paint the existing scrollback once, then follow the stream.
|
|
320
471
|
let seq = 0;
|
|
321
472
|
try {
|
|
473
|
+
if (isRawCapable)
|
|
474
|
+
stdin.setRawMode(true);
|
|
475
|
+
stdin.resume();
|
|
322
476
|
const initial = await connectAndAcquire();
|
|
323
477
|
if (initial.status === "exited")
|
|
324
478
|
return initial.exit?.exitCode ?? 0;
|
|
@@ -334,6 +488,7 @@ export async function runAttach(id, runtime = defaultAttachRuntime(socketPathFro
|
|
|
334
488
|
"without restarting the session\r\n");
|
|
335
489
|
recoveryAnnounced = true;
|
|
336
490
|
}
|
|
491
|
+
resetModes();
|
|
337
492
|
let reconnectDelayMs = ATTACH_RECONNECT_MIN_MS;
|
|
338
493
|
for (;;) {
|
|
339
494
|
if (detached)
|
|
@@ -346,6 +501,7 @@ export async function runAttach(id, runtime = defaultAttachRuntime(socketPathFro
|
|
|
346
501
|
break;
|
|
347
502
|
}
|
|
348
503
|
stdout.write("\r\n[h2a] native terminal reconnected\r\n");
|
|
504
|
+
resumeModes();
|
|
349
505
|
recoveryAnnounced = false;
|
|
350
506
|
idlePollMs = ATTACH_IDLE_POLL_MIN_MS;
|
|
351
507
|
nextStatePollAt = runtime.now() + ATTACH_STATE_POLL_MS;
|
|
@@ -353,7 +509,6 @@ export async function runAttach(id, runtime = defaultAttachRuntime(socketPathFro
|
|
|
353
509
|
}
|
|
354
510
|
catch (error) {
|
|
355
511
|
if (isDefinitiveNativeTerminalAbsence(error)) {
|
|
356
|
-
stdout.write("\r\n[h2a] native session is no longer available; attach stopped\r\n");
|
|
357
512
|
exitCode = 1;
|
|
358
513
|
terminalEnded = true;
|
|
359
514
|
break;
|
|
@@ -369,10 +524,22 @@ export async function runAttach(id, runtime = defaultAttachRuntime(socketPathFro
|
|
|
369
524
|
const current = active;
|
|
370
525
|
try {
|
|
371
526
|
const replay = await current.client.readOutput(id, seq, NATIVE_TERMINAL_INTERACTIVE_READ_TIMEOUT_MS);
|
|
527
|
+
if (detached)
|
|
528
|
+
break;
|
|
529
|
+
if (replay.gap) {
|
|
530
|
+
resetModes();
|
|
531
|
+
modes = new TerminalModeTracker();
|
|
532
|
+
resetRelay = new TerminalResetRelay(relayOutput, () => modes.resetSequence());
|
|
533
|
+
modesSuspended = false;
|
|
534
|
+
if (replay.terminalModePrefix) {
|
|
535
|
+
resetRelay.feed(replay.terminalModePrefix);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
372
538
|
for (const chunk of replay.chunks) {
|
|
373
|
-
|
|
539
|
+
resetRelay.feed(chunk.data);
|
|
374
540
|
seq = chunk.seq;
|
|
375
541
|
}
|
|
542
|
+
seq = replay.latestSeq;
|
|
376
543
|
if (replay.chunks.length === 0) {
|
|
377
544
|
await runtime.delay(idlePollMs);
|
|
378
545
|
idlePollMs = Math.min(ATTACH_IDLE_POLL_MAX_MS, idlePollMs * 2);
|
|
@@ -406,55 +573,65 @@ export async function runAttach(id, runtime = defaultAttachRuntime(socketPathFro
|
|
|
406
573
|
}
|
|
407
574
|
}
|
|
408
575
|
finally {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
576
|
+
try {
|
|
577
|
+
resetRelay.flush();
|
|
578
|
+
if (detached)
|
|
579
|
+
stdout.write("\r\n[h2a] detached from native session (session keeps running)\r\n");
|
|
580
|
+
resetModes();
|
|
581
|
+
}
|
|
582
|
+
finally {
|
|
583
|
+
runtime.offTerminate?.(terminate);
|
|
584
|
+
runtime.offResize(resize);
|
|
585
|
+
stdin.removeListener("data", onInput);
|
|
586
|
+
stdin.removeListener("end", onEnd);
|
|
587
|
+
if (isRawCapable)
|
|
588
|
+
stdin.setRawMode(wasRaw);
|
|
589
|
+
stdin.pause();
|
|
590
|
+
if (inputPump !== undefined)
|
|
591
|
+
await inputPump.catch(() => { });
|
|
592
|
+
const current = active;
|
|
593
|
+
active = undefined;
|
|
594
|
+
if (current !== undefined) {
|
|
595
|
+
await current.client.releaseController(current.lease).catch(() => { });
|
|
596
|
+
current.client.close();
|
|
597
|
+
}
|
|
421
598
|
}
|
|
422
|
-
}
|
|
423
|
-
if (detached) {
|
|
424
|
-
stdout.write("\r\n[h2a] detached from native session (session keeps running)\r\n");
|
|
425
599
|
}
|
|
426
600
|
return exitCode;
|
|
427
601
|
}
|
|
428
602
|
export async function runNativeTerminalOp(argv) {
|
|
429
603
|
const parsed = parseArgv(argv);
|
|
430
|
-
const socketPath = socketPathFromEnv();
|
|
604
|
+
const socketPath = parsed.flags.get("socket") ?? socketPathFromEnv();
|
|
431
605
|
switch (parsed.op) {
|
|
432
606
|
case "ensure-host": {
|
|
433
|
-
|
|
607
|
+
let selected;
|
|
608
|
+
try {
|
|
609
|
+
selected = parsed.flags.has("socket") ? { client: await connectExisting(socketPath), socketPath }
|
|
610
|
+
: await selectLaunchClient(parsed.flags.get("fenced") === "true");
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
emit({ code: "native-inventory-unknown", hosts: [{ socketPath: error?.socketPath ?? socketPath,
|
|
614
|
+
reason: error instanceof Error ? error.message : String(error) }] });
|
|
615
|
+
return 1;
|
|
616
|
+
}
|
|
617
|
+
const { client } = selected;
|
|
434
618
|
const ping = await client.ping();
|
|
435
619
|
client.close();
|
|
436
|
-
emit({ hostPid: ping.hostPid, generation: ping.generation, socketPath, launchFence: ping.launchFence === true });
|
|
620
|
+
emit({ hostPid: ping.hostPid, generation: ping.generation, socketPath: selected.socketPath, launchFence: ping.launchFence === true });
|
|
437
621
|
return 0;
|
|
438
622
|
}
|
|
439
623
|
case "list": {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
444
|
-
catch {
|
|
445
|
-
emit({ sessions: [] });
|
|
446
|
-
return 0;
|
|
447
|
-
}
|
|
448
|
-
const sessions = await client.list();
|
|
449
|
-
client.close();
|
|
450
|
-
emit({ sessions });
|
|
624
|
+
const inventory = await inventoryFor(parsed);
|
|
625
|
+
emit({ sessions: inventory.sessions, complete: inventory.complete,
|
|
626
|
+
hosts: inventory.hosts.map(({ socketPath, reason, absent }) => ({ socketPath, reachable: !absent && reason === undefined, ...(absent ? { absent } : {}), ...(reason ? { reason } : {}) })) });
|
|
451
627
|
return 0;
|
|
452
628
|
}
|
|
453
629
|
case "state": {
|
|
454
|
-
const
|
|
630
|
+
const owner = await owningClient(parsed, required(parsed, "id"));
|
|
631
|
+
const { client } = owner;
|
|
455
632
|
const state = await client.state(required(parsed, "id"));
|
|
456
633
|
client.close();
|
|
457
|
-
emit(state);
|
|
634
|
+
emit({ ...state, socketPath: owner.socketPath });
|
|
458
635
|
return 0;
|
|
459
636
|
}
|
|
460
637
|
case "probe": {
|
|
@@ -462,53 +639,46 @@ export async function runNativeTerminalOp(argv) {
|
|
|
462
639
|
// generic nonzero exit, so the synchronous caller never has to guess
|
|
463
640
|
// whether a failure meant "no session" or "the op broke":
|
|
464
641
|
// - live: a reachable host answered and the session is running
|
|
465
|
-
// - dead:
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
642
|
+
// - dead: every known reachable endpoint proves absence, or the
|
|
643
|
+
// owning endpoint reports an exited session
|
|
644
|
+
// - unknown: an incomplete inventory without a found owner; even
|
|
645
|
+
// transport errors without an independent owner-death proof.
|
|
469
646
|
const id = required(parsed, "id");
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
emit({
|
|
490
|
-
verdict: state.status === "running" ? "live" : "dead",
|
|
491
|
-
state,
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
catch (error) {
|
|
495
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
496
|
-
// The host's own "unknown terminal session" refusal is a POSITIVE
|
|
497
|
-
// absence verdict from a reachable host; any other remote/protocol
|
|
498
|
-
// error proves nothing (fail closed on the caller side).
|
|
499
|
-
emit(error instanceof NativeTerminalRemoteError &&
|
|
500
|
-
message.includes("unknown terminal session")
|
|
501
|
-
? { verdict: "dead", reason: message }
|
|
502
|
-
: { verdict: "unknown", reason: message });
|
|
503
|
-
}
|
|
504
|
-
finally {
|
|
505
|
-
client.close();
|
|
506
|
-
}
|
|
647
|
+
const owner = resolveNativeOwner(id, await inventoryFor(parsed));
|
|
648
|
+
if (owner.state === "found")
|
|
649
|
+
emit({ verdict: owner.session.status === "running" ? "live" : "dead",
|
|
650
|
+
state: { ...owner.session, socketPath: owner.socketPath } });
|
|
651
|
+
else if (owner.state === "absent")
|
|
652
|
+
emit({ verdict: "dead" });
|
|
653
|
+
else
|
|
654
|
+
emit({ verdict: "unknown", code: owner.state,
|
|
655
|
+
...(owner.state === "unknown" ? { reason: owner.reason } : { sockets: owner.sockets, reason: "ambiguous-owner" }) });
|
|
656
|
+
return 0;
|
|
657
|
+
}
|
|
658
|
+
case "admit": {
|
|
659
|
+
const inventory = await inventoryFor({ ...parsed, flags: new Map() });
|
|
660
|
+
const ids = [required(parsed, "id"), parsed.flags.get("sidecar")].filter((id) => !!id);
|
|
661
|
+
const collision = inventory.sessions.find(session => ids.includes(session.id) &&
|
|
662
|
+
(session.status !== "exited" || session.socketPath !== parsed.flags.get("launch-socket")));
|
|
663
|
+
emit(collision ? { code: "native-name-collision", id: collision.id, socketPath: collision.socketPath }
|
|
664
|
+
: !inventory.complete ? { code: "native-inventory-unknown", hosts: inventory.hosts.map(({ socketPath, reason }) => ({ socketPath, reason })) }
|
|
665
|
+
: { admitted: true });
|
|
507
666
|
return 0;
|
|
508
667
|
}
|
|
509
668
|
case "create": {
|
|
510
|
-
const client = await ensureClient(socketPath);
|
|
669
|
+
const client = parsed.flags.has("socket") ? await connectExisting(socketPath) : await ensureClient(socketPath);
|
|
511
670
|
const id = required(parsed, "id");
|
|
671
|
+
if (parsed.flags.has("incarnation") || socketPath.endsWith("/native-terminal.lf1.sock")) {
|
|
672
|
+
if (parsed.flags.has("incarnation") && (await client.ping()).launchFence !== true)
|
|
673
|
+
throw new Error("selected native host does not provide launchFence");
|
|
674
|
+
const inventory = await inventoryFor({ ...parsed, flags: new Map() });
|
|
675
|
+
const ids = [id, ...(parsed.flags.has("sidecar") ? [required(parsed, "sidecar")]
|
|
676
|
+
: !id.endsWith(".h2a") ? [`${id}.h2a`] : [])];
|
|
677
|
+
if (!inventory.complete || inventory.sessions.some(session => ids.includes(session.id) &&
|
|
678
|
+
(session.status !== "exited" || session.socketPath !== socketPath))) {
|
|
679
|
+
throw new Error("native creation admission changed; refusing possible name collision");
|
|
680
|
+
}
|
|
681
|
+
}
|
|
512
682
|
const cwd = required(parsed, "cwd");
|
|
513
683
|
const cols = Number(parsed.flags.get("cols") ?? 160);
|
|
514
684
|
const rows = Number(parsed.flags.get("rows") ?? 48);
|
|
@@ -535,7 +705,8 @@ export async function runNativeTerminalOp(argv) {
|
|
|
535
705
|
return 0;
|
|
536
706
|
}
|
|
537
707
|
case "drive": {
|
|
538
|
-
const target =
|
|
708
|
+
const target = parsed.flags.has("id")
|
|
709
|
+
? { state: "found", id: required(parsed, "id") } : nativeDriveSessionId(required(parsed, "target"));
|
|
539
710
|
if (target.state === "unresolved") {
|
|
540
711
|
emit({ outcome: "unresolved" });
|
|
541
712
|
return 0;
|
|
@@ -552,7 +723,7 @@ export async function runNativeTerminalOp(argv) {
|
|
|
552
723
|
});
|
|
553
724
|
return 0;
|
|
554
725
|
}
|
|
555
|
-
const client = await
|
|
726
|
+
const { client } = await owningClient(parsed, target.id);
|
|
556
727
|
try {
|
|
557
728
|
let lease;
|
|
558
729
|
try {
|
|
@@ -581,8 +752,8 @@ export async function runNativeTerminalOp(argv) {
|
|
|
581
752
|
}
|
|
582
753
|
case "write":
|
|
583
754
|
case "paste": {
|
|
584
|
-
const client = await connectExisting(socketPath);
|
|
585
755
|
const id = required(parsed, "id");
|
|
756
|
+
const { client } = await owningClient(parsed, id);
|
|
586
757
|
const text = Buffer.from(required(parsed, "b64"), "base64").toString("utf8");
|
|
587
758
|
await withController(client, id, async (lease) => {
|
|
588
759
|
if (parsed.op === "paste") {
|
|
@@ -598,14 +769,14 @@ export async function runNativeTerminalOp(argv) {
|
|
|
598
769
|
return 0;
|
|
599
770
|
}
|
|
600
771
|
case "enter": {
|
|
601
|
-
const client = await
|
|
772
|
+
const { client } = await owningClient(parsed, required(parsed, "id"));
|
|
602
773
|
await withController(client, required(parsed, "id"), (lease) => client.write(lease, "\r"));
|
|
603
774
|
client.close();
|
|
604
775
|
emit({ ok: true });
|
|
605
776
|
return 0;
|
|
606
777
|
}
|
|
607
778
|
case "capture": {
|
|
608
|
-
const client = await
|
|
779
|
+
const { client } = await owningClient(parsed, required(parsed, "id"));
|
|
609
780
|
const raw = await readAll(client, required(parsed, "id"));
|
|
610
781
|
client.close();
|
|
611
782
|
const budget = Number(parsed.flags.get("bytes") ?? 16384);
|
|
@@ -615,15 +786,23 @@ export async function runNativeTerminalOp(argv) {
|
|
|
615
786
|
return 0;
|
|
616
787
|
}
|
|
617
788
|
case "pid": {
|
|
618
|
-
const client = await
|
|
789
|
+
const { client } = await owningClient(parsed, required(parsed, "id"));
|
|
619
790
|
const state = await client.state(required(parsed, "id"));
|
|
620
791
|
client.close();
|
|
621
792
|
emit({ pid: state.pid, status: state.status });
|
|
622
793
|
return 0;
|
|
623
794
|
}
|
|
795
|
+
case "resize": {
|
|
796
|
+
const id = required(parsed, "id");
|
|
797
|
+
const { client } = await owningClient(parsed, id);
|
|
798
|
+
await withController(client, id, lease => client.resize(lease, Number(required(parsed, "cols")), Number(required(parsed, "rows"))));
|
|
799
|
+
emit({ ok: true });
|
|
800
|
+
return 0;
|
|
801
|
+
}
|
|
802
|
+
case "stop":
|
|
624
803
|
case "kill": {
|
|
625
|
-
const client = await connectExisting(socketPath);
|
|
626
804
|
const id = required(parsed, "id");
|
|
805
|
+
const { client } = await owningClient(parsed, id);
|
|
627
806
|
const signal = parsed.flags.get("signal") ?? "SIGTERM";
|
|
628
807
|
if (signal !== "SIGTERM" && signal !== "SIGKILL" && signal !== "SIGINT" && signal !== "SIGHUP") {
|
|
629
808
|
throw new Error(`unsupported signal: ${signal}`);
|
|
@@ -653,9 +832,10 @@ export async function runNativeTerminalOp(argv) {
|
|
|
653
832
|
emit(after);
|
|
654
833
|
return after.status === "exited" ? 0 : 1;
|
|
655
834
|
}
|
|
835
|
+
case "stop-if-incarnation":
|
|
656
836
|
case "kill-if-incarnation": {
|
|
657
|
-
const client = await connectExisting(socketPath);
|
|
658
837
|
const id = required(parsed, "id");
|
|
838
|
+
const { client } = await owningClient(parsed, id);
|
|
659
839
|
const generation = required(parsed, "generation");
|
|
660
840
|
const incarnation = required(parsed, "incarnation");
|
|
661
841
|
const signal = parsed.flags.get("signal") ?? "SIGTERM";
|
|
@@ -687,7 +867,11 @@ export async function runNativeTerminalOp(argv) {
|
|
|
687
867
|
return after.status === "exited" ? 0 : 1;
|
|
688
868
|
}
|
|
689
869
|
case "attach": {
|
|
690
|
-
|
|
870
|
+
const id = required(parsed, "id");
|
|
871
|
+
const owner = await owningClient(parsed, id);
|
|
872
|
+
owner.client.close();
|
|
873
|
+
// Reconnects retain this owner for the entire interactive operation.
|
|
874
|
+
return runAttach(id, defaultAttachRuntime(owner.socketPath));
|
|
691
875
|
}
|
|
692
876
|
case "host-stop": {
|
|
693
877
|
let client;
|
|
@@ -726,6 +910,8 @@ if (isEntryPoint()) {
|
|
|
726
910
|
.then((code) => {
|
|
727
911
|
process.exitCode = code;
|
|
728
912
|
}, (error) => {
|
|
913
|
+
if (error instanceof NativeOwnerError)
|
|
914
|
+
emit(error.refusal);
|
|
729
915
|
process.stderr.write(`[h2a-native-op] ${error instanceof Error ? error.message : String(error)}\n`);
|
|
730
916
|
process.exitCode = 1;
|
|
731
917
|
})
|