@emptyos/client 0.1.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/LICENSE +21 -0
- package/README.md +264 -0
- package/bin/empty.js +4 -0
- package/lib/browser.js +23 -0
- package/lib/catalog-output.js +73 -0
- package/lib/cli.js +985 -0
- package/lib/commands.js +272 -0
- package/lib/config.js +189 -0
- package/lib/constants.js +13 -0
- package/lib/errors.js +16 -0
- package/lib/git.js +28 -0
- package/lib/koans.js +26 -0
- package/lib/manifest.js +117 -0
- package/lib/pairing.js +466 -0
- package/lib/platform-update.js +304 -0
- package/lib/process.js +97 -0
- package/lib/rpc.js +161 -0
- package/lib/skill-install.js +65 -0
- package/lib/thing-put.js +406 -0
- package/lib/tunnel-proxy.js +119 -0
- package/package.json +29 -0
- package/skills/emptyos-computer/SKILL.md +233 -0
- package/skills/emptyos-computer/agents/openai.yaml +4 -0
package/lib/pairing.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import https from 'node:https';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { openInBrowser as defaultOpenInBrowser } from './browser.js';
|
|
8
|
+
import { configPath, readConfig, validateAlias, validateTarget, writeConfig } from './config.js';
|
|
9
|
+
import { ClientError } from './errors.js';
|
|
10
|
+
import { runCaptured } from './process.js';
|
|
11
|
+
|
|
12
|
+
const CREATE_PATH = '/_system/agents/pairings';
|
|
13
|
+
const STATUS_PATH = '/_system/agents/pairings/status';
|
|
14
|
+
const AGENTS_PATH = '/_system/agents';
|
|
15
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
16
|
+
const POLL_INTERVAL_MS = 1000;
|
|
17
|
+
const MAX_PAIRING_LIFETIME_MS = 10 * 60 * 1000;
|
|
18
|
+
const PAIRING_ID_RE = /^[A-Za-z0-9_-]{22}$/;
|
|
19
|
+
const PAIRING_SECRET_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
20
|
+
const PAIRING_CODE_RE = /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{8}$/;
|
|
21
|
+
const AGENT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
22
|
+
const SSH_USER_RE = /^[a-z_][a-z0-9_-]{0,31}$/;
|
|
23
|
+
|
|
24
|
+
export function validateOwnerOrigin(value) {
|
|
25
|
+
const hasExplicitScheme = typeof value === 'string'
|
|
26
|
+
&& /^[A-Za-z][A-Za-z0-9+.-]*:\/\//u.test(value);
|
|
27
|
+
let url;
|
|
28
|
+
try {
|
|
29
|
+
if (typeof value !== 'string' || (!hasExplicitScheme && /^[\\/]/u.test(value))) {
|
|
30
|
+
throw new TypeError('invalid hostname');
|
|
31
|
+
}
|
|
32
|
+
url = new URL(hasExplicitScheme ? value : `https://${value}`);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new ClientError('Computer URL must be a hostname or HTTPS origin without credentials or a path');
|
|
35
|
+
}
|
|
36
|
+
if (
|
|
37
|
+
url.protocol !== 'https:'
|
|
38
|
+
|| url.username
|
|
39
|
+
|| url.password
|
|
40
|
+
|| url.pathname !== '/'
|
|
41
|
+
|| url.search
|
|
42
|
+
|| url.hash
|
|
43
|
+
) {
|
|
44
|
+
throw new ClientError('Computer URL must be a hostname or HTTPS origin without credentials or a path');
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
validateTarget(`emptyos@${url.hostname}`);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new ClientError('Computer URL must contain a hostname supported by EmptyOS SSH transport');
|
|
50
|
+
}
|
|
51
|
+
return url.origin;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validatePairingLabel(value) {
|
|
55
|
+
if (typeof value !== 'string' || /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
56
|
+
throw new ClientError('Pairing label must be 1-80 characters without controls');
|
|
57
|
+
}
|
|
58
|
+
const label = value.trim();
|
|
59
|
+
if (label.length === 0 || label.length > 80) {
|
|
60
|
+
throw new ClientError('Pairing label must be 1-80 characters without controls');
|
|
61
|
+
}
|
|
62
|
+
return label;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function deriveComputerAlias(ownerOrigin) {
|
|
66
|
+
const origin = validateOwnerOrigin(ownerOrigin);
|
|
67
|
+
const alias = new URL(origin).hostname.split('.', 1)[0];
|
|
68
|
+
try {
|
|
69
|
+
return validateAlias(alias);
|
|
70
|
+
} catch {
|
|
71
|
+
throw new ClientError(`Cannot derive a computer alias from computer URL ${JSON.stringify(origin)}; use --as <alias>`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function pairComputer({ alias, ownerOrigin, label, browser = true }, {
|
|
76
|
+
env = process.env,
|
|
77
|
+
stdout = process.stdout,
|
|
78
|
+
request = requestJson,
|
|
79
|
+
openInBrowser = defaultOpenInBrowser,
|
|
80
|
+
sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
81
|
+
runKeygen = defaultRunKeygen,
|
|
82
|
+
random = randomBytes,
|
|
83
|
+
now = Date.now,
|
|
84
|
+
hostname = os.hostname,
|
|
85
|
+
} = {}) {
|
|
86
|
+
validateAlias(alias);
|
|
87
|
+
const origin = validateOwnerOrigin(ownerOrigin);
|
|
88
|
+
const pairingLabel = validatePairingLabel(label ?? hostname());
|
|
89
|
+
rejectExistingConnection(alias, origin, env);
|
|
90
|
+
|
|
91
|
+
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'emptyos-client-pairing.'));
|
|
92
|
+
try {
|
|
93
|
+
fs.chmodSync(temporaryDirectory, 0o700);
|
|
94
|
+
const temporaryIdentity = path.join(temporaryDirectory, 'id_ed25519');
|
|
95
|
+
await createKeyPair(temporaryIdentity, env, runKeygen);
|
|
96
|
+
const publicKey = readPublicKey(`${temporaryIdentity}.pub`, 'generated SSH public key');
|
|
97
|
+
const publicKeyFingerprint = sshFingerprint(publicKey);
|
|
98
|
+
const tokenBytes = random(32);
|
|
99
|
+
if (!(tokenBytes instanceof Uint8Array) || tokenBytes.byteLength !== 32) {
|
|
100
|
+
throw new ClientError('Pairing token generator must return exactly 32 bytes');
|
|
101
|
+
}
|
|
102
|
+
const tunnelToken = `v1.${Buffer.from(tokenBytes).toString('base64url')}`;
|
|
103
|
+
const tokenHash = createHash('sha256').update(tunnelToken, 'utf8').digest('hex');
|
|
104
|
+
|
|
105
|
+
const created = await request(new URL(CREATE_PATH, origin), {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: { 'content-type': 'application/json' },
|
|
108
|
+
body: JSON.stringify({ label: pairingLabel, publicKey, tokenHash }),
|
|
109
|
+
});
|
|
110
|
+
if (created.status !== 202) throw httpError('create pairing', created.status);
|
|
111
|
+
const pairing = parseCreatedPairing(created.value, now());
|
|
112
|
+
const agentsUrl = new URL(AGENTS_PATH, origin).href;
|
|
113
|
+
stdout.write(`Pairing code: ${pairing.code}\nSSH key: ${publicKeyFingerprint}\nApprove it at:\n ${agentsUrl}\n`);
|
|
114
|
+
if (browser) {
|
|
115
|
+
stdout.write(await openInBrowser(agentsUrl)
|
|
116
|
+
? 'Opening the approval page in your browser...\n'
|
|
117
|
+
: 'Cannot open a browser here; open the approval page yourself\n');
|
|
118
|
+
}
|
|
119
|
+
stdout.write('Waiting for approval...\n');
|
|
120
|
+
|
|
121
|
+
const approved = await waitForApproval(origin, pairing, { request, sleep, now });
|
|
122
|
+
const hostKey = inspectEd25519PublicKey(approved.sshHostKey, 'SSH host key');
|
|
123
|
+
if (!SSH_USER_RE.test(approved.sshUser)) {
|
|
124
|
+
throw new ClientError('Computer returned an invalid SSH user during pairing', 'pairing-failed');
|
|
125
|
+
}
|
|
126
|
+
if (!AGENT_ID_RE.test(approved.agentId)) {
|
|
127
|
+
throw new ClientError('Computer returned an invalid agent id during pairing', 'pairing-failed');
|
|
128
|
+
}
|
|
129
|
+
const ownerHost = new URL(origin).hostname;
|
|
130
|
+
const target = validateTarget(`${approved.sshUser}@${ownerHost}`);
|
|
131
|
+
const paths = installCredentials({
|
|
132
|
+
alias,
|
|
133
|
+
env,
|
|
134
|
+
ownerOrigin: origin,
|
|
135
|
+
temporaryIdentity,
|
|
136
|
+
tunnelToken,
|
|
137
|
+
ownerHost,
|
|
138
|
+
sshHostKey: hostKey,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
let committed = false;
|
|
142
|
+
let selected = false;
|
|
143
|
+
let previousDefault = null;
|
|
144
|
+
try {
|
|
145
|
+
const config = readConfig(env);
|
|
146
|
+
rejectExistingConnectionInConfig(config, alias, origin);
|
|
147
|
+
config.computers[alias] = {
|
|
148
|
+
target,
|
|
149
|
+
ownerOrigin: origin,
|
|
150
|
+
tunnelTokenPath: paths.tunnelTokenPath,
|
|
151
|
+
sshIdentityPath: paths.sshIdentityPath,
|
|
152
|
+
sshKnownHostsPath: paths.sshKnownHostsPath,
|
|
153
|
+
};
|
|
154
|
+
previousDefault = config.defaultComputer;
|
|
155
|
+
if (previousDefault === null) {
|
|
156
|
+
config.defaultComputer = alias;
|
|
157
|
+
selected = true;
|
|
158
|
+
}
|
|
159
|
+
writeConfig(config, env);
|
|
160
|
+
committed = true;
|
|
161
|
+
} finally {
|
|
162
|
+
if (!committed) removeInstalledCredentials(paths.directory);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
stdout.write(selected
|
|
166
|
+
? `Successfully connected computer ${alias} (${ownerHost}) and selected it as the default\n`
|
|
167
|
+
+ 'List computers with `empty computers`\n'
|
|
168
|
+
: `Successfully connected computer ${alias} (${ownerHost}); the default computer remains ${previousDefault}\n`
|
|
169
|
+
+ `List computers with \`empty computers\`; switch with \`empty computer use ${alias}\`\n`);
|
|
170
|
+
return { alias, target, ownerOrigin: origin, agentId: approved.agentId };
|
|
171
|
+
} finally {
|
|
172
|
+
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function createKeyPair(identityPath, env, runKeygen) {
|
|
177
|
+
const command = env.EMPTYOS_SSH_KEYGEN_COMMAND || 'ssh-keygen';
|
|
178
|
+
let result;
|
|
179
|
+
try {
|
|
180
|
+
result = await runKeygen(command, [
|
|
181
|
+
'-q', '-t', 'ed25519', '-N', '', '-C', '', '-f', identityPath,
|
|
182
|
+
], { env });
|
|
183
|
+
} catch (error) {
|
|
184
|
+
throw new ClientError(`Cannot generate pairing SSH key: ${error.message}`, 'pairing-failed');
|
|
185
|
+
}
|
|
186
|
+
if (!result || result.code !== 0) {
|
|
187
|
+
const detail = result?.stderr?.trim() || result?.stdout?.trim();
|
|
188
|
+
throw new ClientError(
|
|
189
|
+
detail ? `cannot generate pairing SSH key: ${detail}` : 'cannot generate pairing SSH key',
|
|
190
|
+
'pairing-failed',
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const privateStat = safeRegularFile(identityPath, 'generated SSH private key');
|
|
194
|
+
if ((privateStat.mode & 0o777) !== 0o600) {
|
|
195
|
+
throw new ClientError('Generated SSH private key must have mode 0600', 'pairing-failed');
|
|
196
|
+
}
|
|
197
|
+
safeRegularFile(`${identityPath}.pub`, 'generated SSH public key');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function defaultRunKeygen(command, args, options) {
|
|
201
|
+
return runCaptured(command, args, options);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function waitForApproval(origin, pairing, { request, sleep, now }) {
|
|
205
|
+
const statusUrl = new URL(STATUS_PATH, origin);
|
|
206
|
+
while (now() < pairing.expiresAtMilliseconds) {
|
|
207
|
+
const response = await request(statusUrl, {
|
|
208
|
+
method: 'GET',
|
|
209
|
+
headers: {
|
|
210
|
+
Authorization: `Pairing ${pairing.pairingSecret}`,
|
|
211
|
+
'X-EmptyOS-Pairing-Id': pairing.pairingId,
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
if (response.status !== 200) throw httpError('check pairing status', response.status);
|
|
215
|
+
const status = parsePairingStatus(response.value);
|
|
216
|
+
if (status.status === 'approved') return status;
|
|
217
|
+
if (status.status === 'rejected') {
|
|
218
|
+
throw new ClientError('The computer owner rejected this pairing', 'pairing-rejected');
|
|
219
|
+
}
|
|
220
|
+
if (status.status === 'expired') {
|
|
221
|
+
throw new ClientError('The pairing request expired before approval', 'pairing-expired');
|
|
222
|
+
}
|
|
223
|
+
await sleep(POLL_INTERVAL_MS);
|
|
224
|
+
}
|
|
225
|
+
throw new ClientError('The pairing request expired before approval', 'pairing-expired');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function parseCreatedPairing(value, currentTime) {
|
|
229
|
+
requireObjectKeys(value, ['code', 'expiresAt', 'pairingId', 'pairingSecret'], 'create pairing');
|
|
230
|
+
if (!PAIRING_ID_RE.test(value.pairingId) || !PAIRING_SECRET_RE.test(value.pairingSecret)
|
|
231
|
+
|| !PAIRING_CODE_RE.test(value.code)) {
|
|
232
|
+
throw new ClientError('Computer returned an invalid pairing response', 'pairing-failed');
|
|
233
|
+
}
|
|
234
|
+
const expiresAtMilliseconds = typeof value.expiresAt === 'string' ? Date.parse(value.expiresAt) : Number.NaN;
|
|
235
|
+
if (!Number.isFinite(expiresAtMilliseconds)
|
|
236
|
+
|| expiresAtMilliseconds <= currentTime
|
|
237
|
+
|| expiresAtMilliseconds > currentTime + MAX_PAIRING_LIFETIME_MS) {
|
|
238
|
+
throw new ClientError('Computer returned an invalid pairing expiration', 'pairing-failed');
|
|
239
|
+
}
|
|
240
|
+
return { ...value, expiresAtMilliseconds };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function parsePairingStatus(value) {
|
|
244
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || typeof value.status !== 'string') {
|
|
245
|
+
throw new ClientError('Computer returned an invalid pairing status', 'pairing-failed');
|
|
246
|
+
}
|
|
247
|
+
if (value.status === 'approved') {
|
|
248
|
+
requireObjectKeys(value, ['agentId', 'expiresAt', 'sshHostKey', 'sshUser', 'status'], 'pairing status');
|
|
249
|
+
requireStatusExpiration(value.expiresAt);
|
|
250
|
+
return value;
|
|
251
|
+
}
|
|
252
|
+
if (['pending', 'approving', 'rejected', 'expired'].includes(value.status)) {
|
|
253
|
+
requireObjectKeys(value, ['expiresAt', 'status'], 'pairing status');
|
|
254
|
+
requireStatusExpiration(value.expiresAt);
|
|
255
|
+
return value;
|
|
256
|
+
}
|
|
257
|
+
throw new ClientError('Computer returned an invalid pairing status', 'pairing-failed');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function requireStatusExpiration(value) {
|
|
261
|
+
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) {
|
|
262
|
+
throw new ClientError('Computer returned an invalid pairing status expiration', 'pairing-failed');
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function requireObjectKeys(value, keys, label) {
|
|
267
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
268
|
+
|| Object.keys(value).sort().join(',') !== [...keys].sort().join(',')) {
|
|
269
|
+
throw new ClientError(`Computer returned an invalid ${label} response`, 'pairing-failed');
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function inspectEd25519PublicKey(value, label) {
|
|
274
|
+
if (typeof value !== 'string') {
|
|
275
|
+
throw new ClientError(`The ${label} must be a comment-free OpenSSH Ed25519 key`, 'pairing-failed');
|
|
276
|
+
}
|
|
277
|
+
const match = /^ssh-ed25519 ([A-Za-z0-9+/]+={0,2})$/.exec(value);
|
|
278
|
+
if (!match) throw new ClientError(`The ${label} must be a comment-free OpenSSH Ed25519 key`, 'pairing-failed');
|
|
279
|
+
const blob = Buffer.from(match[1], 'base64');
|
|
280
|
+
if (blob.toString('base64') !== match[1]) {
|
|
281
|
+
throw new ClientError(`The ${label} has non-canonical base64`, 'pairing-failed');
|
|
282
|
+
}
|
|
283
|
+
const type = readSshString(blob, 0, label);
|
|
284
|
+
const key = readSshString(blob, type.end, label);
|
|
285
|
+
if (type.value.toString('ascii') !== 'ssh-ed25519' || key.value.length !== 32 || key.end !== blob.length) {
|
|
286
|
+
throw new ClientError(`The ${label} is not an exact Ed25519 SSH key`, 'pairing-failed');
|
|
287
|
+
}
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function sshFingerprint(publicKey) {
|
|
292
|
+
const encoded = /^ssh-ed25519 ([A-Za-z0-9+/]+={0,2})$/u.exec(publicKey)?.[1];
|
|
293
|
+
if (!encoded) throw new ClientError('Cannot fingerprint the generated SSH public key', 'pairing-failed');
|
|
294
|
+
return `SHA256:${createHash('sha256').update(Buffer.from(encoded, 'base64')).digest('base64').replace(/=+$/u, '')}`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function readSshString(blob, offset, label) {
|
|
298
|
+
if (offset + 4 > blob.length) throw new ClientError(`The ${label} has invalid SSH wire format`, 'pairing-failed');
|
|
299
|
+
const length = blob.readUInt32BE(offset);
|
|
300
|
+
const start = offset + 4;
|
|
301
|
+
const end = start + length;
|
|
302
|
+
if (end > blob.length) throw new ClientError(`The ${label} has invalid SSH wire format`, 'pairing-failed');
|
|
303
|
+
return { value: blob.subarray(start, end), end };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function readPublicKey(file, label) {
|
|
307
|
+
safeRegularFile(file, label);
|
|
308
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
309
|
+
const value = raw.trim();
|
|
310
|
+
if (value.includes('\n') || value.includes('\r')) {
|
|
311
|
+
throw new ClientError(`The ${label} must contain one comment-free key`, 'pairing-failed');
|
|
312
|
+
}
|
|
313
|
+
return inspectEd25519PublicKey(value, label);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function safeRegularFile(file, label) {
|
|
317
|
+
let stat;
|
|
318
|
+
try {
|
|
319
|
+
stat = fs.lstatSync(file);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
throw new ClientError(`Cannot inspect ${label}: ${error.message}`, 'pairing-failed');
|
|
322
|
+
}
|
|
323
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
324
|
+
throw new ClientError(`The ${label} must be a regular file, not a symbolic link`, 'pairing-failed');
|
|
325
|
+
}
|
|
326
|
+
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
327
|
+
throw new ClientError(`The ${label} must be owned by the current user`, 'pairing-failed');
|
|
328
|
+
}
|
|
329
|
+
return stat;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function installCredentials({ alias, env, ownerOrigin, temporaryIdentity, tunnelToken, ownerHost, sshHostKey }) {
|
|
333
|
+
rejectExistingConnection(alias, ownerOrigin, env);
|
|
334
|
+
const clientDirectory = path.dirname(configPath(env));
|
|
335
|
+
ensurePrivateDirectory(clientDirectory);
|
|
336
|
+
const computersDirectory = path.join(clientDirectory, 'computers');
|
|
337
|
+
ensurePrivateDirectory(computersDirectory);
|
|
338
|
+
const directory = path.join(computersDirectory, alias);
|
|
339
|
+
try {
|
|
340
|
+
fs.mkdirSync(directory, { mode: 0o700 });
|
|
341
|
+
fs.chmodSync(directory, 0o700);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if (error.code === 'EEXIST') {
|
|
344
|
+
throw new ClientError(`Pairing credentials already exist for computer "${alias}"; refusing to overwrite them`);
|
|
345
|
+
}
|
|
346
|
+
throw new ClientError(`Cannot create pairing credentials for computer "${alias}": ${error.message}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const sshIdentityPath = path.join(directory, 'id_ed25519');
|
|
350
|
+
const tunnelTokenPath = path.join(directory, 'tunnel-token');
|
|
351
|
+
const sshKnownHostsPath = path.join(directory, 'known_hosts');
|
|
352
|
+
try {
|
|
353
|
+
const privateKey = fs.readFileSync(temporaryIdentity);
|
|
354
|
+
if (privateKey.byteLength === 0) throw new ClientError('Generated SSH private key is empty', 'pairing-failed');
|
|
355
|
+
fs.writeFileSync(sshIdentityPath, privateKey, { flag: 'wx', mode: 0o600 });
|
|
356
|
+
fs.writeFileSync(tunnelTokenPath, `${tunnelToken}\n`, { flag: 'wx', mode: 0o600 });
|
|
357
|
+
fs.writeFileSync(sshKnownHostsPath, `${ownerHost} ${sshHostKey}\n`, { flag: 'wx', mode: 0o600 });
|
|
358
|
+
return { directory, sshIdentityPath, tunnelTokenPath, sshKnownHostsPath };
|
|
359
|
+
} catch (error) {
|
|
360
|
+
removeInstalledCredentials(directory);
|
|
361
|
+
if (error instanceof ClientError) throw error;
|
|
362
|
+
throw new ClientError(`Cannot install pairing credentials for computer "${alias}": ${error.message}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function ensurePrivateDirectory(directory) {
|
|
367
|
+
try {
|
|
368
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
369
|
+
const stat = fs.lstatSync(directory);
|
|
370
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
371
|
+
throw new Error('path is not a regular directory');
|
|
372
|
+
}
|
|
373
|
+
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
374
|
+
throw new Error('directory is not owned by the current user');
|
|
375
|
+
}
|
|
376
|
+
fs.chmodSync(directory, 0o700);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
throw new ClientError(`Cannot prepare pairing credential directory ${directory}: ${error.message}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function removeInstalledCredentials(directory) {
|
|
383
|
+
try {
|
|
384
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
385
|
+
} catch {
|
|
386
|
+
// A failed best-effort rollback leaves private material in its 0700 directory.
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function rejectExistingConnection(alias, ownerOrigin, env) {
|
|
391
|
+
rejectExistingConnectionInConfig(readConfig(env), alias, ownerOrigin);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function rejectExistingConnectionInConfig(config, alias, ownerOrigin) {
|
|
395
|
+
if (config.computers[alias]) {
|
|
396
|
+
throw new ClientError(`Computer "${alias}" is already configured; choose another with --as <alias>`);
|
|
397
|
+
}
|
|
398
|
+
const existing = Object.entries(config.computers).find(([, profile]) => (
|
|
399
|
+
profile.ownerOrigin && new URL(profile.ownerOrigin).origin === ownerOrigin
|
|
400
|
+
));
|
|
401
|
+
if (existing) {
|
|
402
|
+
throw new ClientError(`Computer at ${JSON.stringify(ownerOrigin)} is already connected as "${existing[0]}"`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function httpError(action, status) {
|
|
407
|
+
return new ClientError(`Cannot ${action}: computer returned HTTP ${status}`, 'pairing-failed');
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function requestJson(url, { method, headers = {}, body }, requestImpl = https.request) {
|
|
411
|
+
return new Promise((resolve, reject) => {
|
|
412
|
+
let settled = false;
|
|
413
|
+
const complete = (operation, value) => {
|
|
414
|
+
if (settled) return;
|
|
415
|
+
settled = true;
|
|
416
|
+
operation(value);
|
|
417
|
+
};
|
|
418
|
+
const payload = body === undefined ? null : Buffer.from(body, 'utf8');
|
|
419
|
+
const requestHeaders = {
|
|
420
|
+
...headers,
|
|
421
|
+
...(payload ? { 'content-length': String(payload.byteLength) } : {}),
|
|
422
|
+
};
|
|
423
|
+
const req = requestImpl(url, { method, headers: requestHeaders }, (res) => {
|
|
424
|
+
const chunks = [];
|
|
425
|
+
let length = 0;
|
|
426
|
+
res.once('error', (error) => complete(
|
|
427
|
+
reject,
|
|
428
|
+
new ClientError(`Pairing response failed: ${error.message}`, 'pairing-failed'),
|
|
429
|
+
));
|
|
430
|
+
res.on('data', (chunk) => {
|
|
431
|
+
if (settled) return;
|
|
432
|
+
length += chunk.length;
|
|
433
|
+
if (length > MAX_RESPONSE_BYTES) {
|
|
434
|
+
complete(reject, new ClientError('Pairing response is too large', 'pairing-failed'));
|
|
435
|
+
req.destroy();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
chunks.push(chunk);
|
|
439
|
+
});
|
|
440
|
+
res.on('end', () => {
|
|
441
|
+
if (settled) return;
|
|
442
|
+
let value = null;
|
|
443
|
+
const responseBody = Buffer.concat(chunks, length).toString('utf8');
|
|
444
|
+
if (responseBody.length > 0) {
|
|
445
|
+
try {
|
|
446
|
+
value = JSON.parse(responseBody);
|
|
447
|
+
} catch {
|
|
448
|
+
const status = res.statusCode ?? 0;
|
|
449
|
+
if (status === 200 || status === 202) {
|
|
450
|
+
complete(reject, new ClientError(`Computer returned invalid JSON for pairing (HTTP ${status})`, 'pairing-failed'));
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
complete(resolve, { status: res.statusCode ?? 0, value });
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
req.setTimeout(15_000, () => req.destroy(new Error('pairing request timed out')));
|
|
459
|
+
req.once('error', (error) => complete(
|
|
460
|
+
reject,
|
|
461
|
+
new ClientError(`Pairing request failed: ${error.message}`, 'pairing-failed'),
|
|
462
|
+
));
|
|
463
|
+
if (payload) req.write(payload);
|
|
464
|
+
req.end();
|
|
465
|
+
});
|
|
466
|
+
}
|