@nimbus-sh/core 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/dist/_shared/tarball-stream.d.ts +93 -0
- package/dist/_shared/tarball-stream.d.ts.map +1 -0
- package/dist/_shared/tarball-stream.js +235 -0
- package/dist/_shared/tarball.d.ts +17 -0
- package/dist/_shared/tarball.d.ts.map +1 -0
- package/dist/_shared/tarball.js +39 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/runtime/clang-runner.d.ts +38 -0
- package/dist/runtime/clang-runner.d.ts.map +1 -0
- package/dist/runtime/clang-runner.js +866 -0
- package/dist/runtime/facet-host.d.ts +12 -0
- package/dist/runtime/facet-host.d.ts.map +1 -1
- package/dist/runtime/local-facet-host.d.ts.map +1 -1
- package/dist/runtime/local-facet-host.js +29 -9
- package/dist/runtime/ruby-gems.d.ts +30 -0
- package/dist/runtime/ruby-gems.d.ts.map +1 -0
- package/dist/runtime/ruby-gems.js +636 -0
- package/dist/runtime/ruby-runner.d.ts +127 -0
- package/dist/runtime/ruby-runner.d.ts.map +1 -0
- package/dist/runtime/ruby-runner.js +1357 -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/runtime/session-process-supervisor.d.ts +15 -0
- package/dist/runtime/session-process-supervisor.d.ts.map +1 -1
- package/dist/runtime/session-process-supervisor.js +30 -0
- 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 +197 -52
- package/package.json +4 -2
- package/src/_shared/tarball-stream.ts +263 -0
- package/src/_shared/tarball.ts +46 -0
- package/src/index.ts +7 -0
- package/src/runtime/clang-runner.ts +924 -0
- package/src/runtime/facet-host.ts +12 -0
- package/src/runtime/local-facet-host.ts +28 -8
- package/src/runtime/ruby-gems.ts +682 -0
- package/src/runtime/ruby-runner.ts +1484 -0
- package/src/runtime/runtime-package.ts +114 -0
- package/src/runtime/runtime-registry.ts +4 -3
- package/src/runtime/session-process-supervisor.ts +27 -0
- package/src/workspace/nimbus-workspace.ts +268 -63
|
@@ -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,31 @@ export class NimbusWorkspace {
|
|
|
127
220
|
* them.
|
|
128
221
|
*/
|
|
129
222
|
async function registerWasmRuntimes(deps) {
|
|
130
|
-
const [{ makeBashRunnerFactory }, { makeCPythonRunnerFactory }, { wasmRunnerSpec }, { buildRuntimeHandler },
|
|
223
|
+
const [{ makeBashRunnerFactory }, { makeCPythonRunnerFactory }, { makeRubyRunnerFactory }, { makeClangRunnerFactory }, { wasmRunnerSpec }, { buildRuntimeHandler },] = await Promise.all([
|
|
131
224
|
import('../runtime/bash-runner.js'),
|
|
132
225
|
import('../runtime/cpython-runner.js'),
|
|
226
|
+
import('../runtime/ruby-runner.js'),
|
|
227
|
+
import('../runtime/clang-runner.js'),
|
|
133
228
|
import('../runtime/wasm-runner.js'),
|
|
134
229
|
import('../runtime/runtime-registry.js'),
|
|
135
|
-
import('../runtime/esbuild-service.js'),
|
|
136
230
|
]);
|
|
137
231
|
// 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.
|
|
232
|
+
// whose pid space is this generation's.
|
|
140
233
|
const processes = new SessionProcessSupervisor();
|
|
141
234
|
processes.setPidBase(deps.generation * PID_GEN_STRIDE);
|
|
235
|
+
// Loaded on the first TypeScript or ESM script and not before. The module
|
|
236
|
+
// statically imports `esbuild-wasm/esbuild.wasm`, which only wrangler
|
|
237
|
+
// resolves — node instantiates it as a wasm module and fails on its Go
|
|
238
|
+
// imports — so a host outside Cloudflare must be able to run a shell, bash
|
|
239
|
+
// and python without that module ever entering its graph.
|
|
142
240
|
let esbuild = null;
|
|
143
241
|
deps.registry.register('wasm-runner', buildRuntimeHandler(wasmRunnerSpec({ vfs: deps.vfs, facets: deps.facets, processes }), {
|
|
144
242
|
vfs: deps.vfs,
|
|
145
243
|
getEsbuild: () => {
|
|
146
|
-
if (!esbuild)
|
|
147
|
-
esbuild =
|
|
244
|
+
if (!esbuild) {
|
|
245
|
+
esbuild = import('../runtime/esbuild-service.js')
|
|
246
|
+
.then((module) => new module.EsbuildService(deps.vfs));
|
|
247
|
+
}
|
|
148
248
|
return esbuild;
|
|
149
249
|
},
|
|
150
250
|
registry: deps.registry,
|
|
@@ -153,8 +253,13 @@ async function registerWasmRuntimes(deps) {
|
|
|
153
253
|
'bash-runner': makeBashRunnerFactory({ facets: deps.facets, vfs: deps.vfs }),
|
|
154
254
|
// No `startResident`: a workspace owns no actor that could outlive the
|
|
155
255
|
// call, so a program that keeps serving is refused by name rather than
|
|
156
|
-
// run as a one-shot that dies with it.
|
|
256
|
+
// run as a one-shot that dies with it. Same for ruby, where a script is
|
|
257
|
+
// the shape that may bind a port.
|
|
157
258
|
'cpython-runner': makeCPythonRunnerFactory({ facets: deps.facets, vfs: deps.vfs }),
|
|
259
|
+
'ruby-runner': makeRubyRunnerFactory({
|
|
260
|
+
facets: deps.facets, vfs: deps.vfs, registry: deps.registry,
|
|
261
|
+
}),
|
|
262
|
+
'clang-runner': makeClangRunnerFactory({ facets: deps.facets, vfs: deps.vfs }),
|
|
158
263
|
};
|
|
159
264
|
rehydrateInstalledRuntimesView(deps.vfs.as(CRED_KERNEL), deps.registry, deps.home, (key) => runners[key]);
|
|
160
265
|
}
|
|
@@ -185,8 +290,14 @@ const WORKSPACE_TABLES = [
|
|
|
185
290
|
* a workspace reopened over a populated database keeps whatever the user did
|
|
186
291
|
* to these files. `/etc/passwd` and `/etc/group` are load-bearing rather than
|
|
187
292
|
* decorative — `id`, `chown` and `su` resolve names through them.
|
|
293
|
+
*
|
|
294
|
+
* What a PRODUCT puts in a fresh filesystem — a banner, a welcome file, a
|
|
295
|
+
* starter app — is not here. This is the base an OS needs in order to boot,
|
|
296
|
+
* and it is exported because a host may need the filesystem before it needs a
|
|
297
|
+
* shell: the Nimbus session seeds its starter project for a browser that hits
|
|
298
|
+
* `/preview` without ever opening a terminal.
|
|
188
299
|
*/
|
|
189
|
-
function seedBaseFilesystem(vfs, mounts) {
|
|
300
|
+
export function seedBaseFilesystem(vfs, mounts) {
|
|
190
301
|
const fs = vfs.as(CRED_SESSION_USER);
|
|
191
302
|
const rootFs = vfs.as(CRED_KERNEL);
|
|
192
303
|
// Created AS the session user, so the user owns their own tree. Seeding
|
|
@@ -195,12 +306,37 @@ function seedBaseFilesystem(vfs, mounts) {
|
|
|
195
306
|
if (mount !== 'etc' && !fs.exists(mount))
|
|
196
307
|
fs.mkdir(mount, { recursive: true });
|
|
197
308
|
}
|
|
198
|
-
for (const dir of [
|
|
309
|
+
for (const dir of [
|
|
310
|
+
'home/user', 'home/user/.config', 'home/user/projects',
|
|
311
|
+
'tmp', 'var/log',
|
|
312
|
+
'usr/bin', 'usr/lib', 'usr/lib/node_modules',
|
|
313
|
+
'usr/share', 'usr/share/pkg', 'usr/share/pkg/node_modules',
|
|
314
|
+
'usr/local', 'usr/local/lib', 'usr/local/lib/node_modules', 'usr/local/bin',
|
|
315
|
+
]) {
|
|
199
316
|
if (!fs.exists(dir))
|
|
200
317
|
fs.mkdir(dir, { recursive: true });
|
|
201
318
|
}
|
|
202
|
-
|
|
319
|
+
// /etc belongs to root, and is re-asserted rather than only created: a
|
|
320
|
+
// user-writable /etc is an authority bug, not an untidy directory.
|
|
321
|
+
if (!rootFs.exists('etc')) {
|
|
203
322
|
rootFs.mkdir('etc', { mode: 0o755 });
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
const etc = rootFs.stat('etc');
|
|
326
|
+
if (etc.uid !== 0 || etc.gid !== 0)
|
|
327
|
+
rootFs.chown('etc', 0, 0);
|
|
328
|
+
if ((etc.mode & 0o7777) !== 0o755)
|
|
329
|
+
rootFs.chmod('etc', 0o755);
|
|
330
|
+
}
|
|
331
|
+
if (!rootFs.exists('etc/hostname')) {
|
|
332
|
+
rootFs.writeFile('etc/hostname', `${DEFAULT_HOSTNAME}\n`);
|
|
333
|
+
rootFs.chown('etc/hostname', CRED_SESSION_USER.uid, CRED_SESSION_USER.gid);
|
|
334
|
+
}
|
|
335
|
+
if (!rootFs.exists('etc/os-release')) {
|
|
336
|
+
rootFs.writeFile('etc/os-release', `NAME="Nimbus"\nVERSION="${NIMBUS_VERSION}"\nID=nimbus\n`
|
|
337
|
+
+ 'PRETTY_NAME="Nimbus — Cloud Dev Environment"\n');
|
|
338
|
+
rootFs.chown('etc/os-release', CRED_SESSION_USER.uid, CRED_SESSION_USER.gid);
|
|
339
|
+
}
|
|
204
340
|
// Root-owned 0644, and re-asserted rather than only created: these decide
|
|
205
341
|
// what `id`, `chown` and `su` believe, so a user-writable /etc/passwd would
|
|
206
342
|
// be an authority bug rather than an untidy file.
|
|
@@ -215,8 +351,17 @@ function seedBaseFilesystem(vfs, mounts) {
|
|
|
215
351
|
};
|
|
216
352
|
accountFile('etc/passwd', 'root:x:0:0:root:/root:/bin/sh\nuser:x:1000:1000:Nimbus User:/home/user:/bin/sh\n');
|
|
217
353
|
accountFile('etc/group', 'root:x:0:\nuser:x:1000:user\n');
|
|
354
|
+
const defaultProfile = `export PATH=${DEFAULT_PATH}\nexport EDITOR=nano\n`;
|
|
218
355
|
if (!rootFs.exists('etc/profile')) {
|
|
219
|
-
rootFs.writeFile('etc/profile',
|
|
356
|
+
rootFs.writeFile('etc/profile', defaultProfile);
|
|
220
357
|
rootFs.chown('etc/profile', CRED_SESSION_USER.uid, CRED_SESSION_USER.gid);
|
|
221
358
|
}
|
|
359
|
+
else if (rootFs.readFileString('etc/profile') === 'export PATH=/usr/bin:/bin\nexport EDITOR=nano\n') {
|
|
360
|
+
// The lifo default, from before Nimbus had a PATH of its own. Nobody ever
|
|
361
|
+
// chose it, so replacing it is not overwriting a user's file.
|
|
362
|
+
rootFs.writeFile('etc/profile', defaultProfile);
|
|
363
|
+
}
|
|
364
|
+
if (!fs.exists('home/user/.nimbusrc')) {
|
|
365
|
+
fs.writeFile('home/user/.nimbusrc', '# Nimbus shell config\nalias ll="ls -la"\nalias la="ls -a"\nalias l="ls -1"\n');
|
|
366
|
+
}
|
|
222
367
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nimbus-sh/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Backend-agnostic half of Nimbus \u2014 the durable filesystem, the shell, and the OS/process contracts, over a host-supplied SQL port.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"filesystem",
|
|
@@ -45,7 +45,9 @@
|
|
|
45
45
|
},
|
|
46
46
|
"files": [
|
|
47
47
|
"dist",
|
|
48
|
-
"src"
|
|
48
|
+
"src",
|
|
49
|
+
"README.md",
|
|
50
|
+
"LICENSE"
|
|
49
51
|
],
|
|
50
52
|
"scripts": {
|
|
51
53
|
"build": "tsc -p tsconfig.json --noCheck --noEmit false --declaration true --declarationMap true --outDir dist --rootDir src",
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tarball-stream.ts — pure streaming tar primitives.
|
|
3
|
+
*
|
|
4
|
+
* A leaf with no imports at all, deliberately: `bundle-facet-workers.mjs`
|
|
5
|
+
* esbuilds this file into a string constant the loader pool injects into
|
|
6
|
+
* dynamic workers, and a facet isolate resolves no specifier. Anything this
|
|
7
|
+
* file imported would have to travel with it.
|
|
8
|
+
*
|
|
9
|
+
* Zero dependencies. Works identically on the supervisor and inside a
|
|
10
|
+
* facet isolate. Never buffers the full decompressed tarball — peak
|
|
11
|
+
* transient heap is one file's bytes plus a 512-byte carry.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Maximum size of a single file inside a tarball. Larger entries are skipped.
|
|
16
|
+
*
|
|
17
|
+
* History: 5 MB was too low — it silently dropped `esbuild-wasm/esbuild.wasm`
|
|
18
|
+
* (11.35 MB on v0.24.2), which made Nimbus-in-Nimbus `npm run dev` fail with
|
|
19
|
+
* `No such module "esbuild-wasm/esbuild.wasm"` since the missing file caused
|
|
20
|
+
* esbuild's VFS plugin to mark the import `external`, and workerd's LOADER
|
|
21
|
+
* has no entry for that specifier. 20 MB covers esbuild-wasm with headroom
|
|
22
|
+
* while keeping per-facet peak heap bounded for the streaming extractor.
|
|
23
|
+
*/
|
|
24
|
+
export const MAX_FILE_BYTES = 20_000_000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read one tar header (USTAR) out of `block`. Returns parsed fields or
|
|
28
|
+
* `null` for an end-of-archive block (all zeros).
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Collapse "."/".." segments in a tar entry's package-relative path.
|
|
32
|
+
* Returns the canonical relative path, or '' when the entry escapes its
|
|
33
|
+
* package root (a leading ".." that pops above the root) — the caller
|
|
34
|
+
* treats '' as a no-name entry and skips it. Mirrors the segment logic in
|
|
35
|
+
* w7-frame's canonicalPath so joined write paths are always accepted.
|
|
36
|
+
*/
|
|
37
|
+
export function canonicalTarName(name: string): string {
|
|
38
|
+
const out: string[] = [];
|
|
39
|
+
for (const seg of name.split('/')) {
|
|
40
|
+
if (seg === '..') {
|
|
41
|
+
if (out.length === 0) return '';
|
|
42
|
+
out.pop();
|
|
43
|
+
} else if (seg !== '' && seg !== '.') {
|
|
44
|
+
out.push(seg);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out.join('/');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseTarHeader(block: Uint8Array): { name: string; size: number; typeFlag: number } | null {
|
|
51
|
+
if (block[0] === 0) return null;
|
|
52
|
+
|
|
53
|
+
let name = '';
|
|
54
|
+
for (let i = 0; i < 100 && block[i] !== 0; i++) {
|
|
55
|
+
name += String.fromCharCode(block[i]);
|
|
56
|
+
}
|
|
57
|
+
let prefix = '';
|
|
58
|
+
for (let i = 345; i < 500 && block[i] !== 0; i++) {
|
|
59
|
+
prefix += String.fromCharCode(block[i]);
|
|
60
|
+
}
|
|
61
|
+
if (prefix) name = prefix + '/' + name;
|
|
62
|
+
// Strip the npm `package/` convention.
|
|
63
|
+
name = name.replace(/^package\//, '');
|
|
64
|
+
// Canonicalize the entry-relative path. npm tarballs legitimately carry
|
|
65
|
+
// entries like "./dist/index.js" (agent-base, http-proxy-agent,
|
|
66
|
+
// protobufjs, ...); left as-is the "./" survives into the VFS write path
|
|
67
|
+
// and the w7-frame writer rejects the noncanonical path, failing the
|
|
68
|
+
// whole shared install wave and dropping shard-mates' completion markers.
|
|
69
|
+
// Collapsing here (the one place entry names are assembled) keeps every
|
|
70
|
+
// downstream join canonical. An entry that escapes its package root via
|
|
71
|
+
// ".." is dropped to '' → skipped as a no-name entry.
|
|
72
|
+
name = canonicalTarName(name);
|
|
73
|
+
|
|
74
|
+
let sizeStr = '';
|
|
75
|
+
for (let i = 124; i < 136 && block[i] !== 0; i++) {
|
|
76
|
+
sizeStr += String.fromCharCode(block[i]);
|
|
77
|
+
}
|
|
78
|
+
const size = parseInt(sizeStr.trim(), 8) || 0;
|
|
79
|
+
const typeFlag = block[156];
|
|
80
|
+
return { name, size, typeFlag };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Wrap a `ReadableStream<Uint8Array>` as an async iterable. Workerd and
|
|
85
|
+
* Node both support `Symbol.asyncIterator` on ReadableStream, but we
|
|
86
|
+
* spell the reader loop out so we don't depend on ambient lib typings.
|
|
87
|
+
*/
|
|
88
|
+
export async function* readableStreamToAsyncIterable(
|
|
89
|
+
rs: ReadableStream<Uint8Array>,
|
|
90
|
+
): AsyncGenerator<Uint8Array, void, undefined> {
|
|
91
|
+
const reader = rs.getReader();
|
|
92
|
+
try {
|
|
93
|
+
while (true) {
|
|
94
|
+
const { value, done } = await reader.read();
|
|
95
|
+
if (done) return;
|
|
96
|
+
if (value && value.length > 0) yield value;
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
try { reader.releaseLock(); } catch { /* ignore */ }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Reason a tar entry was skipped and never yielded. Surfaced to callers
|
|
105
|
+
* via the optional `onSkip` callback on `streamTarEntries`.
|
|
106
|
+
*
|
|
107
|
+
* - 'too-large': size > MAX_FILE_BYTES. The most common reason Nimbus
|
|
108
|
+
* cares about — it's what caused esbuild-wasm/esbuild.wasm to vanish
|
|
109
|
+
* silently in Nimbus-in-Nimbus before the cap was raised.
|
|
110
|
+
* - 'non-regular': typeFlag indicates a symlink / hardlink / directory /
|
|
111
|
+
* PaxHeader / GNU LongName etc. These aren't files we stage.
|
|
112
|
+
* - 'no-name': header parsed but name was empty (malformed or a PaxHeader
|
|
113
|
+
* that our parser didn't recognize as non-regular).
|
|
114
|
+
*/
|
|
115
|
+
export type TarSkipReason = 'too-large' | 'non-regular' | 'no-name';
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Optional skip-observer passed to `streamTarEntries`. Called ONCE per
|
|
119
|
+
* skipped entry, with the declared name (may be empty for 'no-name'
|
|
120
|
+
* skips) and the declared size in bytes.
|
|
121
|
+
*
|
|
122
|
+
* Consumers typically push these into a per-package warnings array so
|
|
123
|
+
* users see what wasn't installed. The callback is synchronous and
|
|
124
|
+
* must not throw — thrown errors are swallowed to keep the extractor
|
|
125
|
+
* best-effort.
|
|
126
|
+
*/
|
|
127
|
+
export type TarSkipCallback = (
|
|
128
|
+
name: string,
|
|
129
|
+
size: number,
|
|
130
|
+
reason: TarSkipReason,
|
|
131
|
+
) => void;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Streaming tar extractor.
|
|
135
|
+
*
|
|
136
|
+
* Consumes an async iterable of Uint8Array chunks (the decompressed tar
|
|
137
|
+
* byte stream) and yields one `{ name, data }` entry per regular file,
|
|
138
|
+
* as each file completes.
|
|
139
|
+
*
|
|
140
|
+
* Memory invariant: holds at most one pending file's bytes (≤ MAX_FILE_BYTES)
|
|
141
|
+
* plus a small carry buffer for the tar header being assembled.
|
|
142
|
+
*
|
|
143
|
+
* Skips: symlinks, directories, hardlinks, long-name extensions (PaxHeader),
|
|
144
|
+
* and any file whose declared size exceeds MAX_FILE_BYTES.
|
|
145
|
+
*
|
|
146
|
+
* If `onSkip` is provided, it is invoked for each skipped entry with the
|
|
147
|
+
* name, declared size, and reason code. Callers that need to surface
|
|
148
|
+
* dropped-file warnings to users should pass one; legacy callers that
|
|
149
|
+
* omit the arg still behave exactly as before (silent skip).
|
|
150
|
+
*/
|
|
151
|
+
export async function* streamTarEntries(
|
|
152
|
+
source: AsyncIterable<Uint8Array>,
|
|
153
|
+
onSkip?: TarSkipCallback,
|
|
154
|
+
): AsyncGenerator<{ name: string; data: Uint8Array }, void, undefined> {
|
|
155
|
+
let carry: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
|
|
156
|
+
|
|
157
|
+
type State =
|
|
158
|
+
| { kind: 'header' }
|
|
159
|
+
| { kind: 'file'; name: string; remaining: number; fileBuf: Uint8Array; fileOffset: number; pad: number; skip: boolean }
|
|
160
|
+
| { kind: 'skip'; remaining: number };
|
|
161
|
+
let state: State = { kind: 'header' };
|
|
162
|
+
|
|
163
|
+
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
164
|
+
if (a.length === 0) return b;
|
|
165
|
+
if (b.length === 0) return a;
|
|
166
|
+
const out = new Uint8Array(a.length + b.length);
|
|
167
|
+
out.set(a, 0);
|
|
168
|
+
out.set(b, a.length);
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for await (const chunkRaw of source) {
|
|
173
|
+
let buf = concat(carry, chunkRaw);
|
|
174
|
+
let cursor = 0;
|
|
175
|
+
|
|
176
|
+
while (true) {
|
|
177
|
+
if (state.kind === 'header') {
|
|
178
|
+
if (buf.length - cursor < 512) break;
|
|
179
|
+
const header = buf.subarray(cursor, cursor + 512);
|
|
180
|
+
const parsed = parseTarHeader(header);
|
|
181
|
+
cursor += 512;
|
|
182
|
+
if (!parsed) return; // end-of-archive
|
|
183
|
+
const { name, size, typeFlag } = parsed;
|
|
184
|
+
const pad = size === 0 ? 0 : (512 - (size % 512)) % 512;
|
|
185
|
+
const isRegularFile = (typeFlag === 48 /* '0' */ || typeFlag === 0);
|
|
186
|
+
if (size === 0) {
|
|
187
|
+
if (isRegularFile && name) {
|
|
188
|
+
yield { name, data: new Uint8Array(0) };
|
|
189
|
+
}
|
|
190
|
+
state = { kind: 'header' };
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (!isRegularFile || !name || size > MAX_FILE_BYTES) {
|
|
194
|
+
if (onSkip) {
|
|
195
|
+
// Classify before entering the skip state so the reason is
|
|
196
|
+
// exact. Precedence matches the condition order above:
|
|
197
|
+
// non-regular first (directories/symlinks/PaxHeaders are
|
|
198
|
+
// skipped regardless of size), then no-name, then too-large.
|
|
199
|
+
const reason: TarSkipReason = !isRegularFile
|
|
200
|
+
? 'non-regular'
|
|
201
|
+
: !name
|
|
202
|
+
? 'no-name'
|
|
203
|
+
: 'too-large';
|
|
204
|
+
try { onSkip(name, size, reason); } catch { /* best-effort */ }
|
|
205
|
+
}
|
|
206
|
+
state = { kind: 'skip', remaining: size + pad };
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
state = {
|
|
210
|
+
kind: 'file',
|
|
211
|
+
name,
|
|
212
|
+
remaining: size,
|
|
213
|
+
fileBuf: new Uint8Array(size),
|
|
214
|
+
fileOffset: 0,
|
|
215
|
+
pad,
|
|
216
|
+
skip: false,
|
|
217
|
+
};
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (state.kind === 'file') {
|
|
222
|
+
const avail = buf.length - cursor;
|
|
223
|
+
if (avail === 0) break;
|
|
224
|
+
if (state.remaining > 0) {
|
|
225
|
+
const take = Math.min(state.remaining, avail);
|
|
226
|
+
state.fileBuf.set(buf.subarray(cursor, cursor + take), state.fileOffset);
|
|
227
|
+
state.fileOffset += take;
|
|
228
|
+
state.remaining -= take;
|
|
229
|
+
cursor += take;
|
|
230
|
+
if (state.remaining > 0) break;
|
|
231
|
+
}
|
|
232
|
+
if (state.pad > 0) {
|
|
233
|
+
const avail2 = buf.length - cursor;
|
|
234
|
+
if (avail2 === 0) break;
|
|
235
|
+
const take = Math.min(state.pad, avail2);
|
|
236
|
+
state.pad -= take;
|
|
237
|
+
cursor += take;
|
|
238
|
+
if (state.pad > 0) break;
|
|
239
|
+
}
|
|
240
|
+
yield { name: state.name, data: state.fileBuf };
|
|
241
|
+
state = { kind: 'header' };
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// state.kind === 'skip'
|
|
246
|
+
const avail = buf.length - cursor;
|
|
247
|
+
if (avail === 0) break;
|
|
248
|
+
const take = Math.min(state.remaining, avail);
|
|
249
|
+
cursor += take;
|
|
250
|
+
state.remaining -= take;
|
|
251
|
+
if (state.remaining > 0) break;
|
|
252
|
+
state = { kind: 'header' };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (cursor >= buf.length) {
|
|
256
|
+
carry = new Uint8Array(0);
|
|
257
|
+
} else if (cursor === 0) {
|
|
258
|
+
carry = buf;
|
|
259
|
+
} else {
|
|
260
|
+
carry = buf.slice(cursor);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|