@nimbus-sh/core 0.2.0 → 0.3.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/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/runtime/runtime-package.d.ts +63 -0
- package/dist/runtime/runtime-package.d.ts.map +1 -0
- package/dist/runtime/runtime-package.js +66 -0
- package/dist/runtime/runtime-registry.d.ts +3 -2
- package/dist/runtime/runtime-registry.d.ts.map +1 -1
- package/dist/runtime/runtime-registry.js +1 -1
- package/dist/workspace/nimbus-workspace.d.ts +102 -22
- package/dist/workspace/nimbus-workspace.d.ts.map +1 -1
- package/dist/workspace/nimbus-workspace.js +189 -51
- package/package.json +1 -1
- package/src/index.ts +7 -0
- package/src/runtime/runtime-package.ts +114 -0
- package/src/runtime/runtime-registry.ts +4 -3
- package/src/workspace/nimbus-workspace.ts +258 -62
|
@@ -3,37 +3,49 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A workspace is a durable filesystem plus a shell over it. It owns no
|
|
5
5
|
* transport, no session, no socket and no Durable Object: the host supplies
|
|
6
|
-
*
|
|
7
|
-
* a Durable Object that is already
|
|
8
|
-
* makes it runnable in a plain bun
|
|
6
|
+
* the filesystem and gets back `.fs`, `.exec`, and a command registry to add
|
|
7
|
+
* to. That is what makes it embeddable in a Durable Object that is already
|
|
8
|
+
* busy powering something else, and what makes it runnable in a plain bun
|
|
9
|
+
* process over `bun:sqlite`.
|
|
9
10
|
*
|
|
10
|
-
* The composition here is not new. It is the one `session/init.ts`
|
|
11
|
-
* lifted out of the session so there is one recipe rather than one per
|
|
12
|
-
* —
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* The composition here is not new. It is the one `session/init.ts` performed
|
|
12
|
+
* inline, lifted out of the session so there is one recipe rather than one per
|
|
13
|
+
* caller — the session now reads its kernel, shell and registry off a
|
|
14
|
+
* workspace, and five unit tests were already hand-rolling the same steps.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately not `Sandbox.create`, which is the lifo demo sandbox's boot
|
|
17
|
+
* rather than this one: it registers `systemctl`, `tunnel` and the network
|
|
18
|
+
* command set, boots enabled service units out of `/etc/systemd`, and starts
|
|
19
|
+
* the shell before the host can register a command of its own. A session
|
|
20
|
+
* routed through it would silently acquire all of that.
|
|
16
21
|
*/
|
|
17
|
-
import {
|
|
22
|
+
import { Kernel } from '../substrate/lifo/kernel/index.js';
|
|
23
|
+
import { Shell } from '../substrate/lifo/shell/Shell.js';
|
|
24
|
+
import { createDefaultRegistry } from '../substrate/lifo/commands/registry.js';
|
|
25
|
+
import { SandboxCommandsImpl } from '../substrate/lifo/sandbox/SandboxCommands.js';
|
|
18
26
|
import { SandboxFsImpl } from '../substrate/lifo/sandbox/SandboxFs.js';
|
|
27
|
+
import { HeadlessTerminal } from '../substrate/lifo/sandbox/HeadlessTerminal.js';
|
|
19
28
|
import { SqliteVFS, SqliteVFSProvider } from '../vfs/sqlite-vfs.js';
|
|
20
|
-
import { DEFAULT_MOUNT_POINTS, DEFAULT_PATH } from '../constants.js';
|
|
29
|
+
import { DEFAULT_HOME, DEFAULT_HOSTNAME, DEFAULT_MOUNT_POINTS, DEFAULT_PATH, DEFAULT_SHELL, DEFAULT_USER, NIMBUS_VERSION, } from '../constants.js';
|
|
21
30
|
import { CRED_KERNEL, CRED_SESSION_USER } from '../runtime/os-contracts.js';
|
|
22
31
|
import { PID_GEN_STRIDE } from '../runtime/process-table.js';
|
|
23
32
|
import { SessionProcessSupervisor } from '../runtime/session-process-supervisor.js';
|
|
24
33
|
import { rehydrateInstalledRuntimesView, } from '../runtime/installed-runtimes.js';
|
|
34
|
+
import { seedRuntimePackage } from '../runtime/runtime-package.js';
|
|
25
35
|
import { registerUnixCommands } from '../shell/unix-commands.js';
|
|
26
36
|
import { installPathExecResolver } from '../shell/exec-dispatch.js';
|
|
27
37
|
/**
|
|
28
38
|
* A durable filesystem and a shell over it.
|
|
29
39
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
40
|
+
* The composition itself is synchronous. {@link create} awaits only the
|
|
41
|
+
* optional work — installing runtime packages, loading the wasm runner modules
|
|
42
|
+
* — so a host that asks for neither is never suspended between mounting the
|
|
43
|
+
* filesystem and registering the commands. A Durable Object needs that: it
|
|
44
|
+
* must not take delivery of an event with a half-built shell. The remaining
|
|
45
|
+
* async step, running the user's login files, is {@link start}, which the host
|
|
46
|
+
* calls once its own commands are in place.
|
|
34
47
|
*/
|
|
35
48
|
export class NimbusWorkspace {
|
|
36
|
-
sandbox;
|
|
37
49
|
sql;
|
|
38
50
|
/**
|
|
39
51
|
* Credentialed and mount-aware. Acts as the session user, never as the
|
|
@@ -45,48 +57,84 @@ export class NimbusWorkspace {
|
|
|
45
57
|
vfs;
|
|
46
58
|
kernel;
|
|
47
59
|
shell;
|
|
48
|
-
|
|
49
|
-
|
|
60
|
+
/** What the shell resolves a command name against. A host adds its own. */
|
|
61
|
+
registry;
|
|
62
|
+
/**
|
|
63
|
+
* The environment the shell was composed with. The shell's own copy drifts
|
|
64
|
+
* from this one the moment the user exports anything; this is what a host
|
|
65
|
+
* hands to a subordinate shell it starts itself.
|
|
66
|
+
*/
|
|
67
|
+
env;
|
|
68
|
+
commands;
|
|
69
|
+
constructor(vfs, kernel, shell, registry, env, sql) {
|
|
50
70
|
this.sql = sql;
|
|
51
71
|
this.vfs = vfs;
|
|
52
|
-
this.kernel =
|
|
53
|
-
this.shell =
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
72
|
+
this.kernel = kernel;
|
|
73
|
+
this.shell = shell;
|
|
74
|
+
this.registry = registry;
|
|
75
|
+
this.env = env;
|
|
76
|
+
this.commands = new SandboxCommandsImpl(shell, registry);
|
|
77
|
+
// NOT the kernel VFS as it stands, which is kernel-credentialed because
|
|
78
|
+
// the shell re-credentials per command; a host calling `.fs` has no
|
|
79
|
+
// process behind it and must not inherit that.
|
|
80
|
+
this.fs = new SandboxFsImpl(kernel.vfs.as(CRED_SESSION_USER), () => shell.getCwd());
|
|
58
81
|
}
|
|
59
82
|
static async create(options) {
|
|
60
|
-
const vfs =
|
|
61
|
-
vfs.revokeAppendWritersThrough((options.generation ?? 1) * PID_GEN_STRIDE);
|
|
83
|
+
const vfs = options.vfs ?? openFilesystem(options);
|
|
62
84
|
const mounts = options.mounts ?? DEFAULT_MOUNT_POINTS;
|
|
63
85
|
seedBaseFilesystem(vfs, mounts);
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
});
|
|
86
|
+
const kernel = new Kernel();
|
|
87
|
+
// Seeds the in-memory tree. Mounting AFTER it is what keeps a durable
|
|
88
|
+
// /etc from being overwritten by the defaults on every boot.
|
|
89
|
+
kernel.initFilesystem();
|
|
90
|
+
for (const mount of mounts) {
|
|
91
|
+
kernel.vfs.mount(`/${mount}`, new SqliteVFSProvider(vfs, mount));
|
|
92
|
+
}
|
|
93
|
+
const registry = createDefaultRegistry();
|
|
73
94
|
// The durable coreutils replace ~25 lifo builtins. They are the ones that
|
|
74
95
|
// carry credentials and read this filesystem's uid/gid, so they must win.
|
|
75
|
-
registerUnixCommands(
|
|
76
|
-
|
|
96
|
+
registerUnixCommands(registry, vfs);
|
|
97
|
+
const env = { ...defaultEnv(), ...options.env };
|
|
98
|
+
const shell = new Shell(options.terminal ?? new HeadlessTerminal(), kernel.vfs, registry, env, kernel.processRegistry, options.identity);
|
|
99
|
+
if (options.cwd)
|
|
100
|
+
shell.setCwd(options.cwd);
|
|
101
|
+
// Kernel-credentialed on purpose: this only INSPECTS a file to decide how
|
|
102
|
+
// to run it, and re-checks the caller's own execute permission at
|
|
103
|
+
// invocation time — the `authorize` wrapper in exec-dispatch.ts.
|
|
104
|
+
installPathExecResolver(registry, vfs.as(CRED_KERNEL), () => shell.getCwd());
|
|
105
|
+
const home = env.HOME ?? DEFAULT_HOME;
|
|
106
|
+
// Before the runners are wired, because registration reads what the
|
|
107
|
+
// filesystem holds — the same order `nimbus install` observes, and the
|
|
108
|
+
// same order a Durable Object observes when it rehydrates after eviction.
|
|
109
|
+
for (const runtimePackage of options.runtimes ?? []) {
|
|
110
|
+
await seedRuntimePackage(vfs.as(CRED_KERNEL), home, runtimePackage);
|
|
111
|
+
}
|
|
77
112
|
if (options.facets) {
|
|
78
113
|
await registerWasmRuntimes({
|
|
79
114
|
facets: options.facets,
|
|
80
115
|
vfs,
|
|
81
|
-
registry
|
|
116
|
+
registry,
|
|
82
117
|
generation: options.generation ?? 1,
|
|
83
|
-
home
|
|
118
|
+
home,
|
|
84
119
|
});
|
|
85
120
|
}
|
|
86
|
-
return new NimbusWorkspace(
|
|
121
|
+
return new NimbusWorkspace(vfs, kernel, shell, registry, env, options.sql);
|
|
87
122
|
}
|
|
88
123
|
exec(command, options) {
|
|
89
|
-
return this.
|
|
124
|
+
return this.commands.run(command, options);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Apply the login files, and begin reading the terminal when there is one.
|
|
128
|
+
*
|
|
129
|
+
* Separate from {@link create} because a host with commands of its own must
|
|
130
|
+
* register them first: `/etc/profile` and `~/.nimbusrc` are the user's
|
|
131
|
+
* files, and either may name a command the host has yet to supply.
|
|
132
|
+
*/
|
|
133
|
+
async start() {
|
|
134
|
+
// Sources /etc/profile and the first user rc file it finds, then prompts.
|
|
135
|
+
this.shell.start();
|
|
136
|
+
// Nimbus's own rc file, which the shell's list predates.
|
|
137
|
+
await this.shell.sourceFile(`${this.shell.getEnv().HOME ?? DEFAULT_HOME}/.nimbusrc`);
|
|
90
138
|
}
|
|
91
139
|
/**
|
|
92
140
|
* Files, directories and bytes this workspace occupies.
|
|
@@ -113,6 +161,51 @@ export class NimbusWorkspace {
|
|
|
113
161
|
}
|
|
114
162
|
}
|
|
115
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Open the durable filesystem for a host that has not opened one itself.
|
|
166
|
+
*
|
|
167
|
+
* The revocation is here rather than in `create` because it is the act of
|
|
168
|
+
* OPENING that carries it: pids at or below this generation's floor belong to
|
|
169
|
+
* an instance that is gone, and their append capabilities must stop being
|
|
170
|
+
* honoured before the first read. A host that opened the filesystem itself has
|
|
171
|
+
* already done this, at the same seam, for the same reason.
|
|
172
|
+
*/
|
|
173
|
+
function openFilesystem(options) {
|
|
174
|
+
const vfs = new SqliteVFS(options.sql, options.transactions);
|
|
175
|
+
vfs.revokeAppendWritersThrough((options.generation ?? 1) * PID_GEN_STRIDE);
|
|
176
|
+
return vfs;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The environment a Nimbus shell starts in.
|
|
180
|
+
*
|
|
181
|
+
* `PATH` and `EDITOR` restate what the seeded `/etc/profile` exports, so a
|
|
182
|
+
* workspace whose host never runs the login files is still on the real PATH.
|
|
183
|
+
* `PORT` and `HOST` are here because every scaffolded server reads them and
|
|
184
|
+
* gets `undefined` otherwise — Express's default app, every create-vite
|
|
185
|
+
* template, `${PORT:-3000}` in a package.json script.
|
|
186
|
+
*/
|
|
187
|
+
function defaultEnv() {
|
|
188
|
+
return {
|
|
189
|
+
HOME: DEFAULT_HOME,
|
|
190
|
+
USER: DEFAULT_USER,
|
|
191
|
+
SHELL: DEFAULT_SHELL,
|
|
192
|
+
HOSTNAME: DEFAULT_HOSTNAME,
|
|
193
|
+
TERM: 'xterm-256color',
|
|
194
|
+
PWD: DEFAULT_HOME,
|
|
195
|
+
PATH: DEFAULT_PATH,
|
|
196
|
+
PS1: `\x1b[1;32muser@${DEFAULT_HOSTNAME}\x1b[0m:\x1b[1;34m\\w\x1b[0m$ `,
|
|
197
|
+
NODE_ENV: 'development',
|
|
198
|
+
LANG: 'en_US.UTF-8',
|
|
199
|
+
EDITOR: 'nano',
|
|
200
|
+
NIMBUS_VERSION: NIMBUS_VERSION,
|
|
201
|
+
TMPDIR: '/tmp',
|
|
202
|
+
XDG_CONFIG_HOME: `${DEFAULT_HOME}/.config`,
|
|
203
|
+
XDG_DATA_HOME: `${DEFAULT_HOME}/.local/share`,
|
|
204
|
+
npm_config_prefix: '/usr/local',
|
|
205
|
+
PORT: '3000',
|
|
206
|
+
HOST: '0.0.0.0',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
116
209
|
/**
|
|
117
210
|
* Turn a facet host into commands: the runtimes this filesystem already holds,
|
|
118
211
|
* plus `wasm-runner` for everything else with a `\0asm` header.
|
|
@@ -127,24 +220,29 @@ export class NimbusWorkspace {
|
|
|
127
220
|
* them.
|
|
128
221
|
*/
|
|
129
222
|
async function registerWasmRuntimes(deps) {
|
|
130
|
-
const [{ makeBashRunnerFactory }, { makeCPythonRunnerFactory }, { wasmRunnerSpec }, { buildRuntimeHandler },
|
|
223
|
+
const [{ makeBashRunnerFactory }, { makeCPythonRunnerFactory }, { wasmRunnerSpec }, { buildRuntimeHandler },] = await Promise.all([
|
|
131
224
|
import('../runtime/bash-runner.js'),
|
|
132
225
|
import('../runtime/cpython-runner.js'),
|
|
133
226
|
import('../runtime/wasm-runner.js'),
|
|
134
227
|
import('../runtime/runtime-registry.js'),
|
|
135
|
-
import('../runtime/esbuild-service.js'),
|
|
136
228
|
]);
|
|
137
229
|
// wasm-runner allocates pids for what it runs, so it needs a process table
|
|
138
|
-
// whose pid space is this generation's
|
|
139
|
-
// above are revoked by.
|
|
230
|
+
// whose pid space is this generation's.
|
|
140
231
|
const processes = new SessionProcessSupervisor();
|
|
141
232
|
processes.setPidBase(deps.generation * PID_GEN_STRIDE);
|
|
233
|
+
// Loaded on the first TypeScript or ESM script and not before. The module
|
|
234
|
+
// statically imports `esbuild-wasm/esbuild.wasm`, which only wrangler
|
|
235
|
+
// resolves — node instantiates it as a wasm module and fails on its Go
|
|
236
|
+
// imports — so a host outside Cloudflare must be able to run a shell, bash
|
|
237
|
+
// and python without that module ever entering its graph.
|
|
142
238
|
let esbuild = null;
|
|
143
239
|
deps.registry.register('wasm-runner', buildRuntimeHandler(wasmRunnerSpec({ vfs: deps.vfs, facets: deps.facets, processes }), {
|
|
144
240
|
vfs: deps.vfs,
|
|
145
241
|
getEsbuild: () => {
|
|
146
|
-
if (!esbuild)
|
|
147
|
-
esbuild =
|
|
242
|
+
if (!esbuild) {
|
|
243
|
+
esbuild = import('../runtime/esbuild-service.js')
|
|
244
|
+
.then((module) => new module.EsbuildService(deps.vfs));
|
|
245
|
+
}
|
|
148
246
|
return esbuild;
|
|
149
247
|
},
|
|
150
248
|
registry: deps.registry,
|
|
@@ -185,8 +283,14 @@ const WORKSPACE_TABLES = [
|
|
|
185
283
|
* a workspace reopened over a populated database keeps whatever the user did
|
|
186
284
|
* to these files. `/etc/passwd` and `/etc/group` are load-bearing rather than
|
|
187
285
|
* decorative — `id`, `chown` and `su` resolve names through them.
|
|
286
|
+
*
|
|
287
|
+
* What a PRODUCT puts in a fresh filesystem — a banner, a welcome file, a
|
|
288
|
+
* starter app — is not here. This is the base an OS needs in order to boot,
|
|
289
|
+
* and it is exported because a host may need the filesystem before it needs a
|
|
290
|
+
* shell: the Nimbus session seeds its starter project for a browser that hits
|
|
291
|
+
* `/preview` without ever opening a terminal.
|
|
188
292
|
*/
|
|
189
|
-
function seedBaseFilesystem(vfs, mounts) {
|
|
293
|
+
export function seedBaseFilesystem(vfs, mounts) {
|
|
190
294
|
const fs = vfs.as(CRED_SESSION_USER);
|
|
191
295
|
const rootFs = vfs.as(CRED_KERNEL);
|
|
192
296
|
// Created AS the session user, so the user owns their own tree. Seeding
|
|
@@ -195,12 +299,37 @@ function seedBaseFilesystem(vfs, mounts) {
|
|
|
195
299
|
if (mount !== 'etc' && !fs.exists(mount))
|
|
196
300
|
fs.mkdir(mount, { recursive: true });
|
|
197
301
|
}
|
|
198
|
-
for (const dir of [
|
|
302
|
+
for (const dir of [
|
|
303
|
+
'home/user', 'home/user/.config', 'home/user/projects',
|
|
304
|
+
'tmp', 'var/log',
|
|
305
|
+
'usr/bin', 'usr/lib', 'usr/lib/node_modules',
|
|
306
|
+
'usr/share', 'usr/share/pkg', 'usr/share/pkg/node_modules',
|
|
307
|
+
'usr/local', 'usr/local/lib', 'usr/local/lib/node_modules', 'usr/local/bin',
|
|
308
|
+
]) {
|
|
199
309
|
if (!fs.exists(dir))
|
|
200
310
|
fs.mkdir(dir, { recursive: true });
|
|
201
311
|
}
|
|
202
|
-
|
|
312
|
+
// /etc belongs to root, and is re-asserted rather than only created: a
|
|
313
|
+
// user-writable /etc is an authority bug, not an untidy directory.
|
|
314
|
+
if (!rootFs.exists('etc')) {
|
|
203
315
|
rootFs.mkdir('etc', { mode: 0o755 });
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
const etc = rootFs.stat('etc');
|
|
319
|
+
if (etc.uid !== 0 || etc.gid !== 0)
|
|
320
|
+
rootFs.chown('etc', 0, 0);
|
|
321
|
+
if ((etc.mode & 0o7777) !== 0o755)
|
|
322
|
+
rootFs.chmod('etc', 0o755);
|
|
323
|
+
}
|
|
324
|
+
if (!rootFs.exists('etc/hostname')) {
|
|
325
|
+
rootFs.writeFile('etc/hostname', `${DEFAULT_HOSTNAME}\n`);
|
|
326
|
+
rootFs.chown('etc/hostname', CRED_SESSION_USER.uid, CRED_SESSION_USER.gid);
|
|
327
|
+
}
|
|
328
|
+
if (!rootFs.exists('etc/os-release')) {
|
|
329
|
+
rootFs.writeFile('etc/os-release', `NAME="Nimbus"\nVERSION="${NIMBUS_VERSION}"\nID=nimbus\n`
|
|
330
|
+
+ 'PRETTY_NAME="Nimbus — Cloud Dev Environment"\n');
|
|
331
|
+
rootFs.chown('etc/os-release', CRED_SESSION_USER.uid, CRED_SESSION_USER.gid);
|
|
332
|
+
}
|
|
204
333
|
// Root-owned 0644, and re-asserted rather than only created: these decide
|
|
205
334
|
// what `id`, `chown` and `su` believe, so a user-writable /etc/passwd would
|
|
206
335
|
// be an authority bug rather than an untidy file.
|
|
@@ -215,8 +344,17 @@ function seedBaseFilesystem(vfs, mounts) {
|
|
|
215
344
|
};
|
|
216
345
|
accountFile('etc/passwd', 'root:x:0:0:root:/root:/bin/sh\nuser:x:1000:1000:Nimbus User:/home/user:/bin/sh\n');
|
|
217
346
|
accountFile('etc/group', 'root:x:0:\nuser:x:1000:user\n');
|
|
347
|
+
const defaultProfile = `export PATH=${DEFAULT_PATH}\nexport EDITOR=nano\n`;
|
|
218
348
|
if (!rootFs.exists('etc/profile')) {
|
|
219
|
-
rootFs.writeFile('etc/profile',
|
|
349
|
+
rootFs.writeFile('etc/profile', defaultProfile);
|
|
220
350
|
rootFs.chown('etc/profile', CRED_SESSION_USER.uid, CRED_SESSION_USER.gid);
|
|
221
351
|
}
|
|
352
|
+
else if (rootFs.readFileString('etc/profile') === 'export PATH=/usr/bin:/bin\nexport EDITOR=nano\n') {
|
|
353
|
+
// The lifo default, from before Nimbus had a PATH of its own. Nobody ever
|
|
354
|
+
// chose it, so replacing it is not overwriting a user's file.
|
|
355
|
+
rootFs.writeFile('etc/profile', defaultProfile);
|
|
356
|
+
}
|
|
357
|
+
if (!fs.exists('home/user/.nimbusrc')) {
|
|
358
|
+
fs.writeFile('home/user/.nimbusrc', '# Nimbus shell config\nalias ll="ls -la"\nalias la="ls -a"\nalias l="ls -1"\n');
|
|
359
|
+
}
|
|
222
360
|
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -20,6 +20,13 @@ export type {
|
|
|
20
20
|
SqlValue,
|
|
21
21
|
TransactionHost,
|
|
22
22
|
} from './runtime/os-contracts.js';
|
|
23
|
+
export { seedRuntimePackage } from './runtime/runtime-package.js';
|
|
24
|
+
export type { RuntimePackage, SeededRuntime } from './runtime/runtime-package.js';
|
|
25
|
+
export type {
|
|
26
|
+
ManifestEntrypoint,
|
|
27
|
+
ManifestFile,
|
|
28
|
+
RuntimeManifest,
|
|
29
|
+
} from './runtime/runtime-manifest.js';
|
|
23
30
|
export { localFacetHost } from './runtime/local-facet-host.js';
|
|
24
31
|
export type {
|
|
25
32
|
Facet,
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime-package.ts — a runtime that arrived with the code, rather than over
|
|
3
|
+
* the network.
|
|
4
|
+
*
|
|
5
|
+
* `nimbus install <name>` reads a catalog out of R2 and writes the result into
|
|
6
|
+
* `~/.nimbus/runtimes/<name>/<version>/`. That is the Cloudflare deployment's
|
|
7
|
+
* answer to "where do the bytes come from", and it needs a bucket, a binding
|
|
8
|
+
* and a colo cache. An embedder who ran `npm i @nimbus-sh/runtime-bash` has
|
|
9
|
+
* already answered the same question: the bytes are on disk beside their
|
|
10
|
+
* manifest, fetched and integrity-checked by npm before any of this ran.
|
|
11
|
+
*
|
|
12
|
+
* So this is the second publisher, not the second package manager. It writes
|
|
13
|
+
* the SAME tree at the SAME path from the SAME manifest — `installRoot()`,
|
|
14
|
+
* `parseRuntimeManifest()`, one file per `manifest.files` entry — so
|
|
15
|
+
* `listInstalledManifests` and `rehydrateInstalledRuntimes` cannot tell which
|
|
16
|
+
* one ran, and a workspace behaves identically either way.
|
|
17
|
+
*
|
|
18
|
+
* Trust: R2 is verified against a digest chain rooted in a build-time pin
|
|
19
|
+
* because `caches.default` is shared across tenants and R2 keys are not
|
|
20
|
+
* content-addressed. A runtime package's root of trust is npm's own tarball
|
|
21
|
+
* integrity, which the install already checked; from there the manifest's
|
|
22
|
+
* per-file digests are re-verified here for exactly the reason the R2 path
|
|
23
|
+
* verifies them — these blobs are interpreters, so bytes that reach the
|
|
24
|
+
* filesystem are bytes that execute.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { sha256Hex } from '../_shared/crypto.js';
|
|
28
|
+
import type { CredentialedVfs } from '../vfs/sqlite-vfs.js';
|
|
29
|
+
import { installRoot } from './installed-runtimes.js';
|
|
30
|
+
import {
|
|
31
|
+
parseRuntimeManifest,
|
|
32
|
+
type ManifestFile,
|
|
33
|
+
type RuntimeManifest,
|
|
34
|
+
} from './runtime-manifest.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* An installed npm package holding one runtime.
|
|
38
|
+
*
|
|
39
|
+
* The published packages (`@nimbus-sh/runtime-bash`,
|
|
40
|
+
* `@nimbus-sh/runtime-cpython`) are the implementations; each is a manifest,
|
|
41
|
+
* the content-addressed blobs it names, and the eight lines of `node:fs` that
|
|
42
|
+
* read them. The port is here rather than in those packages because the
|
|
43
|
+
* FILESYSTEM is what a runtime is, and this is the half that knows it.
|
|
44
|
+
*
|
|
45
|
+
* `readBlob` takes the whole manifest entry rather than a bare key, mirroring
|
|
46
|
+
* `fetchBlob` in the Cloudflare catalog: a key and the digest that vouches for
|
|
47
|
+
* it never travel as separate arguments, so there is no call in which they can
|
|
48
|
+
* disagree.
|
|
49
|
+
*/
|
|
50
|
+
export interface RuntimePackage {
|
|
51
|
+
readonly manifest: RuntimeManifest;
|
|
52
|
+
readBlob(file: ManifestFile): Uint8Array | Promise<Uint8Array>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SeededRuntime {
|
|
56
|
+
readonly name: string;
|
|
57
|
+
readonly version: string;
|
|
58
|
+
/** VFS path of the install root, e.g. `home/user/.nimbus/runtimes/bash/5.2.37`. */
|
|
59
|
+
readonly root: string;
|
|
60
|
+
/** False when the runtime was already installed at `root` and nothing was written. */
|
|
61
|
+
readonly written: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Write a runtime package into the filesystem as an install.
|
|
66
|
+
*
|
|
67
|
+
* Idempotent on the same rule the package manager uses: a `manifest.json`
|
|
68
|
+
* already at the install root means the install completed, and the root
|
|
69
|
+
* carries the version, so a package upgrade lands beside its predecessor
|
|
70
|
+
* rather than half over it.
|
|
71
|
+
*/
|
|
72
|
+
export async function seedRuntimePackage(
|
|
73
|
+
vfs: CredentialedVfs,
|
|
74
|
+
homeDir: string,
|
|
75
|
+
runtimePackage: RuntimePackage,
|
|
76
|
+
): Promise<SeededRuntime> {
|
|
77
|
+
const manifest = parseRuntimeManifest(runtimePackage.manifest);
|
|
78
|
+
const root = installRoot(homeDir, manifest.name, manifest.version);
|
|
79
|
+
|
|
80
|
+
if (vfs.exists(`${root}/manifest.json`)) {
|
|
81
|
+
return { name: manifest.name, version: manifest.version, root, written: false };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
vfs.mkdir(root, { recursive: true });
|
|
85
|
+
for (const file of manifest.files) {
|
|
86
|
+
const target = `${root}/${file.path}`;
|
|
87
|
+
const parent = target.slice(0, target.lastIndexOf('/'));
|
|
88
|
+
if (!vfs.exists(parent)) vfs.mkdir(parent, { recursive: true });
|
|
89
|
+
vfs.writeFile(target, await verifiedBlob(manifest, runtimePackage, file));
|
|
90
|
+
}
|
|
91
|
+
// Last, where the R2 installer writes it first: that one has `--reinstall`
|
|
92
|
+
// to force a redo, and this one has no verb at all, so a manifest sitting
|
|
93
|
+
// beside a half-written tree would report a broken runtime as installed for
|
|
94
|
+
// the life of the filesystem.
|
|
95
|
+
vfs.writeFile(`${root}/manifest.json`, JSON.stringify(manifest, null, 2));
|
|
96
|
+
|
|
97
|
+
return { name: manifest.name, version: manifest.version, root, written: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function verifiedBlob(
|
|
101
|
+
manifest: RuntimeManifest,
|
|
102
|
+
runtimePackage: RuntimePackage,
|
|
103
|
+
file: ManifestFile,
|
|
104
|
+
): Promise<Uint8Array> {
|
|
105
|
+
const bytes = await runtimePackage.readBlob(file);
|
|
106
|
+
const actual = await sha256Hex(bytes);
|
|
107
|
+
if (actual !== file.sha256) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`${manifest.name}@${manifest.version}: sha256 mismatch for ${file.path} — manifest expects `
|
|
110
|
+
+ `${file.sha256}, ${file.content} holds ${actual}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return bytes;
|
|
114
|
+
}
|
|
@@ -196,8 +196,9 @@ export function buildRuntimeHandler(
|
|
|
196
196
|
ctx0: {
|
|
197
197
|
vfs: SqliteVFS;
|
|
198
198
|
/** Lazy esbuild initialiser. Called once per first .ts/.tsx/.jsx
|
|
199
|
-
* invocation — the host owns the init lifecycle
|
|
200
|
-
|
|
199
|
+
* invocation — the host owns the init lifecycle, including whether
|
|
200
|
+
* the module is loaded eagerly or on this call. */
|
|
201
|
+
getEsbuild(): EsbuildService | Promise<EsbuildService>;
|
|
201
202
|
registry: ShellRegistry;
|
|
202
203
|
},
|
|
203
204
|
): (ctx: any) => Promise<number> {
|
|
@@ -421,7 +422,7 @@ export function buildRuntimeHandler(
|
|
|
421
422
|
needsEsmTransform
|
|
422
423
|
) {
|
|
423
424
|
try {
|
|
424
|
-
const eb = getEsbuild();
|
|
425
|
+
const eb = await getEsbuild();
|
|
425
426
|
const loader =
|
|
426
427
|
scriptExt === '.tsx' ? 'tsx' :
|
|
427
428
|
scriptExt === '.jsx' ? 'jsx' :
|