@emptyos/client 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -262,3 +262,8 @@ npm test
262
262
 
263
263
  Tests use temporary real Git repositories and fake `ssh`/`git` executables;
264
264
  they do not require a network connection or an EmptyOS computer.
265
+
266
+ `lib/vendor/` holds byte-identical copies of the `provision/lib` modules the
267
+ client imports, because the published package must not reach outside its own
268
+ tree and those modules also ship to computers. `test/vendor.test.js` fails
269
+ when a copy drifts; refresh it with `cp provision/lib/<name> client/lib/vendor/<name>`.
package/lib/constants.js CHANGED
@@ -1,4 +1,4 @@
1
- export const CLIENT_VERSION = '0.1.0';
1
+ export const CLIENT_VERSION = '0.1.1';
2
2
  export const PROTOCOL_VERSION = 2;
3
3
 
4
4
  export const ALIAS_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -4,8 +4,10 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
- import { parseReleaseArtifactBytes } from '../../provision/lib/platform-release-artifact.mjs';
8
- import { PLATFORM_RELEASE_CATALOG_URL } from '../../provision/lib/platform-release-channel.mjs';
7
+ // Byte-identical copies of the provision modules (client/test/vendor.test.js):
8
+ // the published package must import nothing outside its own tree.
9
+ import { parseReleaseArtifactBytes } from './vendor/platform-release-artifact.mjs';
10
+ import { PLATFORM_RELEASE_CATALOG_URL } from './vendor/platform-release-channel.mjs';
9
11
  import { ClientError } from './errors.js';
10
12
  import { runCaptured, runInherited } from './process.js';
11
13
  import { sshCommand, sshTransport } from './rpc.js';
@@ -0,0 +1,413 @@
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 { spawnSync } from 'node:child_process';
6
+
7
+ import {
8
+ assertValidProtocolTarget,
9
+ assertValidTarget,
10
+ manifestAtRevision,
11
+ manifestDigest,
12
+ normalizeManifest,
13
+ readSourceScope,
14
+ serializeManifest,
15
+ validateRelativePath,
16
+ } from './platform-release.mjs';
17
+
18
+ export const RELEASE_ARTIFACT_COMPONENT = 'emptyos-platform-release';
19
+ export const RELEASE_ARTIFACT_VERSION = 1;
20
+ export const UPDATE_PROTOCOL_VERSION = 1;
21
+
22
+ const RELEASE_FILE = /^[A-Za-z0-9._/-]+$/;
23
+ const RELEASE_ID = /^sha256:[0-9a-f]{64}$/;
24
+ const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024;
25
+ const MAX_FILES = 10_000;
26
+ const MAX_BASELINES = 100_000;
27
+
28
+ function fail(message) {
29
+ throw new Error(message);
30
+ }
31
+
32
+ function run(command, args, { cwd, env = process.env, timeout = 120_000, quiet = false } = {}) {
33
+ const result = spawnSync(command, args, {
34
+ cwd,
35
+ env: { ...env, LC_ALL: 'C' },
36
+ encoding: 'utf8',
37
+ stdio: quiet ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'inherit', 'inherit'],
38
+ timeout,
39
+ });
40
+ if (result.error) throw result.error;
41
+ if (result.status !== 0) {
42
+ const detail = quiet ? (result.stderr || result.stdout || '').trim() : '';
43
+ fail(`\`${command} ${args.join(' ')}\` failed${detail ? `: ${detail}` : ''}`);
44
+ }
45
+ return quiet ? result.stdout.trimEnd() : '';
46
+ }
47
+
48
+ function git(repo, args) {
49
+ return run('git', ['-C', repo, ...args], { quiet: true });
50
+ }
51
+
52
+ function gitBlobId(contents, length) {
53
+ const algorithm = length === 64 ? 'sha256' : 'sha1';
54
+ return crypto.createHash(algorithm)
55
+ .update(`blob ${contents.length}\0`)
56
+ .update(contents)
57
+ .digest('hex');
58
+ }
59
+
60
+ function normalizeReleaseFile(file) {
61
+ if (!file || typeof file !== 'object' || Array.isArray(file)) fail('Invalid platform release file');
62
+ const relativePath = String(file.path ?? '');
63
+ const segments = relativePath.split('/');
64
+ if (!RELEASE_FILE.test(relativePath) || relativePath.startsWith('/') || relativePath.length > 4096 ||
65
+ segments.some((segment) => segment === '' || segment === '.' || segment === '..' ||
66
+ segment === '.git' || segment.length > 255)) {
67
+ fail(`Unsafe platform release path: ${JSON.stringify(relativePath)}`);
68
+ }
69
+ const mode = String(file.mode ?? '');
70
+ if (!['100644', '100755'].includes(mode)) fail(`Invalid platform release mode for ${relativePath}`);
71
+ const blob = String(file.blob ?? '');
72
+ if (!/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(blob)) fail(`Invalid platform release blob for ${relativePath}`);
73
+ const sha256 = String(file.sha256 ?? '');
74
+ if (!/^[0-9a-f]{64}$/.test(sha256)) fail(`Invalid platform release sha256 for ${relativePath}`);
75
+ const data = String(file.data ?? '');
76
+ const contents = Buffer.from(data, 'base64');
77
+ if (contents.toString('base64') !== data) fail(`Invalid base64 payload for ${relativePath}`);
78
+ if (gitBlobId(contents, blob.length) !== blob) fail(`Payload does not match manifest blob for ${relativePath}`);
79
+ if (crypto.createHash('sha256').update(contents).digest('hex') !== sha256) {
80
+ fail(`Payload does not match sha256 for ${relativePath}`);
81
+ }
82
+ return { path: relativePath, mode, blob, sha256, data };
83
+ }
84
+
85
+ export function normalizeReleaseArtifact(value) {
86
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail('Invalid platform release artifact');
87
+ if (value.version !== RELEASE_ARTIFACT_VERSION || value.component !== RELEASE_ARTIFACT_COMPONENT) {
88
+ fail('Unsupported platform release artifact');
89
+ }
90
+ if (value.updateProtocol !== UPDATE_PROTOCOL_VERSION) {
91
+ fail(`Unsupported platform update protocol ${JSON.stringify(value.updateProtocol)}`);
92
+ }
93
+
94
+ if (value.target?.version !== 1 || value.target?.component !== 'emptyos-platform' || !Array.isArray(value.target.files)) {
95
+ fail('Platform release has an invalid target manifest');
96
+ }
97
+ const target = normalizeManifest(value.target.files);
98
+ assertValidProtocolTarget(target);
99
+ const targetRelease = `sha256:${manifestDigest(target)}`;
100
+ if (value.release !== targetRelease) fail('Platform release id does not match its target manifest');
101
+
102
+ if (!Array.isArray(value.acceptedBaselines) || value.acceptedBaselines.length === 0) {
103
+ fail('Platform release has no accepted baselines');
104
+ }
105
+ if (value.acceptedBaselines.length > MAX_BASELINES) fail('Platform release has too many accepted baselines');
106
+ const acceptedBaselines = [...new Set(value.acceptedBaselines.map(String))].sort();
107
+ if (acceptedBaselines.some((release) => !RELEASE_ID.test(release))) {
108
+ fail('Platform release has an invalid accepted baseline');
109
+ }
110
+ if (!acceptedBaselines.includes(targetRelease)) fail('Platform release must accept its own target baseline');
111
+
112
+ if (!Array.isArray(value.files)) fail('Platform release files must be an array');
113
+ if (value.files.length > MAX_FILES) fail('Platform release has too many files');
114
+ const comparePath = (left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0;
115
+ const files = value.files.map(normalizeReleaseFile).sort(comparePath);
116
+ for (let index = 1; index < files.length; index += 1) {
117
+ if (files[index - 1].path === files[index].path) {
118
+ fail(`Duplicate platform release path: ${files[index].path}`);
119
+ }
120
+ }
121
+ const targetFiles = [...target.files].sort(comparePath);
122
+ if (files.length !== targetFiles.length) fail('Platform release payload does not match its target manifest');
123
+ for (let index = 0; index < files.length; index += 1) {
124
+ const payload = files[index];
125
+ const manifest = targetFiles[index];
126
+ if (payload.path !== manifest.path || payload.mode !== manifest.mode || payload.blob !== manifest.blob) {
127
+ fail('Platform release payload does not match its target manifest');
128
+ }
129
+ }
130
+
131
+ return {
132
+ version: RELEASE_ARTIFACT_VERSION,
133
+ component: RELEASE_ARTIFACT_COMPONENT,
134
+ updateProtocol: UPDATE_PROTOCOL_VERSION,
135
+ release: targetRelease,
136
+ acceptedBaselines,
137
+ target,
138
+ files,
139
+ };
140
+ }
141
+
142
+ export function serializeReleaseArtifact(value) {
143
+ return `${JSON.stringify(normalizeReleaseArtifact(value), null, 2)}\n`;
144
+ }
145
+
146
+ export function parseReleaseArtifact(file) {
147
+ let raw;
148
+ try {
149
+ const stat = fs.lstatSync(file);
150
+ if (!stat.isFile() || stat.isSymbolicLink()) fail(`Platform release artifact is not a regular file: ${file}`);
151
+ if (stat.size > MAX_ARTIFACT_BYTES) fail(`Platform release artifact exceeds ${MAX_ARTIFACT_BYTES} bytes`);
152
+ raw = fs.readFileSync(file);
153
+ } catch (error) {
154
+ fail(`Cannot read platform release artifact ${file}: ${error.message}`);
155
+ }
156
+ return parseReleaseArtifactBytes(raw, file);
157
+ }
158
+
159
+ export function parseReleaseArtifactBytes(raw, label = '<memory>') {
160
+ if (!Buffer.isBuffer(raw)) fail('Platform release artifact bytes must be a Buffer');
161
+ if (raw.length > MAX_ARTIFACT_BYTES) fail(`Platform release artifact exceeds ${MAX_ARTIFACT_BYTES} bytes`);
162
+ let value;
163
+ try {
164
+ value = JSON.parse(raw.toString('utf8'));
165
+ } catch (error) {
166
+ fail(`Cannot parse platform release artifact ${label}: ${error.message}`);
167
+ }
168
+ const release = normalizeReleaseArtifact(value);
169
+ const canonical = Buffer.from(serializeReleaseArtifact(release), 'utf8');
170
+ if (!raw.equals(canonical)) fail(`Platform release artifact is not canonical: ${label}`);
171
+ return {
172
+ ...release,
173
+ artifactSha256: crypto.createHash('sha256').update(raw).digest('hex'),
174
+ };
175
+ }
176
+
177
+ export function releaseArtifactDigest(value) {
178
+ return crypto.createHash('sha256').update(serializeReleaseArtifact(value)).digest('hex');
179
+ }
180
+
181
+ export function extractReleaseArtifact(file, destination) {
182
+ const release = parseReleaseArtifact(file);
183
+ fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
184
+ fs.mkdirSync(destination, { mode: 0o700 });
185
+ try {
186
+ fs.chmodSync(destination, 0o700);
187
+ const payloadRoot = path.join(destination, 'payload', 'seed');
188
+ fs.mkdirSync(payloadRoot, { recursive: true, mode: 0o700 });
189
+ fs.chmodSync(path.join(destination, 'payload'), 0o700);
190
+ fs.chmodSync(payloadRoot, 0o700);
191
+ for (const entry of release.files) {
192
+ const output = path.join(payloadRoot, ...entry.path.split('/'));
193
+ const relative = path.relative(payloadRoot, output);
194
+ if (relative.startsWith('..') || path.isAbsolute(relative)) fail(`Unsafe extracted platform path: ${entry.path}`);
195
+ fs.mkdirSync(path.dirname(output), { recursive: true, mode: 0o700 });
196
+ for (let current = path.dirname(output); current.startsWith(payloadRoot); current = path.dirname(current)) {
197
+ fs.chmodSync(current, 0o700);
198
+ if (current === payloadRoot) break;
199
+ }
200
+ fs.writeFileSync(output, Buffer.from(entry.data, 'base64'), { flag: 'wx' });
201
+ fs.chmodSync(output, entry.mode === '100755' ? 0o755 : 0o644);
202
+ }
203
+ const targetFile = path.join(destination, 'target.json');
204
+ const releaseFile = path.join(destination, 'release.json');
205
+ fs.writeFileSync(targetFile, serializeManifest(release.target), { flag: 'wx' });
206
+ fs.chmodSync(targetFile, 0o600);
207
+ fs.writeFileSync(releaseFile, `${JSON.stringify({
208
+ version: RELEASE_ARTIFACT_VERSION,
209
+ component: RELEASE_ARTIFACT_COMPONENT,
210
+ updateProtocol: UPDATE_PROTOCOL_VERSION,
211
+ release: release.release,
212
+ artifactSha256: release.artifactSha256,
213
+ acceptedBaselines: release.acceptedBaselines,
214
+ }, null, 2)}\n`, { flag: 'wx' });
215
+ fs.chmodSync(releaseFile, 0o600);
216
+ } catch (error) {
217
+ fs.rmSync(destination, { recursive: true, force: true });
218
+ throw error;
219
+ }
220
+ return release;
221
+ }
222
+
223
+ function packageRecords(manifest) {
224
+ const byPath = new Map(manifest.files.map((entry) => [entry.path, entry]));
225
+ return ['package.json', 'package-lock.json'].map((name) => byPath.get(name)?.blob ?? null).join(':');
226
+ }
227
+
228
+ function repositoryScopePath(repo, scopeFile) {
229
+ const relative = path.relative(repo, fs.realpathSync(scopeFile));
230
+ if (!relative || path.isAbsolute(relative) || relative === '..' || relative.startsWith(`..${path.sep}`)) {
231
+ return null;
232
+ }
233
+ return relative.split(path.sep).join('/');
234
+ }
235
+
236
+ function parseCommittedSourceScope(contents, label) {
237
+ const entries = contents.split(/\r?\n/).filter(Boolean);
238
+ if (entries.length === 0) fail(`Platform source scope is empty at ${label}`);
239
+ const seen = new Set();
240
+ for (const entry of entries) {
241
+ if (!entry.startsWith('seed/')) fail(`Platform source scope escaped seed/ at ${label}`);
242
+ const remotePath = entry.slice('seed/'.length);
243
+ validateRelativePath(entry.endsWith('/') ? remotePath.slice(0, -1) : remotePath);
244
+ if (seen.has(entry)) fail(`Platform source scope contains a duplicate entry at ${label}: ${entry}`);
245
+ seen.add(entry);
246
+ }
247
+ return entries;
248
+ }
249
+
250
+ function committedSourceScope(repo, revision, scopePath) {
251
+ const treeEntry = git(repo, ['ls-tree', revision, '--', scopePath]);
252
+ if (!treeEntry) return null;
253
+ const match = /^(100644|100755) blob [0-9a-f]{40,64}\t(.+)$/u.exec(treeEntry);
254
+ if (!match || match[2] !== scopePath) {
255
+ fail(`Platform source scope is not a regular Git file at ${revision}`);
256
+ }
257
+ return parseCommittedSourceScope(git(repo, ['show', `${revision}:${scopePath}`]), revision);
258
+ }
259
+
260
+ function scopeEntryCoveredByTarget(entry, targetScope) {
261
+ return targetScope.some((targetEntry) =>
262
+ targetEntry.endsWith('/') ? entry.startsWith(targetEntry) : entry === targetEntry,
263
+ );
264
+ }
265
+
266
+ function scopeIsAdditiveCompatible(ancestorScope, targetScope) {
267
+ return ancestorScope.every((entry) => scopeEntryCoveredByTarget(entry, targetScope));
268
+ }
269
+
270
+ export function buildReleaseArtifact({ repo, revision, scopeFile, output, validate = true }) {
271
+ const resolvedRepo = fs.realpathSync(repo);
272
+ const commit = git(resolvedRepo, ['rev-parse', '--verify', `${revision}^{commit}`]);
273
+ const supportedScope = readSourceScope(scopeFile);
274
+ const scopePath = repositoryScopePath(resolvedRepo, scopeFile);
275
+ const sourceScope = scopePath ? committedSourceScope(resolvedRepo, commit, scopePath) : supportedScope;
276
+ if (sourceScope == null) fail(`Platform source scope is missing at target revision ${commit}`);
277
+ if (JSON.stringify(sourceScope) !== JSON.stringify(supportedScope)) {
278
+ fail(`Platform source scope at target revision ${commit} differs from the supported atomic release unit`);
279
+ }
280
+ const target = manifestAtRevision(resolvedRepo, commit, sourceScope);
281
+ assertValidTarget(target);
282
+ const targetPackages = packageRecords(target);
283
+
284
+ const acceptedBaselines = new Set();
285
+ for (const ancestor of git(resolvedRepo, ['rev-list', commit]).split('\n').filter(Boolean)) {
286
+ const ancestorScope = scopePath ? committedSourceScope(resolvedRepo, ancestor, scopePath) : sourceScope;
287
+ if (ancestorScope == null || !scopeIsAdditiveCompatible(ancestorScope, sourceScope)) continue;
288
+ const manifest = manifestAtRevision(resolvedRepo, ancestor, ancestorScope);
289
+ if (packageRecords(manifest) === targetPackages) {
290
+ acceptedBaselines.add(`sha256:${manifestDigest(manifest)}`);
291
+ }
292
+ }
293
+
294
+ const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'emptyos-release-build.'));
295
+ try {
296
+ const archive = path.join(stage, 'source.tar');
297
+ const exported = path.join(stage, 'source');
298
+ fs.mkdirSync(exported);
299
+ const sourceFiles = target.files.map((entry) => `seed/${entry.path}`);
300
+ run('git', ['-C', resolvedRepo, 'archive', '--format=tar', '--output', archive, commit, '--', ...sourceFiles], {
301
+ quiet: true,
302
+ });
303
+ run('tar', ['-xf', archive, '-C', exported], { quiet: true });
304
+
305
+ const seed = path.join(exported, 'seed');
306
+ if (validate) {
307
+ run('npm', ['ci'], { cwd: seed, timeout: 180_000, quiet: true });
308
+ run('npm', ['test'], { cwd: seed, timeout: 180_000, quiet: true });
309
+ }
310
+
311
+ const files = target.files.map((entry) => {
312
+ const contents = fs.readFileSync(path.join(seed, ...entry.path.split('/')));
313
+ return {
314
+ ...entry,
315
+ sha256: crypto.createHash('sha256').update(contents).digest('hex'),
316
+ data: contents.toString('base64'),
317
+ };
318
+ });
319
+ const artifact = normalizeReleaseArtifact({
320
+ version: RELEASE_ARTIFACT_VERSION,
321
+ component: RELEASE_ARTIFACT_COMPONENT,
322
+ updateProtocol: UPDATE_PROTOCOL_VERSION,
323
+ release: `sha256:${manifestDigest(target)}`,
324
+ acceptedBaselines: [...acceptedBaselines],
325
+ target,
326
+ files,
327
+ });
328
+ fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
329
+ fs.writeFileSync(output, serializeReleaseArtifact(artifact), { mode: 0o644 });
330
+ return { artifact, commit, artifactSha256: releaseArtifactDigest(artifact) };
331
+ } finally {
332
+ fs.rmSync(stage, { recursive: true, force: true });
333
+ }
334
+ }
335
+
336
+ export function buildReleasePackage({ repo, revision, scopeFile, outputDirectory, validate = true }) {
337
+ const destination = path.resolve(outputDirectory);
338
+ if (fs.existsSync(destination)) fail(`Platform release package destination already exists: ${destination}`);
339
+ const parent = path.dirname(destination);
340
+ fs.mkdirSync(parent, { recursive: true });
341
+ const stage = fs.mkdtempSync(path.join(parent, `.${path.basename(destination)}.`));
342
+ try {
343
+ const temporaryArtifact = path.join(stage, 'release.json');
344
+ const result = buildReleaseArtifact({ repo, revision, scopeFile, output: temporaryArtifact, validate });
345
+ const releases = path.join(stage, 'releases');
346
+ fs.mkdirSync(releases, { mode: 0o755 });
347
+ const artifactName = `${result.artifactSha256}.json`;
348
+ const artifactFile = path.join(releases, artifactName);
349
+ fs.renameSync(temporaryArtifact, artifactFile);
350
+ const catalog = path.join(stage, 'catalog.json');
351
+ fs.writeFileSync(catalog, `${JSON.stringify({
352
+ version: 1,
353
+ component: 'emptyos-platform-catalog',
354
+ updateProtocol: UPDATE_PROTOCOL_VERSION,
355
+ release: {
356
+ id: result.artifact.release,
357
+ url: `./releases/${artifactName}`,
358
+ sha256: result.artifactSha256,
359
+ },
360
+ }, null, 2)}\n`, { mode: 0o644 });
361
+ fs.renameSync(stage, destination);
362
+ return {
363
+ ...result,
364
+ outputDirectory: destination,
365
+ artifactFile: path.join(destination, 'releases', artifactName),
366
+ catalog: path.join(destination, 'catalog.json'),
367
+ artifactRelativePath: `releases/${artifactName}`,
368
+ };
369
+ } catch (error) {
370
+ fs.rmSync(stage, { recursive: true, force: true });
371
+ throw error;
372
+ }
373
+ }
374
+
375
+ export function packageReleaseArtifact({ artifactFile, outputDirectory }) {
376
+ const source = path.resolve(artifactFile);
377
+ const artifact = parseReleaseArtifact(source);
378
+ const artifactBytes = fs.readFileSync(source);
379
+ const destination = path.resolve(outputDirectory);
380
+ if (fs.existsSync(destination)) fail(`Platform release package destination already exists: ${destination}`);
381
+ const parent = path.dirname(destination);
382
+ fs.mkdirSync(parent, { recursive: true });
383
+ const stage = fs.mkdtempSync(path.join(parent, `.${path.basename(destination)}.`));
384
+ try {
385
+ const releases = path.join(stage, 'releases');
386
+ fs.mkdirSync(releases, { mode: 0o755 });
387
+ const artifactName = `${artifact.artifactSha256}.json`;
388
+ const stagedArtifact = path.join(releases, artifactName);
389
+ fs.writeFileSync(stagedArtifact, artifactBytes, { flag: 'wx', mode: 0o644 });
390
+ const catalog = path.join(stage, 'catalog.json');
391
+ fs.writeFileSync(catalog, `${JSON.stringify({
392
+ version: 1,
393
+ component: 'emptyos-platform-catalog',
394
+ updateProtocol: UPDATE_PROTOCOL_VERSION,
395
+ release: {
396
+ url: `./releases/${artifactName}`,
397
+ sha256: artifact.artifactSha256,
398
+ },
399
+ }, null, 2)}\n`, { flag: 'wx', mode: 0o644 });
400
+ fs.renameSync(stage, destination);
401
+ return {
402
+ artifact,
403
+ artifactSha256: artifact.artifactSha256,
404
+ outputDirectory: destination,
405
+ artifactFile: path.join(destination, 'releases', artifactName),
406
+ catalog: path.join(destination, 'catalog.json'),
407
+ artifactRelativePath: `releases/${artifactName}`,
408
+ };
409
+ } catch (error) {
410
+ fs.rmSync(stage, { recursive: true, force: true });
411
+ throw error;
412
+ }
413
+ }
@@ -0,0 +1,91 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import { parseReleaseArtifactBytes } from './platform-release-artifact.mjs';
6
+
7
+ export const PLATFORM_RELEASE_PUBLIC_ORIGIN = 'https://release.emptyos.com/platform/v1/';
8
+ export const PLATFORM_RELEASE_CATALOG_URL = new URL('catalog.json', PLATFORM_RELEASE_PUBLIC_ORIGIN).href;
9
+
10
+ const ARTIFACT_PATH = /^\.\/releases\/([0-9a-f]{64})\.json$/;
11
+ const RELEASE_ID = /^sha256:[0-9a-f]{64}$/;
12
+
13
+ export function parseReleaseCatalogBytes(catalogBytes, { requireId = false } = {}) {
14
+ let catalog;
15
+ try {
16
+ catalog = JSON.parse(catalogBytes.toString('utf8'));
17
+ } catch (error) {
18
+ throw new Error(`Invalid platform release catalog: ${error.message}`);
19
+ }
20
+ if (!catalog || typeof catalog !== 'object' || Array.isArray(catalog) ||
21
+ catalog.version !== 1 || catalog.component !== 'emptyos-platform-catalog' ||
22
+ catalog.updateProtocol !== 1 || !catalog.release ||
23
+ typeof catalog.release !== 'object' || Array.isArray(catalog.release)) {
24
+ throw new Error('Invalid platform release catalog');
25
+ }
26
+ const artifactSha256 = String(catalog.release.sha256 ?? '');
27
+ const releaseKeys = Object.keys(catalog.release);
28
+ const legacyCatalog = releaseKeys.length === 2 && releaseKeys[0] === 'url' && releaseKeys[1] === 'sha256';
29
+ const identifiedCatalog = releaseKeys.length === 3 && releaseKeys[0] === 'id' &&
30
+ releaseKeys[1] === 'url' && releaseKeys[2] === 'sha256';
31
+ if (!legacyCatalog && !identifiedCatalog) throw new Error('Invalid platform release catalog release shape');
32
+ if (requireId && !identifiedCatalog) {
33
+ throw new Error('Platform release catalog does not identify its resident release');
34
+ }
35
+ if (identifiedCatalog && (typeof catalog.release.id !== 'string' || !RELEASE_ID.test(catalog.release.id))) {
36
+ throw new Error('Platform release catalog has an invalid release id');
37
+ }
38
+ const match = ARTIFACT_PATH.exec(String(catalog.release.url ?? ''));
39
+ if (!match || match[1] !== artifactSha256) {
40
+ throw new Error('Platform release catalog must reference its content-addressed artifact');
41
+ }
42
+ const expectedCatalog = `${JSON.stringify({
43
+ version: 1,
44
+ component: 'emptyos-platform-catalog',
45
+ updateProtocol: 1,
46
+ release: {
47
+ ...(identifiedCatalog ? { id: catalog.release.id } : {}),
48
+ url: catalog.release.url,
49
+ sha256: artifactSha256,
50
+ },
51
+ }, null, 2)}\n`;
52
+ if (!catalogBytes.equals(Buffer.from(expectedCatalog))) throw new Error('Platform release catalog is not canonical');
53
+ return catalog;
54
+ }
55
+
56
+ export function readReleasePackage(directory) {
57
+ const root = fs.realpathSync(directory);
58
+ const catalogFile = path.join(root, 'catalog.json');
59
+ const catalogBytes = readRegularFile(catalogFile, 1024 * 1024);
60
+ const catalog = parseReleaseCatalogBytes(catalogBytes);
61
+ const artifactSha256 = catalog.release.sha256;
62
+ const identifiedCatalog = Object.hasOwn(catalog.release, 'id');
63
+
64
+ const artifactRelativePath = `releases/${artifactSha256}.json`;
65
+ const artifactFile = path.join(root, artifactRelativePath);
66
+ const artifactBytes = readRegularFile(artifactFile, 64 * 1024 * 1024);
67
+ const observedSha256 = crypto.createHash('sha256').update(artifactBytes).digest('hex');
68
+ if (observedSha256 !== artifactSha256) throw new Error('Platform release artifact digest does not match its catalog');
69
+ const artifact = parseReleaseArtifactBytes(artifactBytes, artifactFile);
70
+ if (identifiedCatalog && catalog.release.id !== artifact.release) {
71
+ throw new Error('Platform release catalog id does not match its artifact release');
72
+ }
73
+ return {
74
+ root,
75
+ catalog,
76
+ catalogFile,
77
+ catalogBytes,
78
+ artifact,
79
+ artifactFile,
80
+ artifactBytes,
81
+ artifactSha256,
82
+ artifactRelativePath,
83
+ };
84
+ }
85
+
86
+ function readRegularFile(file, maximumBytes) {
87
+ const stat = fs.lstatSync(file);
88
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Release package entry is not a regular file: ${file}`);
89
+ if (stat.size > maximumBytes) throw new Error(`Release package entry exceeds ${maximumBytes} bytes: ${file}`);
90
+ return fs.readFileSync(file);
91
+ }
@@ -0,0 +1,368 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ export const PLATFORM_COMPONENT = 'emptyos-platform';
7
+ export const BASELINE_FILE = 'platform-release.json';
8
+
9
+ export const PLATFORM_SCOPE = Object.freeze([
10
+ '.agents/skills/build-emptyos-things/',
11
+ '.agents/skills/operate-emptyos-computer/',
12
+ '.gitignore',
13
+ 'EMPTY.md',
14
+ 'README.md',
15
+ 'cli/',
16
+ 'gateway/',
17
+ 'lib/',
18
+ 'package-lock.json',
19
+ 'package.json',
20
+ 'test/build-emptyos-things-skill.test.js',
21
+ 'test/cli.test.js',
22
+ 'test/extensions.test.js',
23
+ 'test/gateway.test.js',
24
+ 'test/git-changes.test.js',
25
+ 'test/helpers.js',
26
+ 'test/lib.test.js',
27
+ 'test/skill.test.js',
28
+ 'test/thing-import.test.js',
29
+ 'web/',
30
+ ]);
31
+
32
+ // Protocol-v1 artifacts remain valid against the semantic minimum that existed
33
+ // when the protocol was introduced. Growing this list would invalidate already
34
+ // published immutable artifacts; removing or renaming one of these paths needs
35
+ // a new update protocol.
36
+ const PROTOCOL_V1_REQUIRED_TARGET_PATHS = Object.freeze([
37
+ '.agents/skills/operate-emptyos-computer/SKILL.md',
38
+ 'EMPTY.md',
39
+ 'cli/empty.js',
40
+ 'gateway/server.js',
41
+ 'lib/manifests.js',
42
+ 'package-lock.json',
43
+ 'package.json',
44
+ 'test/skill.test.js',
45
+ 'web/index.html',
46
+ ]);
47
+ const REQUIRED_TARGET_PATHS = Object.freeze([
48
+ ...PROTOCOL_V1_REQUIRED_TARGET_PATHS,
49
+ '.agents/skills/build-emptyos-things/SKILL.md',
50
+ 'test/build-emptyos-things-skill.test.js',
51
+ ]);
52
+ const PACKAGE_PATHS = new Set(['package.json', 'package-lock.json']);
53
+ const SAFE_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._/-]+$/;
54
+ const ALLOWED_MODES = new Set(['100644', '100755']);
55
+
56
+ function fail(message) {
57
+ throw new Error(message);
58
+ }
59
+
60
+ function run(command, args, { cwd, timeout = 30_000 } = {}) {
61
+ const result = spawnSync(command, args, {
62
+ cwd,
63
+ encoding: 'utf8',
64
+ env: { ...process.env, LC_ALL: 'C' },
65
+ timeout,
66
+ });
67
+ if (result.error) throw result.error;
68
+ if (result.status !== 0) {
69
+ const detail = (result.stderr || result.stdout || '').trim();
70
+ fail(`\`${command} ${args.join(' ')}\` failed${detail ? `: ${detail}` : ''}`);
71
+ }
72
+ return result.stdout.trimEnd();
73
+ }
74
+
75
+ export function tryRun(command, args, { cwd, timeout = 30_000 } = {}) {
76
+ const result = spawnSync(command, args, {
77
+ cwd,
78
+ encoding: 'utf8',
79
+ env: { ...process.env, LC_ALL: 'C' },
80
+ timeout,
81
+ });
82
+ if (result.error) throw result.error;
83
+ return result;
84
+ }
85
+
86
+ export function git(repo, args, options = {}) {
87
+ return run('git', ['-C', repo, ...args], options);
88
+ }
89
+
90
+ export function validateRelativePath(relativePath) {
91
+ const segments = String(relativePath).split('/');
92
+ if (!SAFE_PATH.test(relativePath) || relativePath.length > 4096 ||
93
+ segments.some((segment) => segment === '' || segment === '.' || segment === '..' ||
94
+ segment === '.git' || segment.length > 255)) {
95
+ fail(`Unsafe platform path: ${JSON.stringify(relativePath)}`);
96
+ }
97
+ return relativePath;
98
+ }
99
+
100
+ export function pathIsCovered(relativePath, scope = PLATFORM_SCOPE) {
101
+ return scope.some((entry) =>
102
+ entry.endsWith('/') ? relativePath.startsWith(entry) : relativePath === entry,
103
+ );
104
+ }
105
+
106
+ export function readSourceScope(scopeFile) {
107
+ const entries = fs.readFileSync(scopeFile, 'utf8').split(/\r?\n/).filter(Boolean);
108
+ const expected = PLATFORM_SCOPE.map((entry) => `seed/${entry}`);
109
+ if (JSON.stringify(entries) !== JSON.stringify(expected)) {
110
+ fail('Platform source scope differs from the supported atomic release unit');
111
+ }
112
+ return entries;
113
+ }
114
+
115
+ export function readRemoteScope(scopeFile) {
116
+ const entries = fs.readFileSync(scopeFile, 'utf8').split(/\r?\n/).filter(Boolean);
117
+ if (JSON.stringify(entries) !== JSON.stringify(PLATFORM_SCOPE)) {
118
+ fail('Remote platform scope differs from the supported atomic release unit');
119
+ }
120
+ return entries;
121
+ }
122
+
123
+ function normalizeRecord(record) {
124
+ const normalized = {
125
+ path: validateRelativePath(record.path),
126
+ mode: String(record.mode),
127
+ blob: String(record.blob),
128
+ };
129
+ if (!ALLOWED_MODES.has(normalized.mode)) {
130
+ fail(`Unsupported mode ${normalized.mode} for ${normalized.path}`);
131
+ }
132
+ if (!/^[0-9a-f]{40,64}$/.test(normalized.blob)) {
133
+ fail(`Invalid blob id for ${normalized.path}`);
134
+ }
135
+ if (!pathIsCovered(normalized.path)) {
136
+ fail(`Platform file escaped the declared scope: ${normalized.path}`);
137
+ }
138
+ return normalized;
139
+ }
140
+
141
+ export function normalizeManifest(files) {
142
+ const sorted = files.map(normalizeRecord).sort((left, right) =>
143
+ left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
144
+ );
145
+ const seen = new Set();
146
+ for (let index = 0; index < sorted.length; index += 1) {
147
+ const file = sorted[index];
148
+ if (seen.has(file.path)) fail(`Duplicate platform path: ${file.path}`);
149
+ const segments = file.path.split('/');
150
+ for (let length = 1; length < segments.length; length += 1) {
151
+ const parent = segments.slice(0, length).join('/');
152
+ if (seen.has(parent)) fail(`Conflicting platform paths: ${parent} and ${file.path}`);
153
+ }
154
+ seen.add(file.path);
155
+ }
156
+ return { version: 1, component: PLATFORM_COMPONENT, files: sorted };
157
+ }
158
+
159
+ export function serializeManifest(manifest) {
160
+ return `${JSON.stringify(normalizeManifest(manifest.files), null, 2)}\n`;
161
+ }
162
+
163
+ export function parseManifest(file) {
164
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
165
+ if (parsed?.version !== 1 || parsed?.component !== PLATFORM_COMPONENT || !Array.isArray(parsed.files)) {
166
+ fail(`Invalid platform manifest: ${file}`);
167
+ }
168
+ return normalizeManifest(parsed.files);
169
+ }
170
+
171
+ export function manifestDigest(manifest) {
172
+ return crypto.createHash('sha256').update(serializeManifest(manifest)).digest('hex');
173
+ }
174
+
175
+ export function releaseId(manifest) {
176
+ return `sha256:${manifestDigest(manifest)}`;
177
+ }
178
+
179
+ function parseTreeLine(line, prefix = '') {
180
+ const match = /^(100644|100755) blob ([0-9a-f]{40,64})\t(.+)$/.exec(line);
181
+ if (!match) fail(`Unsupported Git tree entry: ${line}`);
182
+ const relativePath = prefix && match[3].startsWith(prefix)
183
+ ? match[3].slice(prefix.length)
184
+ : match[3];
185
+ return { path: relativePath, mode: match[1], blob: match[2] };
186
+ }
187
+
188
+ export function manifestAtRevision(repo, revision, sourceScope) {
189
+ const output = git(repo, ['ls-tree', '-r', revision, '--', ...sourceScope]);
190
+ const files = output ? output.split('\n').map((line) => parseTreeLine(line, 'seed/')) : [];
191
+ return normalizeManifest(files);
192
+ }
193
+
194
+ function parseIndexLine(line) {
195
+ const match = /^(100644|100755) ([0-9a-f]{40,64}) 0\t(.+)$/.exec(line);
196
+ if (!match) fail(`Unsupported Git index entry: ${line}`);
197
+ return { path: match[3], mode: match[1], blob: match[2] };
198
+ }
199
+
200
+ export function manifestFromIndex(repo, scope = PLATFORM_SCOPE) {
201
+ const output = git(repo, ['ls-files', '--stage', '--', ...scope]);
202
+ const files = output ? output.split('\n').map(parseIndexLine) : [];
203
+ return normalizeManifest(files);
204
+ }
205
+
206
+ function fileMode(stat) {
207
+ return stat.mode & 0o111 ? '100755' : '100644';
208
+ }
209
+
210
+ function listWorktreeFiles(root, relativePath, output) {
211
+ const absolutePath = path.join(root, relativePath);
212
+ if (!fs.existsSync(absolutePath)) return;
213
+ const stat = fs.lstatSync(absolutePath);
214
+ if (stat.isSymbolicLink()) fail(`Symlink is not allowed in platform scope: ${relativePath}`);
215
+ if (stat.isFile()) {
216
+ output.push({
217
+ path: relativePath,
218
+ mode: fileMode(stat),
219
+ blob: git(root, ['hash-object', '--no-filters', '--', absolutePath]),
220
+ });
221
+ return;
222
+ }
223
+ if (!stat.isDirectory()) fail(`Unsupported platform entry: ${relativePath}`);
224
+ for (const name of fs.readdirSync(absolutePath).sort()) {
225
+ listWorktreeFiles(root, path.posix.join(relativePath.replace(/\/$/, ''), name), output);
226
+ }
227
+ }
228
+
229
+ export function manifestFromWorktree(repo, scope = PLATFORM_SCOPE) {
230
+ const files = [];
231
+ for (const entry of scope) listWorktreeFiles(repo, entry, files);
232
+ return normalizeManifest(files);
233
+ }
234
+
235
+ export function assertManifestEqual(left, right, message = 'platform manifests differ') {
236
+ if (serializeManifest(left) !== serializeManifest(right)) fail(message);
237
+ }
238
+
239
+ function assertRequiredTargetPaths(manifest, requiredPaths, message) {
240
+ const paths = new Set(manifest.files.map((file) => file.path));
241
+ for (const required of requiredPaths) {
242
+ if (!paths.has(required)) fail(`${message}: ${required}`);
243
+ }
244
+ }
245
+
246
+ export function assertValidProtocolTarget(manifest) {
247
+ assertRequiredTargetPaths(
248
+ manifest,
249
+ PROTOCOL_V1_REQUIRED_TARGET_PATHS,
250
+ 'platform protocol v1 target is missing required path',
251
+ );
252
+ }
253
+
254
+ export function assertValidTarget(manifest) {
255
+ assertRequiredTargetPaths(manifest, REQUIRED_TARGET_PATHS, 'platform target is missing required path');
256
+ }
257
+
258
+ export function recordsByPath(manifest) {
259
+ return new Map(manifest.files.map((file) => [file.path, file]));
260
+ }
261
+
262
+ export function recordsEqual(left, right) {
263
+ if (!left || !right) return left == null && right == null;
264
+ return left.mode === right.mode && left.blob === right.blob;
265
+ }
266
+
267
+ export function classifyRelease(baseline, local, target) {
268
+ assertValidProtocolTarget(target);
269
+ const baselineByPath = recordsByPath(baseline);
270
+ const localByPath = recordsByPath(local);
271
+ const targetByPath = recordsByPath(target);
272
+ const paths = [...new Set([
273
+ ...baselineByPath.keys(),
274
+ ...localByPath.keys(),
275
+ ...targetByPath.keys(),
276
+ ])].sort();
277
+ const entries = [];
278
+
279
+ for (const relativePath of paths) {
280
+ const base = baselineByPath.get(relativePath) ?? null;
281
+ const current = localByPath.get(relativePath) ?? null;
282
+ const incoming = targetByPath.get(relativePath) ?? null;
283
+ let action;
284
+ if (recordsEqual(current, incoming)) action = 'already-target';
285
+ else if (recordsEqual(current, base)) action = incoming ? 'install-target' : 'delete-target';
286
+ else if (recordsEqual(incoming, base)) action = 'preserve-local';
287
+ else action = 'reconcile';
288
+ entries.push({ path: relativePath, action, baseline: base, local: current, target: incoming });
289
+ }
290
+
291
+ const targetChangedPackages = entries.filter((entry) =>
292
+ PACKAGE_PATHS.has(entry.path) && !recordsEqual(entry.baseline, entry.target),
293
+ );
294
+ return {
295
+ release: releaseId(target),
296
+ baseline: releaseId(baseline),
297
+ entries,
298
+ conflicts: entries.filter((entry) => entry.action === 'reconcile'),
299
+ targetChangedPackages,
300
+ };
301
+ }
302
+
303
+ export function planLines(plan) {
304
+ const labels = {
305
+ 'already-target': 'current ',
306
+ 'install-target': 'update ',
307
+ 'delete-target': 'delete ',
308
+ 'preserve-local': 'preserve',
309
+ reconcile: 'resolve ',
310
+ };
311
+ return plan.entries
312
+ .filter((entry) => entry.action !== 'already-target')
313
+ .map((entry) => `${labels[entry.action]} ${entry.path}`);
314
+ }
315
+
316
+ export function baselineDocument(manifest, { appliedCommit = null } = {}) {
317
+ if (appliedCommit != null && !/^[0-9a-f]{40,64}$/.test(appliedCommit)) {
318
+ fail('Invalid applied commit in platform baseline');
319
+ }
320
+ const baseline = normalizeManifest(manifest.files);
321
+ return {
322
+ version: 1,
323
+ component: PLATFORM_COMPONENT,
324
+ release: releaseId(baseline),
325
+ appliedCommit,
326
+ baseline,
327
+ };
328
+ }
329
+
330
+ export function serializeBaseline(manifest, options = {}) {
331
+ return `${JSON.stringify(baselineDocument(manifest, options), null, 2)}\n`;
332
+ }
333
+
334
+ export function parseBaseline(file) {
335
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
336
+ if (parsed?.version !== 1 || parsed?.component !== PLATFORM_COMPONENT ||
337
+ !parsed.baseline || !Array.isArray(parsed.baseline.files)) {
338
+ fail(`Invalid platform baseline: ${file}`);
339
+ }
340
+ const baseline = normalizeManifest(parsed.baseline.files);
341
+ if (parsed.release !== releaseId(baseline)) fail('Platform baseline release digest mismatch');
342
+ if (parsed.appliedCommit != null && !/^[0-9a-f]{40,64}$/.test(parsed.appliedCommit)) {
343
+ fail('Platform baseline has an invalid applied commit');
344
+ }
345
+ return { ...parsed, baseline };
346
+ }
347
+
348
+ export function writeFileAtomic(destination, contents, mode = 0o600) {
349
+ fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
350
+ const temporary = path.join(
351
+ path.dirname(destination),
352
+ `.${path.basename(destination)}.${process.pid}.${crypto.randomBytes(6).toString('hex')}`,
353
+ );
354
+ fs.writeFileSync(temporary, contents, { mode, flag: 'wx' });
355
+ fs.renameSync(temporary, destination);
356
+ }
357
+
358
+ export function writeBaseline(destination, manifest, options = {}) {
359
+ writeFileAtomic(destination, serializeBaseline(manifest, options));
360
+ }
361
+
362
+ export function changedPaths(left, right) {
363
+ const leftByPath = recordsByPath(left);
364
+ const rightByPath = recordsByPath(right);
365
+ return [...new Set([...leftByPath.keys(), ...rightByPath.keys()])]
366
+ .sort()
367
+ .filter((relativePath) => !recordsEqual(leftByPath.get(relativePath), rightByPath.get(relativePath)));
368
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emptyos/client",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "External EmptyOS client",
6
6
  "license": "MIT",