@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.
@@ -0,0 +1,304 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ import { parseReleaseArtifactBytes } from '../../provision/lib/platform-release-artifact.mjs';
8
+ import { PLATFORM_RELEASE_CATALOG_URL } from '../../provision/lib/platform-release-channel.mjs';
9
+ import { ClientError } from './errors.js';
10
+ import { runCaptured, runInherited } from './process.js';
11
+ import { sshCommand, sshTransport } from './rpc.js';
12
+
13
+ const CATALOG_COMPONENT = 'emptyos-platform-catalog';
14
+ const CATALOG_VERSION = 1;
15
+ const UPDATE_PROTOCOL_VERSION = 1;
16
+ const SHA256 = /^[0-9a-f]{64}$/;
17
+ const MAX_CATALOG_BYTES = 1024 * 1024;
18
+ const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024;
19
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
20
+
21
+ function validateHttpsUrl(value, label) {
22
+ let url;
23
+ try {
24
+ url = new URL(value);
25
+ } catch {
26
+ throw new ClientError(`The ${label} must be an HTTPS URL`);
27
+ }
28
+ if (url.protocol !== 'https:' || url.username || url.password || url.hash) {
29
+ throw new ClientError(`The ${label} must be an HTTPS URL without credentials or a fragment`);
30
+ }
31
+ return url.href;
32
+ }
33
+
34
+ export function validateCatalogUrl(value) {
35
+ return validateHttpsUrl(value, 'platform release catalog');
36
+ }
37
+
38
+ export function parseReleaseCatalog(value, catalogUrl) {
39
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
40
+ value.version !== CATALOG_VERSION || value.component !== CATALOG_COMPONENT ||
41
+ value.updateProtocol !== UPDATE_PROTOCOL_VERSION) {
42
+ throw new ClientError(`Invalid EmptyOS platform release catalog at ${catalogUrl}`);
43
+ }
44
+ if (!value.release || typeof value.release !== 'object' || Array.isArray(value.release)) {
45
+ throw new ClientError(`Platform release catalog at ${catalogUrl} has no release`);
46
+ }
47
+ let artifactUrl;
48
+ try {
49
+ artifactUrl = new URL(value.release.url, catalogUrl);
50
+ } catch {
51
+ throw new ClientError(`Platform release catalog at ${catalogUrl} has an invalid artifact URL`);
52
+ }
53
+ const resolvedArtifactUrl = validateHttpsUrl(
54
+ artifactUrl.href,
55
+ `platform release catalog at ${catalogUrl} artifact`,
56
+ );
57
+ const artifactSha256 = String(value.release.sha256 ?? '');
58
+ if (!SHA256.test(artifactSha256)) {
59
+ throw new ClientError(`Platform release catalog at ${catalogUrl} has an invalid artifact digest`);
60
+ }
61
+ return { catalogUrl, artifactUrl: resolvedArtifactUrl, artifactSha256 };
62
+ }
63
+
64
+ async function fetchHttps(start, { timeout, label }, fetchImpl) {
65
+ let current = validateHttpsUrl(start, label);
66
+ for (let redirects = 0; redirects <= 5; redirects += 1) {
67
+ let response;
68
+ try {
69
+ response = await fetchImpl(current, { redirect: 'manual', signal: AbortSignal.timeout(timeout) });
70
+ } catch (error) {
71
+ throw new ClientError(`Cannot fetch ${label} at ${current}: ${error.message}`);
72
+ }
73
+ if (!REDIRECT_STATUSES.has(response.status)) {
74
+ const finalUrl = response.url ? validateHttpsUrl(response.url, label) : current;
75
+ return { response, finalUrl };
76
+ }
77
+ const location = response.headers.get('location');
78
+ if (!location) throw new ClientError(`The ${label} at ${current} returned a redirect without a location`);
79
+ current = validateHttpsUrl(new URL(location, current).href, `${label} redirect`);
80
+ }
81
+ throw new ClientError(`The ${label} redirected too many times`);
82
+ }
83
+
84
+ async function readBoundedBody(response, { limit, label }) {
85
+ const declared = Number(response.headers.get('content-length'));
86
+ if (Number.isFinite(declared) && declared > limit) throw new ClientError(`The ${label} exceeds ${limit} bytes`);
87
+ if (!response.body) throw new ClientError(`The ${label} returned no body`);
88
+ const reader = response.body.getReader();
89
+ const chunks = [];
90
+ let length = 0;
91
+ try {
92
+ while (true) {
93
+ const { done, value } = await reader.read();
94
+ if (done) break;
95
+ length += value.byteLength;
96
+ if (length > limit) {
97
+ await reader.cancel();
98
+ throw new ClientError(`The ${label} exceeds ${limit} bytes`);
99
+ }
100
+ chunks.push(Buffer.from(value));
101
+ }
102
+ } catch (error) {
103
+ if (error instanceof ClientError) throw error;
104
+ throw new ClientError(`Cannot read ${label}: ${error.message}`);
105
+ }
106
+ return Buffer.concat(chunks, length);
107
+ }
108
+
109
+ export async function fetchReleaseCatalog(catalogUrl, fetchImpl = globalThis.fetch) {
110
+ const resolved = validateCatalogUrl(catalogUrl);
111
+ const { response, finalUrl } = await fetchHttps(
112
+ resolved,
113
+ { timeout: 15_000, label: 'platform release catalog' },
114
+ fetchImpl,
115
+ );
116
+ if (!response.ok) throw new ClientError(`Platform release catalog at ${finalUrl} returned HTTP ${response.status}`);
117
+ const bytes = await readBoundedBody(response, { limit: MAX_CATALOG_BYTES, label: 'platform release catalog' });
118
+ let value;
119
+ try {
120
+ value = JSON.parse(bytes.toString('utf8'));
121
+ } catch (error) {
122
+ throw new ClientError(`Invalid JSON in platform release catalog at ${finalUrl}: ${error.message}`);
123
+ }
124
+ return parseReleaseCatalog(value, finalUrl);
125
+ }
126
+
127
+ async function downloadArtifact(source, fetchImpl = globalThis.fetch) {
128
+ const { response, finalUrl } = await fetchHttps(
129
+ source.artifactUrl,
130
+ { timeout: 60_000, label: 'platform release' },
131
+ fetchImpl,
132
+ );
133
+ if (!response.ok) throw new ClientError(`Platform release ${finalUrl} returned HTTP ${response.status}`);
134
+ const bytes = await readBoundedBody(response, { limit: MAX_ARTIFACT_BYTES, label: 'platform release' });
135
+ const observed = crypto.createHash('sha256').update(bytes).digest('hex');
136
+ if (observed !== source.artifactSha256) {
137
+ throw new ClientError(`Platform release digest mismatch: expected ${source.artifactSha256}, received ${observed}`);
138
+ }
139
+ return { bytes, artifactUrl: finalUrl };
140
+ }
141
+
142
+ function readLocalArtifact(source) {
143
+ const resolved = path.resolve(source);
144
+ let descriptor;
145
+ try {
146
+ descriptor = fs.openSync(resolved, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
147
+ const stat = fs.fstatSync(descriptor);
148
+ if (!stat.isFile()) throw new ClientError(`Platform release must be a regular local file: ${resolved}`);
149
+ if (stat.size > MAX_ARTIFACT_BYTES) throw new ClientError(`Platform release exceeds ${MAX_ARTIFACT_BYTES} bytes`);
150
+ return { path: resolved, bytes: fs.readFileSync(descriptor) };
151
+ } catch (error) {
152
+ if (error.code === 'ENOENT') throw new ClientError(`Platform release does not exist: ${resolved}`);
153
+ if (error instanceof ClientError) throw error;
154
+ throw new ClientError(`Cannot read platform release ${resolved}: ${error.message}`);
155
+ } finally {
156
+ if (descriptor !== undefined) fs.closeSync(descriptor);
157
+ }
158
+ }
159
+
160
+ function updaterPath(env) {
161
+ if (env.NODE_ENV === 'test' && env.EMPTYOS_TEST_PLATFORM_UPDATER) return env.EMPTYOS_TEST_PLATFORM_UPDATER;
162
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'provision', 'update-platform.sh');
163
+ }
164
+
165
+ function readUpdaterResult(resultFile) {
166
+ if (!fs.existsSync(resultFile)) return null;
167
+ try {
168
+ return JSON.parse(fs.readFileSync(resultFile, 'utf8'));
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ function reconciliationNextCommand(selected, update) {
175
+ if (update?.status !== 'needs-reconciliation' ||
176
+ !/^stage\.[A-Za-z0-9]{1,64}$/.test(update.retainedStage ?? '')) return null;
177
+ return `empty --computer ${selected.alias} computer update --resume ${update.retainedStage}`;
178
+ }
179
+
180
+ export async function updateComputer(selected, options, { env, stdout, stderr, fetchImpl = globalThis.fetch }) {
181
+ const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-client-platform-update.'));
182
+ const artifactFile = path.join(stage, 'release.json');
183
+ const resultFile = path.join(stage, 'result.json');
184
+ try {
185
+ const transport = sshTransport(selected, env);
186
+ const transportArgs = ['--ssh-command', sshCommand(env)];
187
+ for (const option of transport.options) transportArgs.push('--ssh-option', option);
188
+ const command = updaterPath(env);
189
+
190
+ if (options.resume) {
191
+ const args = [
192
+ ...transportArgs,
193
+ '--resume', options.resume,
194
+ '--result-file', resultFile,
195
+ selected.target,
196
+ ];
197
+ if (options.json) {
198
+ const result = await runCaptured(command, args, { env });
199
+ const update = readUpdaterResult(resultFile);
200
+ const exitCode = result.code === 0 && update === null ? 1 : result.code;
201
+ stdout.write(`${JSON.stringify({
202
+ computer: selected.alias,
203
+ target: selected.target,
204
+ mode: 'resume',
205
+ retainedStage: options.resume,
206
+ ...(update?.release ? { release: update.release } : {}),
207
+ source: { type: 'retained-stage', stage: options.resume },
208
+ ok: exitCode === 0,
209
+ exitCode,
210
+ status: update?.status ?? 'failed',
211
+ plan: update,
212
+ output: result.stdout,
213
+ warnings: `${result.stderr}${result.code === 0 && update === null ? 'Platform updater returned no structured result\n' : ''}`,
214
+ })}\n`);
215
+ return exitCode;
216
+ }
217
+
218
+ stdout.write(`Resuming computer ${selected.alias} update from ${options.resume}\n`);
219
+ const exitCode = await runInherited(command, args, { env });
220
+ if (exitCode === 0 && readUpdaterResult(resultFile) === null) {
221
+ throw new ClientError('Platform updater returned no structured result');
222
+ }
223
+ return exitCode;
224
+ }
225
+
226
+ let source;
227
+ let artifactBytes;
228
+ if (options.release) {
229
+ const local = readLocalArtifact(options.release);
230
+ artifactBytes = local.bytes;
231
+ source = { type: 'local', path: local.path };
232
+ } else {
233
+ const catalogUrl = options.catalog ?? selected.releaseCatalog ?? PLATFORM_RELEASE_CATALOG_URL;
234
+ const catalog = await fetchReleaseCatalog(catalogUrl, fetchImpl);
235
+ const downloaded = await downloadArtifact(catalog, fetchImpl);
236
+ artifactBytes = downloaded.bytes;
237
+ source = { type: 'catalog', ...catalog, artifactUrl: downloaded.artifactUrl };
238
+ }
239
+
240
+ let release;
241
+ try {
242
+ release = parseReleaseArtifactBytes(
243
+ artifactBytes,
244
+ source.type === 'local' ? source.path : source.artifactUrl,
245
+ );
246
+ } catch (error) {
247
+ throw new ClientError(`Invalid platform release: ${error.message}`);
248
+ }
249
+ const artifactSha256 = release.artifactSha256;
250
+ fs.writeFileSync(artifactFile, artifactBytes, { mode: 0o600, flag: 'wx' });
251
+ fs.chmodSync(artifactFile, 0o400);
252
+ const args = [];
253
+ if (!options.check) args.push('--apply');
254
+ args.push(...transportArgs);
255
+ args.push('--release', artifactFile, '--result-file', resultFile, selected.target);
256
+
257
+ if (options.json) {
258
+ const result = await runCaptured(command, args, { env });
259
+ const update = readUpdaterResult(resultFile);
260
+ const exitCode = result.code === 0 && update === null ? 1 : result.code;
261
+ const nextCommand = reconciliationNextCommand(selected, update);
262
+ stdout.write(`${JSON.stringify({
263
+ computer: selected.alias,
264
+ target: selected.target,
265
+ mode: options.check ? 'check' : 'apply',
266
+ release: release.release,
267
+ artifactSha256,
268
+ source,
269
+ ok: exitCode === 0,
270
+ exitCode,
271
+ status: update?.status ?? 'failed',
272
+ plan: update,
273
+ ...(nextCommand ? { nextCommand } : {}),
274
+ output: result.stdout,
275
+ warnings: `${result.stderr}${result.code === 0 && update === null ? 'Platform updater returned no structured result\n' : ''}`,
276
+ })}\n`);
277
+ return exitCode;
278
+ }
279
+
280
+ stdout.write(`${options.check ? 'Checking' : 'Updating'} computer ${selected.alias} to ${release.release}\n`);
281
+ if (source.type === 'catalog') {
282
+ stdout.write(`Catalog: ${source.catalogUrl}\nArtifact: ${source.artifactUrl}\nDigest: ${artifactSha256}\n`);
283
+ } else {
284
+ stdout.write(`Release: ${source.path}\nDigest: ${artifactSha256}\n`);
285
+ }
286
+ const exitCode = await runInherited(command, args, { env });
287
+ const update = readUpdaterResult(resultFile);
288
+ if (exitCode === 0 && update === null) {
289
+ throw new ClientError('Platform updater returned no structured result');
290
+ }
291
+ const nextCommand = reconciliationNextCommand(selected, update);
292
+ if (nextCommand) {
293
+ stdout.write('\nThis update needs a file-level reconciliation; continue from an agent so it can inspect and merge the retained candidate, then run:\n');
294
+ stdout.write(` ${nextCommand}\n`);
295
+ }
296
+ return exitCode;
297
+ } finally {
298
+ try {
299
+ fs.rmSync(stage, { recursive: true, force: true });
300
+ } catch {
301
+ // Owner-only temporary release bytes are safe to leave for OS cleanup.
302
+ }
303
+ }
304
+ }
package/lib/process.js ADDED
@@ -0,0 +1,97 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+
4
+ export function runInherited(command, args, options = {}) {
5
+ return new Promise((resolve, reject) => {
6
+ const child = spawn(command, args, { ...options, stdio: 'inherit' });
7
+ child.once('error', reject);
8
+ child.once('close', (code, signal) => resolve(code ?? signalExitCode(signal)));
9
+ });
10
+ }
11
+
12
+ export function runCaptured(command, args, options = {}) {
13
+ return new Promise((resolve, reject) => {
14
+ const child = spawn(command, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] });
15
+ let stdout = '';
16
+ let stderr = '';
17
+ child.stdout.setEncoding('utf8');
18
+ child.stderr.setEncoding('utf8');
19
+ child.stdout.on('data', (chunk) => (stdout += chunk));
20
+ child.stderr.on('data', (chunk) => (stderr += chunk));
21
+ child.once('error', reject);
22
+ child.once('close', (code, signal) => resolve({ code: code ?? signalExitCode(signal), stdout, stderr }));
23
+ });
24
+ }
25
+
26
+ export function runFileCaptured(command, args, inputFile, options = {}) {
27
+ return new Promise((resolve, reject) => {
28
+ const child = spawn(command, args, { ...options, stdio: ['pipe', 'pipe', 'pipe'] });
29
+ const input = fs.createReadStream(inputFile);
30
+ let stdout = '';
31
+ let stderr = '';
32
+ let settled = false;
33
+
34
+ child.stdout.setEncoding('utf8');
35
+ child.stderr.setEncoding('utf8');
36
+ child.stdout.on('data', (chunk) => (stdout += chunk));
37
+ child.stderr.on('data', (chunk) => (stderr += chunk));
38
+
39
+ const fail = (error) => {
40
+ if (settled) return;
41
+ settled = true;
42
+ input.destroy();
43
+ child.kill();
44
+ reject(error);
45
+ };
46
+
47
+ child.once('error', fail);
48
+ input.once('error', fail);
49
+ child.stdin.once('error', (error) => {
50
+ // A remote validation failure may close stdin before the local bundle
51
+ // finishes streaming. Its exit status and structured output win.
52
+ if (error.code !== 'EPIPE') fail(error);
53
+ });
54
+ child.once('close', (code, signal) => {
55
+ if (settled) return;
56
+ settled = true;
57
+ input.destroy();
58
+ resolve({ code: code ?? signalExitCode(signal), stdout, stderr });
59
+ });
60
+ input.pipe(child.stdin);
61
+ });
62
+ }
63
+
64
+ export function runFileInherited(command, args, inputFile, options = {}) {
65
+ return new Promise((resolve, reject) => {
66
+ const child = spawn(command, args, { ...options, stdio: ['pipe', 'inherit', 'inherit'] });
67
+ const input = fs.createReadStream(inputFile);
68
+ let settled = false;
69
+
70
+ const fail = (error) => {
71
+ if (settled) return;
72
+ settled = true;
73
+ input.destroy();
74
+ child.kill();
75
+ reject(error);
76
+ };
77
+
78
+ child.once('error', fail);
79
+ input.once('error', fail);
80
+ child.stdin.once('error', (error) => {
81
+ // A remote validation failure may close stdin before the local bundle
82
+ // finishes streaming. Its exit status and stderr are authoritative.
83
+ if (error.code !== 'EPIPE') fail(error);
84
+ });
85
+ child.once('close', (code, signal) => {
86
+ if (settled) return;
87
+ settled = true;
88
+ input.destroy();
89
+ resolve(code ?? signalExitCode(signal));
90
+ });
91
+ input.pipe(child.stdin);
92
+ });
93
+ }
94
+
95
+ function signalExitCode(signal) {
96
+ return signal ? 1 : 0;
97
+ }
package/lib/rpc.js ADDED
@@ -0,0 +1,161 @@
1
+ import { PROTOCOL_VERSION } from './constants.js';
2
+ import { ClientError } from './errors.js';
3
+ import { runCaptured, runFileCaptured, runFileInherited, runInherited } from './process.js';
4
+
5
+ // OpenSSH exits 255 for its own failures (tunnel, host key, authentication);
6
+ // resident commands exit 0-254.
7
+ const SSH_TRANSPORT_EXIT = 255;
8
+
9
+ export function encodeRequest(argv) {
10
+ return Buffer.from(JSON.stringify({ protocolVersion: PROTOCOL_VERSION, argv }), 'utf8').toString('base64url');
11
+ }
12
+
13
+ export function sshRpcArgs(target, argv) {
14
+ const payload = encodeRequest(argv);
15
+ // The only dynamic shell token is base64url data produced above. User text
16
+ // exists exclusively inside the encoded JSON payload.
17
+ const remoteCommand = `$HOME/.local/bin/empty client-exec ${payload}`;
18
+ return ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-T', target, remoteCommand];
19
+ }
20
+
21
+ export function rpcInherited(profile, argv, env = process.env) {
22
+ const transport = sshTransport(profile, env);
23
+ return explainTransportExit(
24
+ withTransportError(runInherited(sshCommand(env), [...transport.options, ...sshRpcArgs(transport.target, argv)], { env }), transport.target),
25
+ profile,
26
+ transport.target,
27
+ );
28
+ }
29
+
30
+ export function rpcCaptured(profile, argv, env = process.env) {
31
+ const transport = sshTransport(profile, env);
32
+ return attachTransportHint(
33
+ withTransportError(runCaptured(sshCommand(env), [...transport.options, ...sshRpcArgs(transport.target, argv)], { env }), transport.target),
34
+ profile,
35
+ transport.target,
36
+ );
37
+ }
38
+
39
+ export function rpcFileInherited(profile, argv, inputFile, env = process.env) {
40
+ const transport = sshTransport(profile, env);
41
+ const payload = encodeRequest(argv);
42
+ // Metadata remains structured and shell-safe while the Git bundle streams
43
+ // over stdin without entering the bounded argv protocol payload.
44
+ const remoteCommand = `$HOME/.local/bin/empty client-import ${payload}`;
45
+ const args = [
46
+ '-o', 'BatchMode=yes',
47
+ '-o', 'ConnectTimeout=10',
48
+ '-o', 'ServerAliveInterval=15',
49
+ '-o', 'ServerAliveCountMax=3',
50
+ ...transport.options,
51
+ '-T', transport.target, remoteCommand,
52
+ ];
53
+ return explainTransportExit(
54
+ withTransportError(runFileInherited(sshCommand(env), args, inputFile, { env }), transport.target),
55
+ profile,
56
+ transport.target,
57
+ );
58
+ }
59
+
60
+ export function rpcFileCaptured(profile, argv, inputFile, env = process.env) {
61
+ const transport = sshTransport(profile, env);
62
+ const payload = encodeRequest(argv);
63
+ const remoteCommand = `$HOME/.local/bin/empty client-import ${payload}`;
64
+ const args = [
65
+ '-o', 'BatchMode=yes',
66
+ '-o', 'ConnectTimeout=10',
67
+ '-o', 'ServerAliveInterval=15',
68
+ '-o', 'ServerAliveCountMax=3',
69
+ ...transport.options,
70
+ '-T', transport.target, remoteCommand,
71
+ ];
72
+ return attachTransportHint(
73
+ withTransportError(runFileCaptured(sshCommand(env), args, inputFile, { env }), transport.target),
74
+ profile,
75
+ transport.target,
76
+ );
77
+ }
78
+
79
+ export function rawSsh(profile, argv, env = process.env) {
80
+ const transport = sshTransport(profile, env);
81
+ const args = argv.length === 0
82
+ ? [...transport.options, transport.target]
83
+ : [...transport.options, '-T', transport.target, ...argv];
84
+ return runInherited(sshCommand(env), args, { env });
85
+ }
86
+
87
+ export function sshTransport(profile, env = process.env) {
88
+ if (typeof profile === 'string') return { target: profile, options: [] };
89
+ if (!profile?.ownerOrigin) return { target: profile.target, options: [] };
90
+
91
+ const proxyCommand = [
92
+ process.execPath,
93
+ process.argv[1],
94
+ '__ssh-tunnel',
95
+ profile.ownerOrigin,
96
+ profile.tunnelTokenPath,
97
+ ].map(shellQuoteForProxyCommand).join(' ');
98
+ const hostKeyAlias = new URL(profile.ownerOrigin).hostname;
99
+ return {
100
+ target: profile.target,
101
+ options: [
102
+ '-F', '/dev/null',
103
+ '-i', profile.sshIdentityPath,
104
+ '-o', 'IdentitiesOnly=yes',
105
+ '-o', 'StrictHostKeyChecking=yes',
106
+ '-o', `UserKnownHostsFile=${profile.sshKnownHostsPath}`,
107
+ '-o', 'GlobalKnownHostsFile=/dev/null',
108
+ '-o', 'ControlMaster=no',
109
+ '-o', 'ControlPath=none',
110
+ '-o', `HostKeyAlias=${hostKeyAlias}`,
111
+ '-o', `ProxyCommand=${proxyCommand}`,
112
+ ],
113
+ };
114
+ }
115
+
116
+ function shellQuoteForProxyCommand(value) {
117
+ // OpenSSH expands percent tokens before invoking the ProxyCommand shell.
118
+ // Doubling them preserves literal percent characters inside paths and URLs.
119
+ const literal = String(value).replaceAll('%', '%%');
120
+ return `'${literal.replaceAll("'", "'\\''")}'`;
121
+ }
122
+
123
+ export function sshCommand(env) {
124
+ return env.EMPTYOS_SSH_COMMAND || 'ssh';
125
+ }
126
+
127
+ // ssh (and the tunnel helper it runs) has already written the failure detail
128
+ // to stderr; the hint naming the profile is the part only the client can add,
129
+ // set off by a blank line from that detail.
130
+ function transportHint(profile, target) {
131
+ const alias = typeof profile === 'string' ? null : profile.alias ?? null;
132
+ return alias
133
+ ? `\nempty: Could not connect to computer ${JSON.stringify(alias)}; list computers with \`empty computers\` or forget this one with \`empty computer remove ${alias}\`\n`
134
+ : `\nempty: Could not connect to computer ${JSON.stringify(target)}\n`;
135
+ }
136
+
137
+ async function explainTransportExit(operation, profile, target, stderr = process.stderr) {
138
+ const code = await operation;
139
+ if (code === SSH_TRANSPORT_EXIT) stderr.write(transportHint(profile, target));
140
+ return code;
141
+ }
142
+
143
+ // Captured results are relayed later; human-mode relays print the hint after
144
+ // the captured stderr, JSON relays keep it out of the structured error.
145
+ async function attachTransportHint(operation, profile, target) {
146
+ const result = await operation;
147
+ if (result.code === SSH_TRANSPORT_EXIT) result.transportHint = transportHint(profile, target);
148
+ return result;
149
+ }
150
+
151
+ async function withTransportError(operation, target) {
152
+ try {
153
+ return await operation;
154
+ } catch (error) {
155
+ if (error instanceof ClientError) throw error;
156
+ throw new ClientError(
157
+ `Cannot connect to EmptyOS computer ${JSON.stringify(target)}: ${error.message}; check SSH connectivity and retry`,
158
+ 'transport-failed',
159
+ );
160
+ }
161
+ }
@@ -0,0 +1,65 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { ClientError } from './errors.js';
7
+
8
+ export const SKILL_ID = 'emptyos-computer';
9
+ const BUNDLED_SKILL = fileURLToPath(new URL(`../skills/${SKILL_ID}/`, import.meta.url));
10
+
11
+ export function defaultSkillsDir(env = process.env) {
12
+ return path.join(env.HOME || os.homedir(), '.agents', 'skills');
13
+ }
14
+
15
+ // Copies the bundled skill directory to <dir>/emptyos-computer/. Existing
16
+ // files are replaced without asking: the skill is the client's own file and
17
+ // is versioned with it.
18
+ export function installSkill(dir, { platform = process.platform } = {}) {
19
+ if (platform === 'win32') throw new ClientError('`skill install` supports macOS and Linux only');
20
+ const target = path.join(path.resolve(dir), SKILL_ID);
21
+ for (const relative of bundledFiles()) {
22
+ const destination = path.join(target, relative);
23
+ try {
24
+ fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o755 });
25
+ } catch (error) {
26
+ throw new ClientError(`Cannot create ${path.dirname(destination)}: ${error.message}`);
27
+ }
28
+ replaceFile(destination, fs.readFileSync(path.join(BUNDLED_SKILL, relative)));
29
+ }
30
+ return target;
31
+ }
32
+
33
+ function bundledFiles(prefix = '') {
34
+ const entries = fs.readdirSync(path.join(BUNDLED_SKILL, prefix), { withFileTypes: true })
35
+ .sort((left, right) => left.name.localeCompare(right.name));
36
+ const files = [];
37
+ for (const entry of entries) {
38
+ const relative = path.join(prefix, entry.name);
39
+ if (entry.isDirectory()) files.push(...bundledFiles(relative));
40
+ else if (entry.isFile()) files.push(relative);
41
+ else throw new ClientError(`Bundled skill contains an unsupported entry: ${relative}`);
42
+ }
43
+ return files;
44
+ }
45
+
46
+ function replaceFile(file, contents) {
47
+ const temp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`);
48
+ let fd;
49
+ try {
50
+ fd = fs.openSync(temp, 'wx', 0o644);
51
+ fs.writeFileSync(fd, contents);
52
+ fs.fsyncSync(fd);
53
+ fs.closeSync(fd);
54
+ fd = undefined;
55
+ fs.renameSync(temp, file);
56
+ } catch (error) {
57
+ if (fd !== undefined) fs.closeSync(fd);
58
+ try {
59
+ fs.unlinkSync(temp);
60
+ } catch (unlinkError) {
61
+ if (unlinkError.code !== 'ENOENT') throw unlinkError;
62
+ }
63
+ throw new ClientError(`Cannot write ${file}: ${error.message}`);
64
+ }
65
+ }