cli-setu 0.2.5 → 0.2.7
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/config.js +12 -0
- package/src/crypto.js +104 -0
- package/src/pair.js +22 -3
- package/src/status.js +32 -9
- package/src/supervisor.js +51 -4
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -44,6 +44,17 @@ function loadOrCreateConfig() {
|
|
|
44
44
|
return { deviceName: defaultDeviceName(), deviceToken: null };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// Section 1 E2E: this device's own X25519 keypair, generated once (first
|
|
48
|
+
// `cli-setu pair`) and reused for the lifetime of this pairing - same
|
|
49
|
+
// lifetime as deviceToken itself, so a re-pair naturally mints a fresh
|
|
50
|
+
// keypair too, same as it mints a fresh token. Private key stored
|
|
51
|
+
// base64-encoded in the same config.json deviceToken already lives in -
|
|
52
|
+
// no new file, no new permissions model; this machine's own user-profile
|
|
53
|
+
// access already protects deviceToken the same way.
|
|
54
|
+
function hasKeypair(config) {
|
|
55
|
+
return !!(config.devicePrivateKey && config.devicePublicKey);
|
|
56
|
+
}
|
|
57
|
+
|
|
47
58
|
module.exports = {
|
|
48
59
|
CONFIG_DIR,
|
|
49
60
|
CONFIG_FILE,
|
|
@@ -51,4 +62,5 @@ module.exports = {
|
|
|
51
62
|
readConfig,
|
|
52
63
|
writeConfig,
|
|
53
64
|
loadOrCreateConfig,
|
|
65
|
+
hasKeypair,
|
|
54
66
|
};
|
package/src/crypto.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Section 1 E2E: real end-to-end encryption for terminal.input/output,
|
|
2
|
+
// so the backend relay can forward packets without being able to read
|
|
3
|
+
// them (previously it could - see this file's own commit message / the
|
|
4
|
+
// privacy policy fix this exists to make true instead of false).
|
|
5
|
+
//
|
|
6
|
+
// Design: X25519 ECDH key exchange, one keypair per laptop, generated
|
|
7
|
+
// once at pair time and reused for the life of that pairing (same
|
|
8
|
+
// lifetime as the device token itself - a re-pair generates a fresh
|
|
9
|
+
// keypair, same as it mints a fresh token). The shared secret is
|
|
10
|
+
// derived once per phone public key via HKDF-SHA256 and cached; AES-
|
|
11
|
+
// 256-GCM encrypts each terminal payload with a fresh random IV.
|
|
12
|
+
//
|
|
13
|
+
// The backend only ever sees: this device's PUBLIC key (at pairing) and
|
|
14
|
+
// ciphertext (at relay time) - never the private key, never the derived
|
|
15
|
+
// secret, never plaintext. That's what makes this actually end-to-end,
|
|
16
|
+
// not just "encrypted somewhere."
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
|
|
19
|
+
// Node's 'x25519' curve via the generic Diffie-Hellman API - available
|
|
20
|
+
// since Node 13, well within this project's >=18 engines requirement.
|
|
21
|
+
function generateKeypair() {
|
|
22
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519');
|
|
23
|
+
return { publicKey, privateKey };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Node's KeyObject export format for a raw X25519 public key is a DER-
|
|
27
|
+
// wrapped SPKI blob, not the raw 32 bytes - stripping to raw bytes here
|
|
28
|
+
// so the wire format matches what @noble/curves on the app side produces
|
|
29
|
+
// and expects directly (no ASN.1 parsing needed on that end).
|
|
30
|
+
const RAW_PUBLIC_KEY_LEN = 32;
|
|
31
|
+
|
|
32
|
+
function publicKeyToRaw(publicKey) {
|
|
33
|
+
const der = publicKey.export({ type: 'spki', format: 'der' });
|
|
34
|
+
return der.subarray(der.length - RAW_PUBLIC_KEY_LEN);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function publicKeyFromRaw(rawBytes) {
|
|
38
|
+
// SPKI header for X25519 is fixed (12 bytes) - prepending it lets
|
|
39
|
+
// Node's crypto module import a raw 32-byte public key the same way
|
|
40
|
+
// it exports one above.
|
|
41
|
+
const SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex');
|
|
42
|
+
const der = Buffer.concat([SPKI_PREFIX, rawBytes]);
|
|
43
|
+
return crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function publicKeyToBase64(publicKey) {
|
|
47
|
+
return publicKeyToRaw(publicKey).toString('base64');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Private key storage shape: Node's native PKCS8 DER export, base64-
|
|
51
|
+
// encoded - config.js stores it this way in config.json alongside
|
|
52
|
+
// deviceToken (same file, same OS-level file permissions already
|
|
53
|
+
// protecting that token, so no new secret-storage mechanism needed).
|
|
54
|
+
function privateKeyToBase64(privateKey) {
|
|
55
|
+
return privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function privateKeyFromBase64(base64) {
|
|
59
|
+
return crypto.createPrivateKey({ key: Buffer.from(base64, 'base64'), format: 'der', type: 'pkcs8' });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function deriveSharedSecret(privateKey, peerPublicKeyBase64) {
|
|
63
|
+
const peerPublicKey = publicKeyFromRaw(Buffer.from(peerPublicKeyBase64, 'base64'));
|
|
64
|
+
const rawSecret = crypto.diffieHellman({ privateKey, publicKey: peerPublicKey });
|
|
65
|
+
// HKDF-SHA256, no salt (both sides derive deterministically from the
|
|
66
|
+
// same ECDH output - a salt would need its own exchange for no real
|
|
67
|
+
// benefit here), fixed info string scoping this key to this exact
|
|
68
|
+
// purpose so it can never collide with a key derived elsewhere for a
|
|
69
|
+
// different purpose from the same raw ECDH secret.
|
|
70
|
+
return Buffer.from(crypto.hkdfSync('sha256', rawSecret, Buffer.alloc(0), 'cli-setu-terminal-v1', 32));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// AES-256-GCM: a fresh random 12-byte IV per message (GCM's own
|
|
74
|
+
// requirement - reusing an IV with the same key breaks its security
|
|
75
|
+
// guarantees entirely), the 16-byte auth tag appended, both prepended/
|
|
76
|
+
// appended to the ciphertext so the wire format is one self-contained
|
|
77
|
+
// base64 string: IV (12) || ciphertext || authTag (16).
|
|
78
|
+
function encrypt(sharedSecret, plaintext) {
|
|
79
|
+
const iv = crypto.randomBytes(12);
|
|
80
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', sharedSecret, iv);
|
|
81
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
82
|
+
const authTag = cipher.getAuthTag();
|
|
83
|
+
return Buffer.concat([iv, ciphertext, authTag]).toString('base64');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function decrypt(sharedSecret, payloadBase64) {
|
|
87
|
+
const buf = Buffer.from(payloadBase64, 'base64');
|
|
88
|
+
const iv = buf.subarray(0, 12);
|
|
89
|
+
const authTag = buf.subarray(buf.length - 16);
|
|
90
|
+
const ciphertext = buf.subarray(12, buf.length - 16);
|
|
91
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', sharedSecret, iv);
|
|
92
|
+
decipher.setAuthTag(authTag);
|
|
93
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = {
|
|
97
|
+
generateKeypair,
|
|
98
|
+
publicKeyToBase64,
|
|
99
|
+
privateKeyToBase64,
|
|
100
|
+
privateKeyFromBase64,
|
|
101
|
+
deriveSharedSecret,
|
|
102
|
+
encrypt,
|
|
103
|
+
decrypt,
|
|
104
|
+
};
|
package/src/pair.js
CHANGED
|
@@ -5,11 +5,25 @@
|
|
|
5
5
|
// of a hand-typed argv, and the result is saved to ~/.cli-setu/config.json
|
|
6
6
|
// instead of a per-device-name file in the install folder.
|
|
7
7
|
const qrcode = require('qrcode-terminal');
|
|
8
|
-
const { loadOrCreateConfig, writeConfig } = require('./config');
|
|
8
|
+
const { loadOrCreateConfig, writeConfig, hasKeypair } = require('./config');
|
|
9
|
+
const { generateKeypair, publicKeyToBase64, privateKeyToBase64 } = require('./crypto');
|
|
9
10
|
|
|
10
11
|
async function pair(authServer, authServerPublic) {
|
|
11
12
|
const config = loadOrCreateConfig();
|
|
12
13
|
|
|
14
|
+
// Section 1 E2E: generate this device's own keypair up front, before
|
|
15
|
+
// either pairing path below, so both qr-start and pair/start can send
|
|
16
|
+
// the SAME public key no matter which path the user actually takes
|
|
17
|
+
// (scan vs typed code - both start by calling this function). A
|
|
18
|
+
// re-pair (config.deviceToken was cleared just below, or this is a
|
|
19
|
+
// genuinely fresh config) always gets a FRESH keypair - never reuses
|
|
20
|
+
// a stale one left over from a previous, now-dead pairing.
|
|
21
|
+
if (!hasKeypair(config)) {
|
|
22
|
+
const { privateKey, publicKey } = generateKeypair();
|
|
23
|
+
config.devicePrivateKey = privateKeyToBase64(privateKey);
|
|
24
|
+
config.devicePublicKey = publicKeyToBase64(publicKey);
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
if (config.deviceToken) {
|
|
14
28
|
// Real bug found by testing: unpairing from the phone deletes the
|
|
15
29
|
// device server-side, but the laptop's local config.json still has
|
|
@@ -55,7 +69,7 @@ async function pair(authServer, authServerPublic) {
|
|
|
55
69
|
const startRes = await fetch(`${authServer}/device/pair/qr-start`, {
|
|
56
70
|
method: 'POST',
|
|
57
71
|
headers: { 'Content-Type': 'application/json' },
|
|
58
|
-
body: JSON.stringify({ deviceName: config.deviceName, sessionId: 'pairing' }),
|
|
72
|
+
body: JSON.stringify({ deviceName: config.deviceName, sessionId: 'pairing', devicePublicKey: config.devicePublicKey }),
|
|
59
73
|
});
|
|
60
74
|
const { qrToken } = await startRes.json();
|
|
61
75
|
|
|
@@ -69,7 +83,7 @@ async function pair(authServer, authServerPublic) {
|
|
|
69
83
|
const codeRes = await fetch(`${authServer}/device/pair/start`, {
|
|
70
84
|
method: 'POST',
|
|
71
85
|
headers: { 'Content-Type': 'application/json' },
|
|
72
|
-
body: JSON.stringify({ deviceName: config.deviceName }),
|
|
86
|
+
body: JSON.stringify({ deviceName: config.deviceName, devicePublicKey: config.devicePublicKey }),
|
|
73
87
|
});
|
|
74
88
|
const { code: typedCode, pairId: typedPairId } = await codeRes.json();
|
|
75
89
|
console.log(`[cli-setu] Or type this code in the app's Devices tab: ${typedCode}`);
|
|
@@ -84,6 +98,10 @@ async function pair(authServer, authServerPublic) {
|
|
|
84
98
|
if (status.status === 'CONFIRMED') {
|
|
85
99
|
confirmed = true;
|
|
86
100
|
config.deviceToken = status.deviceToken;
|
|
101
|
+
// Section 1 E2E: the phone's public key, so this laptop can derive
|
|
102
|
+
// the shared secret for terminal.input/output - see crypto.js's
|
|
103
|
+
// deriveSharedSecret and supervisor.js's own use of it.
|
|
104
|
+
if (status.phonePublicKey) config.phonePublicKey = status.phonePublicKey;
|
|
87
105
|
writeConfig(config);
|
|
88
106
|
console.log(`[cli-setu] ✓ Paired via QR and saved to ~/.cli-setu/config.json`);
|
|
89
107
|
break;
|
|
@@ -100,6 +118,7 @@ async function pair(authServer, authServerPublic) {
|
|
|
100
118
|
if (typedStatus.status === 'CONFIRMED') {
|
|
101
119
|
confirmed = true;
|
|
102
120
|
config.deviceToken = typedStatus.deviceToken;
|
|
121
|
+
if (typedStatus.phonePublicKey) config.phonePublicKey = typedStatus.phonePublicKey;
|
|
103
122
|
writeConfig(config);
|
|
104
123
|
console.log(`[cli-setu] ✓ Paired via typed code and saved to ~/.cli-setu/config.json`);
|
|
105
124
|
break;
|
package/src/status.js
CHANGED
|
@@ -24,26 +24,49 @@ async function status(authServer) {
|
|
|
24
24
|
console.log('[cli-setu] Not paired. Run `cli-setu pair` to get started.');
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
|
-
console.log(`[cli-setu] Paired as "${config.deviceName}".`);
|
|
28
27
|
|
|
29
|
-
// Real
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
28
|
+
// Real bug found by testing: this used to print "Paired as X" straight
|
|
29
|
+
// from the local config file with no server check at all - unpairing
|
|
30
|
+
// from the PHONE deletes the device server-side, but the laptop's own
|
|
31
|
+
// config.json still has the old (now-dead) token, so `status` kept
|
|
32
|
+
// claiming the device was paired indefinitely, with nothing telling the
|
|
33
|
+
// user their session was actually dead until something else failed
|
|
34
|
+
// later. Same real distinction pair.js's own re-pair check already
|
|
35
|
+
// makes: a 401 means the server genuinely doesn't recognize this token
|
|
36
|
+
// anymore (deliberately re-pairable from here); a network error/
|
|
37
|
+
// unreachable server is a THIRD, distinct outcome, not treated as
|
|
38
|
+
// "unpaired" - it falls through to reporting local state as before,
|
|
39
|
+
// same as this check not existing, so a flaky-wifi run doesn't get
|
|
40
|
+
// told it's unpaired when it might just be offline.
|
|
41
|
+
let serverSaysUnpaired = false;
|
|
42
|
+
let userId = null;
|
|
35
43
|
try {
|
|
36
44
|
const whoamiRes = await fetch(`${authServer}/device/whoami`, {
|
|
37
45
|
headers: { Authorization: `Bearer ${config.deviceToken}` },
|
|
38
46
|
});
|
|
39
47
|
if (whoamiRes.ok) {
|
|
40
|
-
|
|
41
|
-
|
|
48
|
+
({ userId } = await whoamiRes.json());
|
|
49
|
+
} else if (whoamiRes.status === 401) {
|
|
50
|
+
serverSaysUnpaired = true;
|
|
42
51
|
}
|
|
43
52
|
} catch {
|
|
44
53
|
// Offline, or the server's unreachable - status still works from local state alone.
|
|
45
54
|
}
|
|
46
55
|
|
|
56
|
+
if (serverSaysUnpaired) {
|
|
57
|
+
// Deliberately NOT returning here - a `cli-setu` process can still be
|
|
58
|
+
// genuinely running (pointlessly retrying against this now-dead
|
|
59
|
+
// token, see supervisor.js's own 401 handling), and the user should
|
|
60
|
+
// still be told that so they know to `cli-setu stop` it rather than
|
|
61
|
+
// it silently retrying in the background forever.
|
|
62
|
+
console.log(`[cli-setu] "${config.deviceName}" was unpaired from the app. Run \`cli-setu pair\` to reconnect.`);
|
|
63
|
+
} else {
|
|
64
|
+
console.log(`[cli-setu] Paired as "${config.deviceName}".`);
|
|
65
|
+
if (userId) {
|
|
66
|
+
console.log(`[cli-setu] Paired to account: ${userId}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
47
70
|
let files;
|
|
48
71
|
try {
|
|
49
72
|
files = fs.readdirSync(CONFIG_DIR).filter((f) => f.startsWith('session.') && f.endsWith('.pid'));
|
package/src/supervisor.js
CHANGED
|
@@ -42,6 +42,7 @@ const execAsync = promisify(exec);
|
|
|
42
42
|
// data, not part of the command syntax itself.
|
|
43
43
|
const execFileAsync = promisify(execFile);
|
|
44
44
|
const { getAdapter } = require('./cli-adapters');
|
|
45
|
+
const setuCrypto = require('./crypto');
|
|
45
46
|
|
|
46
47
|
// Real bug found by testing: this script's own console.log/console.error
|
|
47
48
|
// calls write to the SAME process.stdout the PTY's raw output is echoed
|
|
@@ -166,6 +167,25 @@ let ws = null;
|
|
|
166
167
|
let azureReady = false;
|
|
167
168
|
const pendingPrompts = new Map();
|
|
168
169
|
|
|
170
|
+
// Section 1 E2E: derived once at startup, same lifetime as this process.
|
|
171
|
+
// Null whenever either half is missing - a device paired before this
|
|
172
|
+
// feature existed has devicePrivateKey/phonePublicKey absent from its
|
|
173
|
+
// config.json, and terminal.input/output/snapshot below deliberately
|
|
174
|
+
// fall back to sending PLAINTEXT for exactly that case (see this
|
|
175
|
+
// project's own decision: no forced re-pair, upgrade happens naturally
|
|
176
|
+
// on the next real re-pair). Never silently "half encrypt" - either a
|
|
177
|
+
// real secret is available and used for every payload this session, or
|
|
178
|
+
// none is and nothing here pretends otherwise.
|
|
179
|
+
let sharedSecret = null;
|
|
180
|
+
if (config.devicePrivateKey && config.phonePublicKey) {
|
|
181
|
+
try {
|
|
182
|
+
const privateKey = setuCrypto.privateKeyFromBase64(config.devicePrivateKey);
|
|
183
|
+
sharedSecret = setuCrypto.deriveSharedSecret(privateKey, config.phonePublicKey);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.error('[cli-setu] Could not derive E2E encryption key (falling back to unencrypted relay):', err.message);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
169
189
|
// Phase 6, Section 7: a rolling raw-byte scrollback so a phone that opens
|
|
170
190
|
// terminal.html AFTER the session has been running for a while gets caught
|
|
171
191
|
// up instantly via session.snapshot, instead of an empty screen. Capped so
|
|
@@ -283,7 +303,28 @@ async function connectToAzure() {
|
|
|
283
303
|
// Section 11f Step 2: typed input from the phone, written to the PTY
|
|
284
304
|
// exactly like a local keystroke - this is the "arbitrary remote
|
|
285
305
|
// input" half of the DoD, not just approve/reject.
|
|
286
|
-
|
|
306
|
+
//
|
|
307
|
+
// Section 1 E2E: msg.data is ciphertext whenever sharedSecret exists
|
|
308
|
+
// (the phone encrypts before sending - see TerminalScreen.js's own
|
|
309
|
+
// sendInput). msg.encrypted distinguishes the two wire shapes so a
|
|
310
|
+
// pre-upgrade phone build (no E2E yet) and a pre-upgrade laptop
|
|
311
|
+
// build talking to each other don't try to decrypt plaintext or
|
|
312
|
+
// vice versa - see this file's own sharedSecret comment for why
|
|
313
|
+
// that fallback has to be all-or-nothing, never silent guessing.
|
|
314
|
+
let data = msg.data;
|
|
315
|
+
if (msg.encrypted) {
|
|
316
|
+
if (!sharedSecret) {
|
|
317
|
+
log('[supervisor] Dropped an encrypted terminal.input - this device has no shared secret to decrypt it (re-pair to fix).');
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
data = setuCrypto.decrypt(sharedSecret, msg.data);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
log('[supervisor] Failed to decrypt terminal.input, dropping:', err.message);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
ptyProcess.write(data);
|
|
287
328
|
}
|
|
288
329
|
if (msg.type === 'terminal.resize' && msg.sessionId === SESSION_ID) {
|
|
289
330
|
// Real bug found by testing: this used to call ptyProcess.resize()
|
|
@@ -371,14 +412,17 @@ async function sendSnapshot() {
|
|
|
371
412
|
// pre-TUI history too, not just whatever the current alt-screen
|
|
372
413
|
// buffer still happens to show.
|
|
373
414
|
const history = await preservedHistoryText();
|
|
374
|
-
const
|
|
415
|
+
const plainScreen = history ? `${history}\n${scrollback}` : scrollback;
|
|
416
|
+
// Section 1 E2E: see terminal.input's own comment on why this is
|
|
417
|
+
// all-or-nothing, never a silent per-message guess.
|
|
418
|
+
const screen = sharedSecret ? setuCrypto.encrypt(sharedSecret, plainScreen) : plainScreen;
|
|
375
419
|
await fetch(`${AUTH_SERVER}/relay/terminal-snapshot`, {
|
|
376
420
|
method: 'POST',
|
|
377
421
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${deviceToken}` },
|
|
378
422
|
// cols/rows: the real PTY's actual geometry, so a phone attaching
|
|
379
423
|
// can lock its terminal to match instead of guessing its own size -
|
|
380
424
|
// see auth-server.js's matching comment for why this matters.
|
|
381
|
-
body: JSON.stringify({ sessionId: SESSION_ID, screen, cols: PTY_COLS, rows: PTY_ROWS }),
|
|
425
|
+
body: JSON.stringify({ sessionId: SESSION_ID, screen, encrypted: !!sharedSecret, cols: PTY_COLS, rows: PTY_ROWS }),
|
|
382
426
|
});
|
|
383
427
|
} catch (err) {
|
|
384
428
|
log('[supervisor] Failed to send snapshot:', err.message);
|
|
@@ -555,10 +599,13 @@ function flushOutputBuffer() {
|
|
|
555
599
|
// fetch() itself should resolve quickly regardless of Azure's own cost.
|
|
556
600
|
sendInFlight = sendInFlight.then(async () => {
|
|
557
601
|
try {
|
|
602
|
+
// Section 1 E2E: see terminal.input's own comment on why this is
|
|
603
|
+
// all-or-nothing, never a silent per-message guess.
|
|
604
|
+
const data = sharedSecret ? setuCrypto.encrypt(sharedSecret, batch) : batch;
|
|
558
605
|
await fetch(`${AUTH_SERVER}/relay/terminal-output`, {
|
|
559
606
|
method: 'POST',
|
|
560
607
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${deviceToken}` },
|
|
561
|
-
body: JSON.stringify({ sessionId: SESSION_ID, seq: ++outputSeq, data:
|
|
608
|
+
body: JSON.stringify({ sessionId: SESSION_ID, seq: ++outputSeq, data, encrypted: !!sharedSecret }),
|
|
562
609
|
});
|
|
563
610
|
} catch (err) {
|
|
564
611
|
// A dropped terminal.output batch isn't fatal the way a dropped
|