Compare commits
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git",
|
||||
"sourceCommit": "b5db4f65b59842ba912cb89d11237d0ce428ea95",
|
||||
"sourceRepo": "https://gitea.internal.quixos.org/quixos/quixos.git",
|
||||
"sourceCommit": "99248f90aba2ef64f089244a909159d432efb8af",
|
||||
"sourcePath": "quixos-instance/quixos-nix-helpers",
|
||||
"exportName": "quixos-nix-helpers",
|
||||
"mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git"
|
||||
"mirrorRemote": "https://repos.quixos.org/quixos-toolchain/quixos-nix-helpers.git"
|
||||
}
|
||||
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,193 @@
|
||||
# Checked capability and component artifacts
|
||||
|
||||
This is the **current helper/output format**, not the core package abstraction.
|
||||
See [the architecture](../../docs/ARCHITECTURE.md) and
|
||||
[current gaps](../../docs/ARCHITECTURE_STATUS.md). In particular, the universal
|
||||
artifact inventory and component-only omission of a server are implementation
|
||||
gaps to resolve, not requirements for arbitrary package servers.
|
||||
|
||||
`mkCaminoArtifacts` is the fresh package builder. It regenerates candidate
|
||||
bindings, typechecks the entry's dependency closure, bundles declared targets,
|
||||
generates a process service when requested, and emits content manifests and
|
||||
receipts. It has no migration step or package-defined shell hooks.
|
||||
|
||||
```nix
|
||||
# Inside a per-system flake output:
|
||||
quixosPackages.checkedArtifacts =
|
||||
{ buildContract, sourceDigest, generator, core, sharedRuntime, typescript, packageRevisionId, ... }:
|
||||
helpers.mkCaminoArtifacts {
|
||||
inherit pkgs buildContract sourceDigest core sharedRuntime typescript packageRevisionId;
|
||||
protocol = generator;
|
||||
src = ./.;
|
||||
portable = {
|
||||
entry = "src/portable.ts";
|
||||
bindingOutput = "src/gen/qx.ts";
|
||||
registryExport = "registry";
|
||||
targets = [ "browser" "server" ];
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
`buildContract` is a canonical, source-free capability slice. `sourceDigest`
|
||||
records the exact selected package source in the final installation manifest;
|
||||
it does not enter the reusable compilation group. Ordinary source dependencies,
|
||||
SDKs and React props companions remain explicit Nix inputs. Raw QX and resource
|
||||
lock files enter through the checked contract and evaluated recipe, and are
|
||||
excluded from the implementation source tree. The `plan` argument on the low-level
|
||||
helper is a composition convenience for fixtures; it is projected before building.
|
||||
Package recipes receive the contract directly and can be checked without a workspace.
|
||||
|
||||
Inside `checkedArtifacts = context: ...`, the package helpers expose
|
||||
`camino.packageSet { inherit nixpkgs system context; }` as the usual package set
|
||||
selection. It shares the workspace composition's evaluated Nixpkgs when the
|
||||
source content, system, and default import arguments match. Pass ordinary Nixpkgs
|
||||
arguments such as `config` or `overlays` to use a separate import. A direct
|
||||
`import nixpkgs { inherit system; }` also works, with additional evaluation cost.
|
||||
The implementation groups depend on the complete execution compiler bundle and
|
||||
JavaScript build drivers by content; unrelated checker CLI and publication
|
||||
metadata changes do not enter those inputs.
|
||||
|
||||
The handler entry exports `registry = createRegistry(handlers)` from the generated
|
||||
bindings. One registry contains many exports. The builder always supplies a server
|
||||
implementation for the generated process service. A service-only package instead
|
||||
omits `portable` and supplies `service.entry`, an ordinary JavaScript process entry
|
||||
using the versioned stdin/stdout protocol. Custom services own their lifecycle and
|
||||
may hold external resources. This first helper supports JavaScript custom service
|
||||
entries; other languages can emit the same checked artifact contract independently.
|
||||
|
||||
Additional portable source dependencies can be provided as a locked `nodeModules` directory.
|
||||
The workspace supplies the exact SDK and compiler; packages cannot replace those
|
||||
with their own versions. Authored source symlinks and non-bundled portable imports
|
||||
other than the SDK and server Node builtins are rejected. This is build closure
|
||||
checking, not a purity proof or security sandbox.
|
||||
|
||||
`mkCaminoYarnDependencies` installs the locked dependency recipe and exports its
|
||||
installed Node dependency tree, including native package results and executable
|
||||
links. Generated Yarn install/linker state stays outside runtime outputs. Yarn
|
||||
describes its install-state file as an optimization cache in its
|
||||
[configuration reference](https://yarnpkg.com/configuration/yarnrc#installStatePath).
|
||||
This helper output is a dependency directory for a build, not a standalone
|
||||
Package or a runnable Yarn project. Its `devShell` supplies freestanding Yarn for
|
||||
editable projects; do not run Yarn against the readonly dependency output.
|
||||
Installation discards its disposable archive cache and streams dependencies into
|
||||
the output while retiring staged files, including across separate build/store
|
||||
mounts. It preserves package layout, native results, hidden data and executable
|
||||
links without holding two complete dependency trees. The maintained
|
||||
`camino-yarn-dependencies` check covers transfer and runtime layout; consuming
|
||||
Studio and Package-builder checks retain real locked-installation coverage.
|
||||
|
||||
Use the shared `installDependencies` assembly for consumers: authored libraries
|
||||
are copied into the consumer's `node_modules`, where Node can find their
|
||||
transitive dependencies, while selected SDK links retain their canonical type
|
||||
identity. Reserved SDK substitutions fail checks. Web Studio uses this same
|
||||
assembly rather than retaining a full Yarn project just to resolve UI libraries.
|
||||
Its dependency inputs contain manifests, lock and Yarn configuration/plugin
|
||||
recipe; ordinary Studio source edits do not enter that dependency derivation.
|
||||
Recipes with local/workspace dependencies must still include their needed source.
|
||||
This is language-specific build machinery; it adds no core Package file contract.
|
||||
|
||||
The core/Automerge bundle and Replicache bundle are built once. Browser modules
|
||||
import the core through a content-addressed gateway URL, independent of package
|
||||
identity. Each manifest covers the shared bytes it uses. The gateway retains its
|
||||
workspace authentication and explicit file registry; it never interprets an HTTP
|
||||
path as a Nix store path. Applications cannot import Replicache through the SDK.
|
||||
|
||||
`checked-artifacts.nix` evaluates a package's `checkedArtifacts` output without
|
||||
activation. During the checked Nix build, `loadCheckedArtifactWorld` verifies manifest bytes, the selected
|
||||
generator identity, and exact regenerated binding sources. Receipts are consistency
|
||||
evidence from a trusted Nix build, not cryptographic attestations of remote builds.
|
||||
The build stores the checked plan, kernel/query programs, their digests and original
|
||||
compiler provenance in its immutable candidate output. Runtime loading validates
|
||||
that output's identities, artifact contents and supported runtime contracts. It
|
||||
does not regenerate bindings or programs with the currently running compiler.
|
||||
Nix owns rebuilding and cache reuse when a toolchain input changes.
|
||||
|
||||
The root `camino-artifact-builds` check exercises actual reader/service outputs,
|
||||
module and process execution, browser shared imports, rejection recovery, and
|
||||
altered receipt rejection at the build boundary. The managed workspace host loads
|
||||
the resulting checked candidate before activation. No adapter pretends this ABI
|
||||
works in the old server dispatcher.
|
||||
|
||||
Component packages add `components = { inherit (react) sdk runtime; entries; }`.
|
||||
The ordinary execution candidate checker supplies the selected React build inputs
|
||||
when the package declares components. Each entry maps a component ID to
|
||||
`{ entry = "src/card.tsx"; export = "Card"; }`. See the
|
||||
[React SDK](../camino-react/README.md) for bindings and hooks. CSS is emitted as a
|
||||
checked artifact; local image/font imports are inlined. The helper rejects
|
||||
undeclared external assets and substituted React/client dependencies.
|
||||
Component-only packages produce no backend service.
|
||||
|
||||
`camino-component-artifacts` checks bad subject/SDK/dependency/asset inputs and
|
||||
proves that a component-only edit preserves a mixed package's service digest.
|
||||
Run `nix run .#camino-component-source-qualification` from the monorepo to
|
||||
materialize separate retained Git resources and compile an ordinary Nix candidate.
|
||||
This is a source/build qualification harness; it does not deploy a workspace.
|
||||
|
||||
## Restricted execution in a workspace VM
|
||||
|
||||
Add `service.restricted = true` to a checked portable package to select the pinned
|
||||
`workerd` executor. Generated bindings and the ordinary transaction-port protocol
|
||||
stay the same. All server exports must belong to one checked portable registry;
|
||||
remote exports and custom process services retain their ordinary VM execution
|
||||
path. The restricted build rejects Node imports and unresolved dependencies,
|
||||
and supplies an explicit SDK for value codecs, identities and declared graph ports.
|
||||
|
||||
```nix
|
||||
service = {
|
||||
restricted = true;
|
||||
limits = {
|
||||
deadlineMs = 10000;
|
||||
maxBytes = 1048576;
|
||||
maxPortCalls = 1000;
|
||||
maxQueued = 8;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Each invocation gets a fresh Worker in a reusable engine process. An engine runs
|
||||
one active frame; the adapter has bounded concurrency and queueing. Queue wait
|
||||
consumes the deadline. Overflow is an explicit
|
||||
`RESTRICTED_QUEUE_FULL` result. Cancellation kills the engine process group and
|
||||
removes its sockets before acknowledgement. All graph calls inherit the trusted
|
||||
invocation identity; authored code cannot choose another frame or workspace.
|
||||
Native graph grants and transaction commit remain enforced by Camino outside
|
||||
the guest. Initialization and synchronous loops are subject to the same deadline.
|
||||
|
||||
The default root/Package Invocation wall deadline is 10 seconds. The separate
|
||||
root CPU allowance remains at most 4 CPU seconds; extra wall time accommodates
|
||||
queueing and graph-port waits. Ordinary graph database transactions and idle
|
||||
transactions allow 30 seconds, and ordinary client HTTP requests allow 45 seconds
|
||||
so server failures can be returned. Nested calls inherit the remaining root
|
||||
budget; explicit smaller limits still apply. Restore/activation jobs retain their
|
||||
separate budgets. These defaults are starting limits, not latency targets.
|
||||
|
||||
Direct network access, native filesystem imports, ambient clock and entropy,
|
||||
parent environment credentials, and unlisted modules are denied. Explicit dates,
|
||||
`Intl.Segmenter`, deterministic hashes and normal JavaScript computation remain
|
||||
available. Host-managed HTTP bridges expose only the current frame's declared
|
||||
graph ports. Authored serialization is untrusted protocol input.
|
||||
|
||||
The packaged runtime uses the platform's Bubblewrap/seccomp launcher. Only the
|
||||
engine's read-only runtime closure and private broker directory are mounted;
|
||||
native filesystem/process/network escape probes qualify that boundary. Systemd
|
||||
limits the whole package in a workspace VM. Shared authority additionally charges
|
||||
each engine to its own cgroup before launch. No per-isolate memory protection or
|
||||
Cloudflare hosted side-channel defenses are claimed. See the
|
||||
[runtime decision](../../docs/RESTRICTED_RUNTIME_DECISION.md#os-process-confinement-implementation)
|
||||
for the exact boundary and residual risks. A guest user can control its VM, so
|
||||
authoritative graph grants and credentials must remain outside it.
|
||||
|
||||
Qualification commands:
|
||||
|
||||
```sh
|
||||
nix build .#checks.x86_64-linux.camino-restricted-execution
|
||||
nix build .#checks.x86_64-linux.camino-restricted-package-runners
|
||||
nix build .#checks.x86_64-linux.camino-restricted-execution-vm
|
||||
```
|
||||
|
||||
The first uses the actual pinned engine for frame isolation, graph ports,
|
||||
SDK codecs, denied ambient access, cancellation and overload. The second runs
|
||||
ordinary package dispatch, rollback, PostgreSQL and browser replica tests with
|
||||
the restricted reader fixture. The VM check adds actual cgroup allocation
|
||||
exhaustion, neighboring-service survival, native socket egress rejection and
|
||||
fresh-frame timing. VM qualification is independent of hosted rollout.
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
# Fresh artifact builder. Candidate plans and runtime dependencies are supplied by the workspace compiler.
|
||||
{
|
||||
pkgs,
|
||||
src,
|
||||
plan ? null,
|
||||
buildContract ?
|
||||
(import ./package-context.nix {
|
||||
inherit
|
||||
pkgs
|
||||
protocol
|
||||
plan
|
||||
packageRevisionId
|
||||
;
|
||||
}).buildContract,
|
||||
sourceDigest ?
|
||||
(import ./package-context.nix {
|
||||
inherit
|
||||
pkgs
|
||||
protocol
|
||||
plan
|
||||
packageRevisionId
|
||||
;
|
||||
}).sourceDigest,
|
||||
protocol,
|
||||
core,
|
||||
sharedRuntime,
|
||||
typescript ? core.dependencies,
|
||||
packageRevisionId,
|
||||
portable ? null,
|
||||
remote ? null,
|
||||
service ? { },
|
||||
nodeModules ? null,
|
||||
components ? null,
|
||||
group ? false,
|
||||
}:
|
||||
let
|
||||
buildTools = import ./build-tools.nix;
|
||||
# The standalone bundle is the complete code dependency of this driver.
|
||||
# Changes to unrelated checker CLIs must not invalidate every package group.
|
||||
compiler = builtins.path {
|
||||
path = "${protocol}/libexec/quixos-protocol/execution-world.mjs";
|
||||
name = "execution-world.mjs";
|
||||
};
|
||||
config = pkgs.writeText "camino-artifact-build.json" (
|
||||
builtins.toJSON {
|
||||
inherit
|
||||
buildContract
|
||||
compiler
|
||||
packageRevisionId
|
||||
core
|
||||
sharedRuntime
|
||||
nodeModules
|
||||
service
|
||||
;
|
||||
group = true;
|
||||
components =
|
||||
if components == null then
|
||||
null
|
||||
else
|
||||
{
|
||||
inherit (components) sdk runtime entries;
|
||||
dependencies = components.sdk.dependencies;
|
||||
bindingOutput = components.bindingOutput or "src/gen/client.ts";
|
||||
contracts = components.contracts or [ ];
|
||||
};
|
||||
source = import ./implementation-source.nix {
|
||||
inherit (pkgs) lib;
|
||||
inherit src;
|
||||
};
|
||||
portable =
|
||||
if portable == null then
|
||||
null
|
||||
else
|
||||
{
|
||||
inherit (portable) entry;
|
||||
entries = portable.entries or { };
|
||||
registryExport = portable.registryExport or "registry";
|
||||
bindingOutput = portable.bindingOutput or "src/gen/qx.ts";
|
||||
targets =
|
||||
portable.targets or [
|
||||
"browser"
|
||||
"server"
|
||||
];
|
||||
};
|
||||
remote =
|
||||
if remote == null then
|
||||
null
|
||||
else
|
||||
{
|
||||
inherit (remote) entry;
|
||||
registryExport = remote.registryExport or "registry";
|
||||
bindingOutput = remote.bindingOutput or "src/gen/remote.ts";
|
||||
};
|
||||
node = "${pkgs.nodejs_24}/bin/node";
|
||||
tsc = "${typescript}/node_modules/typescript/bin/tsc";
|
||||
esbuild = "${pkgs.esbuild}/bin/esbuild";
|
||||
shell = "${pkgs.runtimeShell}";
|
||||
serviceAdapter = ./portable-service.mjs;
|
||||
restrictedSDK = "${(import ./restricted-execution { inherit pkgs core; }).sdk}/core.mjs";
|
||||
}
|
||||
);
|
||||
compiled =
|
||||
pkgs.runCommand "camino-compiled-artifacts"
|
||||
{
|
||||
passthru.buildConfig = config;
|
||||
QUIXOS_TRACE_PROFILE = "1";
|
||||
}
|
||||
''
|
||||
${pkgs.nodejs_24}/bin/node ${buildTools}/build-artifacts.mjs ${config}
|
||||
'';
|
||||
in
|
||||
if group then
|
||||
compiled
|
||||
else
|
||||
(import ./assemble-package.nix {
|
||||
inherit pkgs;
|
||||
artifacts = [ compiled ];
|
||||
context = {
|
||||
inherit
|
||||
buildContract
|
||||
sourceDigest
|
||||
packageRevisionId
|
||||
sharedRuntime
|
||||
;
|
||||
generator = protocol;
|
||||
};
|
||||
})
|
||||
// {
|
||||
caminoGroup = compiled;
|
||||
buildConfig = config;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { buildService } from "./build-service.mjs";
|
||||
|
||||
const config = JSON.parse(await fs.readFile(process.argv[2], "utf8"));
|
||||
const compiler = await import(pathToFileURL(config.compiler));
|
||||
const contract = JSON.parse(await fs.readFile(config.buildContract, "utf8"));
|
||||
compiler.checkPackageBuildContract(contract);
|
||||
const pkg = contract.definition.package;
|
||||
if (pkg.revision !== config.packageRevisionId) throw Error("Invalid package assembly context");
|
||||
const output = process.env.out;
|
||||
if (!output) throw Error("Package assembly requires an output directory");
|
||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const generatorDigest = digest(await fs.readFile(config.compiler));
|
||||
const implementations = [],
|
||||
components = [],
|
||||
files = new Map(),
|
||||
ids = new Set();
|
||||
const recordFile = async (name, mediaType) => {
|
||||
const bytes = await fs.readFile(path.join(output, compiler.artifactPath(name)));
|
||||
return { path: name, bytes: bytes.length, digest: digest(bytes), mediaType };
|
||||
};
|
||||
await fs.mkdir(path.join(output, "share/quixos/generated"), { recursive: true });
|
||||
for (const root of config.groups) {
|
||||
const manifest = compiler.parseArtifactGroup(
|
||||
JSON.parse(await fs.readFile(path.join(root, "share/quixos/package-artifacts.json"), "utf8")),
|
||||
);
|
||||
if (
|
||||
manifest.packageRevisionId !== pkg.revision ||
|
||||
manifest.bindingDigest !== contract.digest ||
|
||||
JSON.stringify(manifest.versions) !== JSON.stringify(contract.definition.versions)
|
||||
)
|
||||
throw Error("Artifact group belongs to a different checked package");
|
||||
const receipt = JSON.parse(await fs.readFile(path.join(root, "share/quixos/build-receipt.json"), "utf8"));
|
||||
const expected = compiler.expectedGroupReceipt(contract, manifest, generatorDigest);
|
||||
if (JSON.stringify(receipt) !== JSON.stringify(expected.receipt))
|
||||
throw Error("Artifact group has an invalid build receipt");
|
||||
const read = await compiler.artifactFileReader({ [pkg.revision]: root });
|
||||
for (const artifact of [...manifest.implementations, ...(manifest.components ?? [])]) {
|
||||
const key = `${artifact.compatibility === "org.quixos.react.esm/1" ? "module" : "implementation"}:${artifact.id}`;
|
||||
if (ids.has(key)) throw Error("Duplicate artifact group export " + key);
|
||||
ids.add(key);
|
||||
for (const file of artifact.files) {
|
||||
const bytes = await read(pkg.revision, file.path, file.bytes);
|
||||
if (bytes.length !== file.bytes || digest(bytes) !== file.digest)
|
||||
throw Error("Modified group asset " + file.path);
|
||||
const previous = files.get(file.path);
|
||||
if (previous && JSON.stringify(previous) !== JSON.stringify(file))
|
||||
throw Error("Conflicting group asset " + file.path);
|
||||
if (!previous) {
|
||||
await fs.mkdir(path.dirname(path.join(output, file.path)), { recursive: true });
|
||||
await fs.writeFile(path.join(output, file.path), bytes, { mode: file.path.startsWith("bin/") ? 0o755 : 0o644 });
|
||||
files.set(file.path, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [name, bytes] of expected.sources) {
|
||||
const actual = await fs.readFile(path.join(root, name));
|
||||
if (!Buffer.from(bytes).equals(actual)) throw Error("Modified group type witness " + name);
|
||||
await fs.writeFile(path.join(output, name), bytes);
|
||||
}
|
||||
implementations.push(...manifest.implementations);
|
||||
components.push(...(manifest.components ?? []));
|
||||
}
|
||||
if (implementations.filter((entry) => entry.compatibility === "quixos-service-v1").length > 1)
|
||||
throw Error("A package can have only one process service owner");
|
||||
if (!implementations.some((entry) => entry.compatibility === "quixos-service-v1"))
|
||||
await buildService({
|
||||
config: { ...config, service: {} },
|
||||
compiler,
|
||||
pkg,
|
||||
output,
|
||||
coreServer: path.join(config.sharedRuntime, "core.mjs"),
|
||||
sharedFiles: [...files.values()].filter((file) => file.path.startsWith("share/quixos/shared/")),
|
||||
implementations,
|
||||
recordFile,
|
||||
});
|
||||
const manifest = compiler.parsePackageArtifacts({
|
||||
schemaVersion: 1,
|
||||
packageRevisionId: pkg.revision,
|
||||
bindingDigest: contract.digest,
|
||||
sourceDigest: config.sourceDigest,
|
||||
versions: contract.definition.versions,
|
||||
implementations,
|
||||
...(components.length ? { components } : {}),
|
||||
});
|
||||
const { receipt, sources } = compiler.expectedPackageReceipt(contract, manifest, config.sourceDigest, generatorDigest);
|
||||
for (const [name, bytes] of sources) {
|
||||
const actual = await fs.readFile(path.join(output, name));
|
||||
if (!Buffer.from(bytes).equals(actual)) throw Error("Assembly lacks checked generated source " + name);
|
||||
}
|
||||
await fs.writeFile(path.join(output, "share/quixos/package-artifacts.json"), JSON.stringify(manifest, null, 2));
|
||||
await fs.writeFile(path.join(output, "share/quixos/build-receipt.json"), JSON.stringify(receipt, null, 2));
|
||||
// Source selection is attested here; compilation groups retain their own receipt.
|
||||
compiler.checkPackageCoverage({ ...pkg, bindingDigest: contract.digest, sourceDigest: config.sourceDigest }, manifest);
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
pkgs,
|
||||
context,
|
||||
artifacts,
|
||||
}:
|
||||
let
|
||||
buildTools = import ./build-tools.nix;
|
||||
groups = map (artifact: artifact.caminoGroup or artifact) artifacts;
|
||||
compiler = builtins.path {
|
||||
path = "${context.generator}/libexec/quixos-protocol/execution-world.mjs";
|
||||
name = "execution-world.mjs";
|
||||
};
|
||||
config = pkgs.writeText "camino-package-assembly.json" (
|
||||
builtins.toJSON {
|
||||
inherit groups;
|
||||
inherit (context)
|
||||
buildContract
|
||||
sourceDigest
|
||||
packageRevisionId
|
||||
sharedRuntime
|
||||
;
|
||||
inherit compiler;
|
||||
node = "${pkgs.nodejs_24}/bin/node";
|
||||
esbuild = "${pkgs.esbuild}/bin/esbuild";
|
||||
shell = "${pkgs.runtimeShell}";
|
||||
serviceAdapter = ./portable-service.mjs;
|
||||
}
|
||||
);
|
||||
in
|
||||
pkgs.runCommand "camino-package"
|
||||
{
|
||||
passthru = {
|
||||
caminoGroups = groups;
|
||||
assemblyConfig = config;
|
||||
};
|
||||
}
|
||||
''
|
||||
${pkgs.nodejs_24}/bin/node ${buildTools}/assemble-package.mjs ${config}
|
||||
''
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
|
||||
// Resolve build dependencies from the package's locked node_modules, not from
|
||||
// the helper's own Nix-store location. This program runs only during the build.
|
||||
const require = createRequire(path.join(process.cwd(), "package.json"));
|
||||
const { build } = require("esbuild");
|
||||
const options = JSON.parse(process.argv[2]);
|
||||
const platform = new Map([
|
||||
["react", "/__quixos/platform/react/v18.mjs"],
|
||||
["react/jsx-runtime", "/__quixos/platform/react-jsx-runtime/v18.mjs"],
|
||||
["react/jsx-dev-runtime", "/__quixos/platform/react-jsx-dev-runtime/v18.mjs"],
|
||||
["@quixos/web-studio-react-runtime", "/__quixos/platform/web-studio-react-runtime/v1.mjs"],
|
||||
]);
|
||||
await build({
|
||||
...options,
|
||||
plugins: [
|
||||
{
|
||||
name: "quixos-browser-source",
|
||||
setup(api) {
|
||||
api.onResolve({ filter: /\?browser-source$/ }, async ({ path: specifier, resolveDir }) => {
|
||||
if (!specifier.startsWith("./") && !specifier.startsWith("../"))
|
||||
throw new Error("Browser source imports must name a relative compiled module");
|
||||
const resolved = await api.resolve(specifier.slice(0, -"?browser-source".length), {
|
||||
resolveDir,
|
||||
kind: "import-statement",
|
||||
});
|
||||
if (resolved.errors.length) return { errors: resolved.errors };
|
||||
return { path: resolved.path, namespace: "browser-source" };
|
||||
});
|
||||
api.onLoad({ filter: /.*/, namespace: "browser-source" }, async ({ path: entry }) => {
|
||||
const browser = await build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
write: false,
|
||||
outfile: "component.mjs",
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: "es2022",
|
||||
metafile: true,
|
||||
plugins: [
|
||||
{
|
||||
name: "quixos-platform",
|
||||
setup(browserApi) {
|
||||
browserApi.onResolve({ filter: /.*/ }, ({ path: name }) =>
|
||||
platform.has(name) ? { path: platform.get(name), external: true } : undefined,
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const js = browser.outputFiles.find((file) => file.path.endsWith(".mjs"));
|
||||
if (!js) throw new Error("Browser source build produced no JavaScript");
|
||||
const css = browser.outputFiles.find((file) => file.path.endsWith(".css"));
|
||||
if (css && Object.values(browser.metafile.outputs).some((output) => output.exports?.includes("styles")))
|
||||
throw new Error("Use either imported CSS or an explicit styles export, not both");
|
||||
if (browser.outputFiles.some((file) => !file.path.endsWith(".mjs") && !file.path.endsWith(".css")))
|
||||
throw new Error("Browser assets must be embedded, not emitted as unserved files");
|
||||
const source = js.text + (css ? `\nexport const styles = ${JSON.stringify(css.text)};\n` : "");
|
||||
return {
|
||||
contents: `export default ${JSON.stringify(source)};`,
|
||||
loader: "js",
|
||||
watchFiles: Object.keys(browser.metafile.inputs),
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import { installDependencies } from "./install-dependencies.mjs";
|
||||
import { measureBuild } from "./build-profile.mjs";
|
||||
import { buildService } from "./build-service.mjs";
|
||||
import { buildComponents } from "./build-components.mjs";
|
||||
/** Trusted Nix build driver. No package-defined build hooks run between generation and witnessing. */
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { builtinModules } from "node:module";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
const config = JSON.parse(await fs.readFile(process.argv[2], "utf8"));
|
||||
const compiler = await import(pathToFileURL(config.compiler));
|
||||
const contract = JSON.parse(await fs.readFile(config.buildContract, "utf8"));
|
||||
compiler.checkPackageBuildContract(contract);
|
||||
const pkg = contract.definition.package;
|
||||
if (pkg.revision !== config.packageRevisionId) throw Error("Invalid build contract package");
|
||||
const output = process.env.out;
|
||||
if (!output) throw Error("Missing Nix output");
|
||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const generatorDigest = digest(await fs.readFile(config.compiler));
|
||||
const runtime =
|
||||
config.portable || config.remote
|
||||
? JSON.parse(await fs.readFile(path.join(config.sharedRuntime, "runtime.json"), "utf8"))
|
||||
: { files: [] };
|
||||
const relative = (p) => compiler.artifactPath(p);
|
||||
const implementations = [];
|
||||
const generated = new Map();
|
||||
const recordFile = async (p, mediaType) => {
|
||||
const bytes = await fs.readFile(path.join(output, relative(p)));
|
||||
return { path: p, digest: digest(bytes), bytes: bytes.length, mediaType };
|
||||
};
|
||||
const copyRuntime = async () => {
|
||||
await fs.mkdir(path.join(output, "share/quixos/shared"), { recursive: true });
|
||||
for (const file of runtime.files) {
|
||||
relative(file.path);
|
||||
const bytes = await fs.readFile(path.join(config.sharedRuntime, file.path));
|
||||
if (bytes.length !== file.bytes || digest(bytes) !== file.digest) throw Error("Shared runtime mismatch");
|
||||
await fs.writeFile(path.join(output, "share/quixos/shared", file.path), bytes);
|
||||
}
|
||||
};
|
||||
await copyRuntime();
|
||||
const sharedFiles = await Promise.all(
|
||||
runtime.files.map((f) => recordFile(`share/quixos/shared/${f.path}`, f.mediaType)),
|
||||
);
|
||||
const coreFile = sharedFiles.find((f) => f.path.endsWith("/core.mjs"));
|
||||
const coreURL = coreFile ? compiler.contentArtifactUrl(coreFile) : null;
|
||||
const coreServer = path.join(config.sharedRuntime, "core.mjs");
|
||||
await fs.mkdir(path.join(output, "share/quixos/generated"), { recursive: true });
|
||||
const portable = config.portable;
|
||||
for (const target of Object.keys(portable?.entries ?? {}))
|
||||
if (!["browser", "server"].includes(target)) throw Error(`Unsupported portable entry target ${target}`);
|
||||
for (const settings of [portable, config.remote].filter(Boolean))
|
||||
if (!/^[$A-Z_a-z][$\w]*$/.test(settings.registryExport))
|
||||
throw Error("Registry export must be a JavaScript identifier");
|
||||
const targets = portable ? [...new Set([...portable.targets, "server"])] : [];
|
||||
const builds = targets.map((target) => ({ target, mode: target, settings: portable }));
|
||||
if (config.remote) builds.push({ target: "server", mode: "remote", settings: config.remote });
|
||||
for (const { target, mode, settings } of builds) {
|
||||
const executionTarget = target === "browser" ? "target:org.quixos.execution.javascript@1" : "server";
|
||||
if (!["browser", "server"].includes(target)) throw Error(`Unsupported JS build target ${target}`);
|
||||
const work = path.resolve(`work-${mode}`);
|
||||
await fs.cp(config.source, work, { recursive: true, dereference: false });
|
||||
const writable = async (directory) => {
|
||||
await fs.chmod(directory, 0o755);
|
||||
for (const item of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, item.name);
|
||||
if (item.isSymbolicLink()) throw Error("Authored package source cannot contain symlinks");
|
||||
if (item.isDirectory()) await writable(file);
|
||||
else await fs.chmod(file, 0o644);
|
||||
}
|
||||
};
|
||||
await writable(work);
|
||||
const modules = path.join(work, "node_modules");
|
||||
await fs.mkdir(path.join(modules, "@quixos"), { recursive: true });
|
||||
await installDependencies({
|
||||
destination: modules,
|
||||
application: config.nodeModules,
|
||||
selected: { "@quixos/camino-replica-core": config.core },
|
||||
reserved: ["@quixos/camino-replica-core", "@quixos/camino-replica-client", "@quixos/camino-replica-engine"],
|
||||
});
|
||||
const packageFile = path.join(work, "package.json");
|
||||
const packageMetadata = await fs.readFile(packageFile, "utf8").then(JSON.parse, (error) => {
|
||||
if (error.code === "ENOENT") return {};
|
||||
throw error;
|
||||
});
|
||||
await fs.writeFile(packageFile, JSON.stringify({ ...packageMetadata, type: "module" }));
|
||||
const bindingPath = relative(settings.bindingOutput);
|
||||
const source =
|
||||
mode === "remote"
|
||||
? compiler.generateRemoteTypeScriptBindings(contract.definition.bindings, pkg.revision)
|
||||
: compiler.generatePortableTypeScriptBindings(contract.definition.bindings, pkg.revision, {
|
||||
target: executionTarget,
|
||||
});
|
||||
await fs.mkdir(path.dirname(path.join(work, bindingPath)), { recursive: true });
|
||||
await fs.writeFile(path.join(work, bindingPath), source);
|
||||
generated.set(mode, { file: path.join(work, bindingPath), source });
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
output,
|
||||
mode === "remote" ? "share/quixos/generated/remote.ts" : compiler.generatedBindingPath(executionTarget),
|
||||
),
|
||||
source,
|
||||
);
|
||||
const entry = relative(settings.entries?.[target] ?? settings.entry);
|
||||
measureBuild("portable-typecheck", { package: pkg.revision, target, mode }, () =>
|
||||
execFileSync(
|
||||
config.node,
|
||||
[
|
||||
config.tsc,
|
||||
"--noEmit",
|
||||
"--strict",
|
||||
"--skipLibCheck",
|
||||
"false",
|
||||
"--target",
|
||||
"es2024",
|
||||
"--lib",
|
||||
"es2024,dom,esnext.disposable",
|
||||
"--module",
|
||||
"nodenext",
|
||||
"--moduleResolution",
|
||||
"nodenext",
|
||||
entry,
|
||||
],
|
||||
{ cwd: work, stdio: "inherit" },
|
||||
),
|
||||
);
|
||||
const bundlePath =
|
||||
mode === "remote" ? "share/quixos/remote/server/module.mjs" : `share/quixos/portable/${target}/module.mjs`;
|
||||
await fs.mkdir(path.dirname(path.join(output, bundlePath)), { recursive: true });
|
||||
const coreImport = target === "browser" ? coreURL : coreServer;
|
||||
const metadata = path.join(work, "bundle-meta.json");
|
||||
measureBuild("portable-bundle", { package: pkg.revision, target, mode }, () =>
|
||||
execFileSync(
|
||||
config.esbuild,
|
||||
[
|
||||
entry,
|
||||
"--bundle",
|
||||
`--platform=${target === "browser" ? "browser" : "node"}`,
|
||||
"--format=esm",
|
||||
"--minify-whitespace",
|
||||
"--target=es2024",
|
||||
`--outfile=${path.join(output, bundlePath)}`,
|
||||
`--metafile=${metadata}`,
|
||||
`--alias:@quixos/camino-replica-core=${coreImport}`,
|
||||
`--external:${coreImport}`,
|
||||
],
|
||||
{ cwd: work, stdio: "inherit" },
|
||||
),
|
||||
);
|
||||
const meta = JSON.parse(await fs.readFile(metadata, "utf8"));
|
||||
const built = Object.values(meta.outputs).find((o) => o.entryPoint);
|
||||
if (!built?.exports.includes(settings.registryExport)) throw Error("Bundle lacks the declared registry export");
|
||||
// Portable modules must contain their ordinary source dependencies and share only the checked SDK.
|
||||
if (
|
||||
Object.values(meta.outputs).some((o) =>
|
||||
o.imports.some(
|
||||
(i) =>
|
||||
i.external &&
|
||||
i.path !== coreImport &&
|
||||
!(target === "server" && builtinModules.includes(i.path.replace(/^node:/, ""))),
|
||||
),
|
||||
)
|
||||
)
|
||||
throw Error("Unexpected external import in portable module");
|
||||
const exports = pkg.exports
|
||||
.filter((e) =>
|
||||
mode === "remote"
|
||||
? e.execution.kind === "remote"
|
||||
: e.execution.kind !== "remote" && e.execution.targets.includes(executionTarget),
|
||||
)
|
||||
.map((e) => e.id);
|
||||
const body = {
|
||||
id: `${mode}-module`,
|
||||
target: executionTarget,
|
||||
compatibility: "quixos-js-module-v1",
|
||||
path: bundlePath,
|
||||
ports: "quixos-transaction-ports-v1",
|
||||
registryExport: settings.registryExport,
|
||||
exports,
|
||||
files: [await recordFile(bundlePath, "text/javascript"), ...sharedFiles],
|
||||
};
|
||||
implementations.push({ ...body, contentDigest: compiler.implementationDigest(body) });
|
||||
if (target === "server" && mode !== "remote" && exports.length) {
|
||||
// A closed candidate is not an eligibility claim. The host checks these
|
||||
// bytes and its exact qualified SDK before admitting it to the sandbox.
|
||||
// Keep a normal service implementation for unsupported dependencies.
|
||||
const restrictedPath = "share/quixos/restricted/module.mjs";
|
||||
const sdkPath = "share/quixos/restricted/core.mjs";
|
||||
const sdkName = "quixos:restricted-sdk/v1";
|
||||
await fs.mkdir(path.dirname(path.join(output, restrictedPath)), { recursive: true });
|
||||
await fs.copyFile(config.restrictedSDK, path.join(output, sdkPath));
|
||||
measureBuild("restricted-bundle", { package: pkg.revision }, () =>
|
||||
execFileSync(
|
||||
config.esbuild,
|
||||
[
|
||||
entry,
|
||||
"--bundle",
|
||||
"--platform=node",
|
||||
"--format=esm",
|
||||
"--minify-whitespace",
|
||||
"--target=es2024",
|
||||
`--outfile=${path.join(output, restrictedPath)}`,
|
||||
`--alias:@quixos/camino-replica-core=${sdkName}`,
|
||||
`--external:${sdkName}`,
|
||||
],
|
||||
{ cwd: work, stdio: "inherit" },
|
||||
),
|
||||
);
|
||||
const restricted = {
|
||||
id: "restricted-module",
|
||||
target: "server",
|
||||
compatibility: "quixos-restricted-module-v1",
|
||||
path: restrictedPath,
|
||||
ports: "quixos-transaction-ports-v1",
|
||||
registryExport: settings.registryExport,
|
||||
exports,
|
||||
files: [await recordFile(restrictedPath, "text/javascript"), await recordFile(sdkPath, "text/javascript")],
|
||||
};
|
||||
implementations.push({ ...restricted, contentDigest: compiler.implementationDigest(restricted) });
|
||||
}
|
||||
}
|
||||
const components =
|
||||
config.group && !config.components
|
||||
? []
|
||||
: await buildComponents({ config, compiler, contract, pkg, output, recordFile, generated });
|
||||
if (!config.group || config.service.entry)
|
||||
await buildService({ config, compiler, pkg, output, coreServer, sharedFiles, implementations, recordFile });
|
||||
|
||||
const manifest = compiler.parseArtifactGroup({
|
||||
schemaVersion: 1,
|
||||
packageRevisionId: pkg.revision,
|
||||
bindingDigest: contract.digest,
|
||||
versions: contract.definition.versions,
|
||||
implementations,
|
||||
...(components.length ? { components } : {}),
|
||||
});
|
||||
for (const { file, source } of generated.values())
|
||||
if ((await fs.readFile(file, "utf8")) !== source) throw Error("Generated bindings changed during build");
|
||||
const { receipt, sources } = compiler.expectedGroupReceipt(contract, manifest, generatorDigest);
|
||||
for (const [file, bytes] of sources)
|
||||
if (!Buffer.from(bytes).equals(await fs.readFile(path.join(output, file))))
|
||||
throw Error("Receipt generation differs from compiled bindings");
|
||||
await fs.writeFile(path.join(output, "share/quixos/package-artifacts.json"), JSON.stringify(manifest, null, 2));
|
||||
await fs.writeFile(path.join(output, "share/quixos/build-receipt.json"), JSON.stringify(receipt, null, 2));
|
||||
@@ -0,0 +1,264 @@
|
||||
import { installDependencies } from "./install-dependencies.mjs";
|
||||
import { checkComponentTypes } from "./component-types.mjs";
|
||||
import { measureBuild } from "./build-profile.mjs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
export async function buildComponents({ config, compiler, contract, pkg, output, recordFile, generated }) {
|
||||
const settings = config.components;
|
||||
if (!settings) {
|
||||
if (pkg.components?.length) throw Error("Declared components require checked component build settings");
|
||||
return [];
|
||||
}
|
||||
const declarations = config.group
|
||||
? (pkg.components ?? []).filter((entry) => Object.hasOwn(settings.entries, entry.id))
|
||||
: (pkg.components ?? []);
|
||||
const entries = Object.entries(settings.entries);
|
||||
if (
|
||||
entries.length !== declarations.length ||
|
||||
entries.some(([id]) => !declarations.some((component) => component.id === id))
|
||||
)
|
||||
throw Error("Component build entries differ from package declarations");
|
||||
const runtime = JSON.parse(await fs.readFile(path.join(settings.runtime, "runtime.json"), "utf8"));
|
||||
if (
|
||||
runtime.sdkCodeDigest !== digest(await fs.readFile(path.join(settings.sdk, "dist/index.js"))) ||
|
||||
runtime.sdkTypeDigest !== digest(await fs.readFile(path.join(settings.sdk, "dist/index.d.ts")))
|
||||
)
|
||||
throw Error("Component SDK differs from selected shared runtime");
|
||||
const sharedFiles = [];
|
||||
for (const file of runtime.files) {
|
||||
compiler.artifactPath(file.path);
|
||||
const bytes = await fs.readFile(path.join(settings.runtime, file.path));
|
||||
if (bytes.length !== file.bytes || digest(bytes) !== file.digest) throw Error("Modified component runtime");
|
||||
const target = "share/quixos/component-shared/" + file.path;
|
||||
await fs.mkdir(path.dirname(path.join(output, target)), { recursive: true });
|
||||
await fs.writeFile(path.join(output, target), bytes);
|
||||
sharedFiles.push(await recordFile(target, file.mediaType));
|
||||
}
|
||||
const sharedModules = Object.fromEntries(
|
||||
compiler.componentSharedModules.map((name) => {
|
||||
const file = sharedFiles.find((file) => file.path === "share/quixos/component-shared/" + runtime.modules[name]);
|
||||
if (!file) throw Error("Missing shared component module " + name);
|
||||
return [name, file.path];
|
||||
}),
|
||||
);
|
||||
const imports = Object.fromEntries(
|
||||
Object.entries(sharedModules).map(([name, file]) => [
|
||||
name,
|
||||
compiler.contentArtifactUrl(sharedFiles.find((value) => value.path === file)),
|
||||
]),
|
||||
);
|
||||
const core = runtime.files.find((file) => file.path === "core.mjs");
|
||||
const selected = JSON.parse(await fs.readFile(path.join(config.sharedRuntime, "runtime.json"), "utf8")).files.find(
|
||||
(file) => file.path === "core.mjs",
|
||||
);
|
||||
if (core?.digest !== selected?.digest) throw Error("Component runtime core differs from portable runtime core");
|
||||
const work = path.resolve("work-components");
|
||||
await fs.cp(config.source, work, { recursive: true, dereference: false });
|
||||
const writable = async (directory) => {
|
||||
await fs.chmod(directory, 0o755);
|
||||
for (const item of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, item.name);
|
||||
if (item.isSymbolicLink()) throw Error("Authored component source cannot contain symlinks");
|
||||
if (item.isDirectory()) await writable(file);
|
||||
else await fs.chmod(file, 0o644);
|
||||
}
|
||||
};
|
||||
await writable(work);
|
||||
const modules = path.join(work, "node_modules");
|
||||
await fs.mkdir(path.join(modules, "@quixos"), { recursive: true });
|
||||
await installDependencies({
|
||||
destination: modules,
|
||||
application: config.nodeModules,
|
||||
defaults: [path.join(settings.dependencies, "node_modules")],
|
||||
selected: { "@quixos/camino-react": settings.sdk },
|
||||
reserved: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"scheduler",
|
||||
"csstype",
|
||||
"@types/react",
|
||||
"@types/react-dom",
|
||||
"@types/prop-types",
|
||||
"@quixos/camino-react",
|
||||
"@quixos/camino-replica-core",
|
||||
"@quixos/camino-replica-client",
|
||||
"@quixos/camino-replica-engine",
|
||||
],
|
||||
});
|
||||
const packageFile = path.join(work, "package.json");
|
||||
const packageMetadata = await fs.readFile(packageFile, "utf8").then(JSON.parse, (error) => {
|
||||
if (error.code === "ENOENT") return {};
|
||||
throw error;
|
||||
});
|
||||
await fs.writeFile(packageFile, JSON.stringify({ ...packageMetadata, type: "module" }));
|
||||
// CommonJS dependencies may require React. An ESM bridge lets esbuild lower
|
||||
// that require while retaining the one host-owned runtime instance.
|
||||
const sharedAliases = [];
|
||||
for (const [name, url] of Object.entries(imports)) {
|
||||
const shim = path.join(work, "shared-" + digest(Buffer.from(name)).slice(0, 16) + ".mjs");
|
||||
await fs.writeFile(
|
||||
shim,
|
||||
`export * from ${JSON.stringify(url)};` +
|
||||
(["@quixos/camino-react", "@quixos/camino-react/ui"].includes(name)
|
||||
? ""
|
||||
: `export {default} from ${JSON.stringify(url)};`),
|
||||
);
|
||||
sharedAliases.push("--alias:" + name + "=" + shim, "--external:" + url);
|
||||
}
|
||||
const bindingPath = compiler.artifactPath(settings.bindingOutput),
|
||||
bindings = contract.definition.component,
|
||||
assets = compiler.componentAssetDeclarations();
|
||||
await fs.mkdir(path.dirname(path.join(work, bindingPath)), { recursive: true });
|
||||
await fs.writeFile(path.join(work, bindingPath), bindings);
|
||||
const assetPath = "component-assets.d.ts";
|
||||
await fs.writeFile(path.join(work, assetPath), assets);
|
||||
await fs.writeFile(path.join(output, "share/quixos/generated/client.ts"), bindings);
|
||||
await fs.writeFile(path.join(output, "share/quixos/generated/component-assets.d.ts"), assets);
|
||||
generated.set("component-client", { file: path.join(work, bindingPath), source: bindings });
|
||||
generated.set("component-assets", { file: path.join(work, assetPath), source: assets });
|
||||
// Public props are type-only inputs owned by imported interface resources.
|
||||
// Augment the generated nominal registry without importing an implementation.
|
||||
const propsImports = [],
|
||||
propsMembers = [],
|
||||
seenProps = new Set();
|
||||
for (const root of settings.contracts ?? []) {
|
||||
const companion = JSON.parse(await fs.readFile(path.join(root, "react-contracts.json"), "utf8"));
|
||||
if (companion.schemaVersion !== 1) throw Error("Unsupported React props companion");
|
||||
const contracts = contract.definition.moduleInterfaces.filter(
|
||||
(iface) =>
|
||||
iface.revisionId === companion.interfaceRevisionId || iface.definitionId === companion.interfaceRevisionId,
|
||||
);
|
||||
if (!contracts.length) continue;
|
||||
for (const [memberId, entry] of Object.entries(companion.members)) {
|
||||
if (!/^[$A-Z_a-z][$\w]*$/.test(entry.export)) throw Error("Invalid props type export");
|
||||
const alias = `Props${propsImports.length}`;
|
||||
propsImports.push(
|
||||
`import type {${entry.export} as ${alias}} from ${JSON.stringify(path.join(root, "types", compiler.artifactPath(entry.path)))};`,
|
||||
);
|
||||
for (const iface of contracts) {
|
||||
if (!iface.members.includes(memberId)) throw Error("Props companion names an unknown module member");
|
||||
const key = JSON.stringify([iface.revisionId, memberId]);
|
||||
if (seenProps.has(key)) throw Error("Duplicate React props companion for " + key);
|
||||
seenProps.add(key);
|
||||
propsMembers.push(`${JSON.stringify(key)}: ${alias};`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const propsPath = "component-props.d.ts";
|
||||
await fs.writeFile(
|
||||
path.join(work, propsPath),
|
||||
propsImports.join("\n") +
|
||||
`\nexport {};\ndeclare module ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))} { interface ModuleProps {${propsMembers.join("\n")}} }\n`,
|
||||
);
|
||||
const witnesses = [];
|
||||
for (const [id, entry] of entries) {
|
||||
const declaration = declarations.find((component) => component.id === id),
|
||||
source = compiler.artifactPath(entry.entry);
|
||||
if (!/^[$A-Z_a-z][$\w]*$/.test(entry.export)) throw Error("Invalid component export name");
|
||||
const key = digest(Buffer.from(id)).slice(0, 24);
|
||||
const witness = path.join(work, "component-check-" + key + ".tsx");
|
||||
const module = pkg.modules.find((module) => module.id === id);
|
||||
await fs.writeFile(
|
||||
witness,
|
||||
`import type {Modules} from ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))};import {${entry.export} as Component} from ${JSON.stringify("./" + source.replace(/\.[cm]?tsx?$/, ".js"))};const checked:Modules[${JSON.stringify(declaration.displayName)}]=Component;export default checked;\n` +
|
||||
(module.contract === "org.quixos.react.subject-only/1"
|
||||
? `import type {ComponentProps} from "react";declare const props:Pick<ComponentProps<Modules[${JSON.stringify(declaration.displayName)}]>,"subject"|"input">;const mount = <Component {...props}/>;\n`
|
||||
: ""),
|
||||
);
|
||||
witnesses.push(path.basename(witness));
|
||||
}
|
||||
checkComponentTypes({
|
||||
node: config.node,
|
||||
tsc: config.tsc,
|
||||
work,
|
||||
witnesses,
|
||||
assets: assetPath,
|
||||
props: propsPath,
|
||||
packageRevision: pkg.revision,
|
||||
});
|
||||
const components = [];
|
||||
const { checkControls } = await import(pathToFileURL(path.join(settings.sdk, "lint-controls.mjs")));
|
||||
const { default: ts } = await import(pathToFileURL(path.resolve(path.dirname(config.tsc), "../lib/typescript.js")));
|
||||
const checkedControls = new Set();
|
||||
for (const [id, entry] of entries) {
|
||||
const declaration = declarations.find((component) => component.id === id),
|
||||
source = compiler.artifactPath(entry.entry),
|
||||
key = digest(Buffer.from(id)).slice(0, 24),
|
||||
directory = "share/quixos/components/" + key;
|
||||
const metadata = path.join(work, "component-meta-" + key + ".json");
|
||||
const facade = "component-entry-" + key + ".ts";
|
||||
await fs.writeFile(
|
||||
path.join(work, facade),
|
||||
`export {${entry.export}} from ${JSON.stringify("./" + source.replace(/\.[cm]?tsx?$/, ".js"))};\n`,
|
||||
);
|
||||
await fs.mkdir(path.join(output, directory), { recursive: true });
|
||||
measureBuild("component-bundle", { package: pkg.revision, component: id }, () =>
|
||||
execFileSync(
|
||||
config.esbuild,
|
||||
[
|
||||
facade,
|
||||
"--bundle",
|
||||
"--platform=browser",
|
||||
"--format=esm",
|
||||
"--target=es2024",
|
||||
"--jsx=automatic",
|
||||
'--define:process.env.NODE_ENV="production"',
|
||||
"--metafile=" + metadata,
|
||||
"--outfile=" + path.join(output, directory, "module.mjs"),
|
||||
...sharedAliases,
|
||||
...["svg", "png", "jpg", "jpeg", "gif", "webp", "avif", "woff", "woff2", "ttf", "ico"].map(
|
||||
(extension) => "--loader:." + extension + "=dataurl",
|
||||
),
|
||||
],
|
||||
{ cwd: work, stdio: "inherit" },
|
||||
),
|
||||
);
|
||||
const meta = JSON.parse(await fs.readFile(metadata, "utf8")),
|
||||
outputs = Object.entries(meta.outputs);
|
||||
for (const file of Object.keys(meta.inputs)) {
|
||||
if (!/\.[cm]?[jt]sx?$/.test(file) || file.includes("node_modules/") || checkedControls.has(file)) continue;
|
||||
checkedControls.add(file);
|
||||
const errors = checkControls(ts, await fs.readFile(path.resolve(work, file), "utf8"), file);
|
||||
if (errors.length) throw Error(errors.join("\n"));
|
||||
}
|
||||
const main = outputs.find(([file]) => file.endsWith("/module.mjs"))?.[1];
|
||||
if (!main?.exports.includes(entry.export)) throw Error("Component bundle lacks declared export");
|
||||
if (
|
||||
Object.keys(meta.inputs).some((file) =>
|
||||
/camino-replica-engine|camino-replica-client|replicache|node_modules\/(?:react|react-dom|scheduler)(?:\/|$)/.test(
|
||||
file,
|
||||
),
|
||||
)
|
||||
)
|
||||
throw Error("Component bundled an internal engine/client or duplicate React");
|
||||
if (
|
||||
outputs.some(([, value]) =>
|
||||
value.imports.some((imported) => imported.external && !Object.values(imports).includes(imported.path)),
|
||||
)
|
||||
)
|
||||
throw Error("Component has an undeclared external module or asset");
|
||||
const files = [...sharedFiles];
|
||||
for (const [file] of outputs) {
|
||||
const absolute = path.resolve(work, file),
|
||||
relative = path.relative(output, absolute);
|
||||
compiler.artifactPath(relative);
|
||||
if (!relative.startsWith(directory + "/")) throw Error("Component output escaped declared artifact closure");
|
||||
files.push(await recordFile(relative, relative.endsWith(".css") ? "text/css" : "text/javascript"));
|
||||
}
|
||||
const body = {
|
||||
id,
|
||||
subject: declaration.subject,
|
||||
compatibility: "org.quixos.react.esm/1",
|
||||
path: directory + "/module.mjs",
|
||||
exportName: entry.export,
|
||||
sharedModules,
|
||||
files,
|
||||
};
|
||||
components.push({ ...body, contentDigest: compiler.componentArtifactDigest(body) });
|
||||
}
|
||||
return components;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Timing is log data, never part of an artifact or its witness. */
|
||||
export function measureBuild(phase, details, action) {
|
||||
const started = performance.now();
|
||||
try {
|
||||
return action();
|
||||
} finally {
|
||||
if (process.env.QUIXOS_TRACE_PROFILE === "1")
|
||||
console.error(
|
||||
`[qx-build-profile] ${JSON.stringify({ phase, ms: Math.round(performance.now() - started), ...details })}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { installDependencies } from "./install-dependencies.mjs";
|
||||
const config = JSON.parse(await fs.readFile(process.argv[2], "utf8"));
|
||||
const resource = JSON.parse(await fs.readFile(config.interface, "utf8"));
|
||||
if (resource.kind !== "interface") throw Error("React props belong to an interface resource");
|
||||
const iface = resource.revision;
|
||||
const output = process.env.out;
|
||||
if (!output) throw Error("Missing Nix output");
|
||||
const work = path.resolve("contracts-source");
|
||||
const declarations = [];
|
||||
await fs.cp(config.source, work, { recursive: true });
|
||||
const writable = async (directory) => {
|
||||
await fs.chmod(directory, 0o755);
|
||||
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isSymbolicLink()) throw Error("Contract source cannot contain symlinks");
|
||||
if (entry.isDirectory()) await writable(file);
|
||||
else {
|
||||
await fs.chmod(file, 0o644);
|
||||
if (/\.d\.[cm]?ts$/.test(file)) declarations.push(path.relative(work, file));
|
||||
}
|
||||
}
|
||||
};
|
||||
await writable(work);
|
||||
const packageFile = path.join(work, "package.json");
|
||||
const metadata = await fs.readFile(packageFile, "utf8").then(JSON.parse, (error) => {
|
||||
if (error.code === "ENOENT") return {};
|
||||
throw error;
|
||||
});
|
||||
await fs.writeFile(packageFile, JSON.stringify({ ...metadata, type: "module" }));
|
||||
const modules = path.join(output, "node_modules");
|
||||
await fs.mkdir(modules, { recursive: true });
|
||||
await installDependencies({
|
||||
destination: modules,
|
||||
application: config.dependencies,
|
||||
selected: config.reactSdk ? { "@quixos/camino-react": config.reactSdk } : {},
|
||||
reserved: ["@quixos/camino-react"],
|
||||
});
|
||||
await fs.symlink(modules, path.join(work, "node_modules"));
|
||||
const members = Object.create(null);
|
||||
let index = 0;
|
||||
for (const [id, entry] of Object.entries(config.members)) {
|
||||
const member = (iface.template?.members ?? iface.members).find((member) => member.id === id);
|
||||
if (
|
||||
member?.kind !== "module" ||
|
||||
!["org.quixos.react.component/1", "org.quixos.react.component/2", "org.quixos.react.subject-only/1"].includes(
|
||||
member.contract,
|
||||
)
|
||||
)
|
||||
throw Error("Props entry is not a React module member: " + id);
|
||||
if (
|
||||
!/^[$A-Z_a-z][$\w]*$/.test(entry.export) ||
|
||||
path.isAbsolute(entry.entry) ||
|
||||
entry.entry.split("/").some((part) => part === ".." || !part)
|
||||
)
|
||||
throw Error("Invalid props type entry");
|
||||
const filename = `props-${index++}.ts`;
|
||||
const typeImport = JSON.stringify("./" + entry.entry.replace(/\.[cm]?tsx?$/, ".js"));
|
||||
await fs.writeFile(
|
||||
path.join(work, filename),
|
||||
`import type {${entry.export} as Props} from ${typeImport};
|
||||
type Assert<T extends true> = T;
|
||||
type ObjectProps = Assert<Props extends object ? true : false>;
|
||||
type ReservedRendererProps = Assert<Extract<keyof Props, "subject" | "input" | "navigation" | "usingComponent" | "provider" | "coverage" | "componentCodeFallback"> extends never ? true : false>;
|
||||
${member.contract === "org.quixos.react.subject-only/1" ? "type SubjectOnly = Assert<{} extends Props ? true : false>;" : ""}
|
||||
export type {Props};\n`,
|
||||
);
|
||||
members[id] = { path: filename.replace(/\.ts$/, ".d.ts"), export: "Props" };
|
||||
}
|
||||
await fs.mkdir(path.join(output, "types"), { recursive: true });
|
||||
if (!Object.keys(members).length) throw Error("React type companion declares no module members");
|
||||
execFileSync(
|
||||
config.node,
|
||||
[
|
||||
config.tsc,
|
||||
"--declaration",
|
||||
"--emitDeclarationOnly",
|
||||
"--strict",
|
||||
"--target",
|
||||
"es2024",
|
||||
"--lib",
|
||||
"es2024,dom,dom.iterable,esnext.disposable",
|
||||
"--module",
|
||||
"nodenext",
|
||||
"--moduleResolution",
|
||||
"nodenext",
|
||||
"--rootDir",
|
||||
work,
|
||||
"--outDir",
|
||||
path.join(output, "types"),
|
||||
...Object.values(members).map((entry) => path.join(work, entry.path.replace(/\.d\.ts$/, ".ts"))),
|
||||
],
|
||||
{ cwd: work, stdio: "inherit" },
|
||||
);
|
||||
// TypeScript emits declarations for implementation sources, but does not copy
|
||||
// authored declarations which those emitted files may import.
|
||||
for (const file of declarations) {
|
||||
await fs.mkdir(path.dirname(path.join(output, "types", file)), { recursive: true });
|
||||
await fs.copyFile(path.join(work, file), path.join(output, "types", file));
|
||||
}
|
||||
await fs.writeFile(path.join(output, "package.json"), JSON.stringify({ type: "module" }));
|
||||
await fs.writeFile(
|
||||
path.join(output, "react-contracts.json"),
|
||||
JSON.stringify({ schemaVersion: 1, interfaceRevisionId: iface.revisionId, members }, null, 2),
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
export async function buildService({
|
||||
config,
|
||||
compiler,
|
||||
pkg,
|
||||
output,
|
||||
coreServer,
|
||||
sharedFiles,
|
||||
implementations,
|
||||
recordFile,
|
||||
}) {
|
||||
const relative = compiler.artifactPath;
|
||||
if (pkg.exports.length || config.service.entry) {
|
||||
await fs.mkdir(path.join(output, "bin"), { recursive: true });
|
||||
await fs.mkdir(path.join(output, "share/quixos/service"), { recursive: true });
|
||||
let serviceEntry;
|
||||
if (config.service.entry) {
|
||||
if (!/\.m?js$/.test(config.service.entry))
|
||||
throw Error("Custom service entry must be JavaScript; use a separate typed/language build before packaging");
|
||||
serviceEntry = path.join(config.source, relative(config.service.entry));
|
||||
} else {
|
||||
const modules = implementations.filter(
|
||||
(implementation) =>
|
||||
implementation.target === "server" && implementation.compatibility === "quixos-js-module-v1",
|
||||
);
|
||||
if (pkg.exports.some((entry) => !modules.some((module) => module.exports.includes(entry.id))))
|
||||
throw Error("Generated service does not cover every package export; provide a custom service");
|
||||
if (!modules.length) throw Error("Generated service requires a checked registry");
|
||||
serviceEntry = path.resolve("service-entry.mjs");
|
||||
const imports = modules
|
||||
.map(
|
||||
(module, index) =>
|
||||
`import { ${module.registryExport} as registry${index} } from ${JSON.stringify(path.join(output, module.path))};`,
|
||||
)
|
||||
.join("\n");
|
||||
await fs.writeFile(
|
||||
serviceEntry,
|
||||
imports +
|
||||
`\nimport {servePortableRegistry} from ${JSON.stringify(config.serviceAdapter)};
|
||||
const registries = [${modules.map((_, index) => "registry" + index).join(",")}];
|
||||
const owners = new Map();
|
||||
for (const registry of registries) for (const id of registry.exports) {
|
||||
if (owners.has(id)) throw Error("Duplicate registry export " + id);
|
||||
owners.set(id, registry);
|
||||
}
|
||||
servePortableRegistry({exports: [...owners.keys()], invoke(request, channel, context) {
|
||||
const registry = owners.get(request.exportId);
|
||||
if (!registry) throw Error("Unknown export");
|
||||
return registry.invoke(request, channel, context);
|
||||
}});\n`,
|
||||
);
|
||||
}
|
||||
const serviceModule = "share/quixos/service/main.mjs";
|
||||
execFileSync(
|
||||
config.esbuild,
|
||||
[
|
||||
serviceEntry,
|
||||
"--bundle",
|
||||
"--platform=node",
|
||||
"--format=esm",
|
||||
"--minify-whitespace",
|
||||
"--target=es2024",
|
||||
`--external:${coreServer}`,
|
||||
`--outfile=${path.join(output, serviceModule)}`,
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(output, "bin/service"),
|
||||
`#!${config.shell}\nservice_root=\${0%/*}/..\nexec ${config.node} "$service_root/${serviceModule}" "$@"\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const serviceBody = {
|
||||
id: "service",
|
||||
target: "server",
|
||||
compatibility: "quixos-service-v1",
|
||||
path: "bin/service",
|
||||
ports: "quixos-transaction-ports-v1",
|
||||
exports: pkg.exports.map((e) => e.id),
|
||||
files: [
|
||||
await recordFile("bin/service", "text/x-shellscript"),
|
||||
await recordFile(serviceModule, "text/javascript"),
|
||||
...sharedFiles,
|
||||
],
|
||||
};
|
||||
implementations.unshift({ ...serviceBody, contentDigest: compiler.implementationDigest(serviceBody) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Standalone build drivers import sibling JavaScript modules. Their code is an
|
||||
# input; Nix helper APIs and test sources do not belong to that dependency.
|
||||
builtins.path {
|
||||
path = ./.;
|
||||
name = "camino-build-tools";
|
||||
filter =
|
||||
filename: type:
|
||||
let
|
||||
name = builtins.baseNameOf filename;
|
||||
in
|
||||
(type == "directory" && filename == toString ./.)
|
||||
|| (
|
||||
builtins.dirOf filename == toString ./.
|
||||
&& builtins.match ".*\\.mjs" name != null
|
||||
&& builtins.match ".*\\.test\\.mjs" name == null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import fs from "node:fs";
|
||||
import crypto from "node:crypto";
|
||||
const [schemaPath, packageRevisionId, bindingOutput, generatorPath, output] = process.argv.slice(2);
|
||||
if (!schemaPath || !packageRevisionId || !bindingOutput || !generatorPath || !output)
|
||||
throw new Error("Missing candidate check receipt inputs");
|
||||
const canonical = (value) =>
|
||||
Array.isArray(value)
|
||||
? value.map(canonical)
|
||||
: value && typeof value === "object"
|
||||
? Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([key, entry]) => [key, canonical(entry)]),
|
||||
)
|
||||
: value;
|
||||
const hash = (value) =>
|
||||
`sha256:${crypto
|
||||
.createHash("sha256")
|
||||
.update(JSON.stringify(canonical(value)))
|
||||
.digest("hex")}`;
|
||||
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
|
||||
if (!schema.packages.some((entry) => entry.revisionId === packageRevisionId))
|
||||
throw new Error("Checked binding schema lacks the package");
|
||||
const generated = fs.readFileSync(bindingOutput, "utf8");
|
||||
if (generated !== fs.readFileSync(".qx-checked-bindings", "utf8"))
|
||||
throw new Error("Build replaced candidate-generated bindings; its check is not evidence for this candidate");
|
||||
const receipt = {
|
||||
schemaVersion: 1,
|
||||
packageRevisionId,
|
||||
success: true,
|
||||
bindingSchema: schema,
|
||||
bindingSchemaDigest: hash(schema),
|
||||
generatedDigest: hash(generated),
|
||||
checkerDigest: hash({
|
||||
generatorPath,
|
||||
compiler: JSON.parse(fs.readFileSync("node_modules/typescript/package.json", "utf8")),
|
||||
lock: fs.readFileSync("yarn.lock", "utf8"),
|
||||
}),
|
||||
};
|
||||
fs.writeFileSync(output, `${JSON.stringify(receipt, null, 2)}\n`, { flag: "wx" });
|
||||
@@ -0,0 +1,49 @@
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const [tsc, count, ...args] = process.argv.slice(2);
|
||||
const ts = createRequire(import.meta.url)(path.resolve(path.dirname(tsc), "../lib/typescript.js"));
|
||||
const parsed = ts.parseCommandLine(args);
|
||||
const witnesses = parsed.fileNames.slice(0, Number(count));
|
||||
const shared = parsed.fileNames.slice(Number(count));
|
||||
const host = ts.createCompilerHost(parsed.options, true);
|
||||
// These programs only discover source closures; they do not bind or check.
|
||||
// Sharing immutable parsed source files avoids reading/parsing the same libs
|
||||
// for every witness. Only the final combined program performs typechecking.
|
||||
const sources = new Map();
|
||||
const getSourceFile = host.getSourceFile;
|
||||
host.getSourceFile = (filename, ...options) => {
|
||||
if (!sources.has(filename)) sources.set(filename, getSourceFile(filename, ...options));
|
||||
return sources.get(filename);
|
||||
};
|
||||
for (const method of ["fileExists", "directoryExists", "getDirectories", "readFile", "realpath"]) {
|
||||
if (!host[method]) continue;
|
||||
const original = host[method],
|
||||
cache = new Map();
|
||||
host[method] = (filename) => {
|
||||
if (!cache.has(filename)) cache.set(filename, original(filename));
|
||||
return cache.get(filename);
|
||||
};
|
||||
}
|
||||
const closures = witnesses.map(
|
||||
(witness) => new Set(ts.createProgram([witness, ...shared], parsed.options, host).getSourceFiles()),
|
||||
);
|
||||
const common = new Set([...closures[0]].filter((source) => closures.every((closure) => closure.has(source))));
|
||||
const ambient = (node) =>
|
||||
ts.isModuleDeclaration(node) || ts.isNamespaceExportDeclaration(node) || ts.forEachChild(node, ambient);
|
||||
// A file that is visible to just some witnesses must not contribute globals
|
||||
// or ambient module declarations to the others. Otherwise a combined success
|
||||
// could hide an independent failure. Script files are conservatively unsafe.
|
||||
if (
|
||||
closures.some((closure) =>
|
||||
[...closure].some((source) => !common.has(source) && (!ts.isExternalModule(source) || ambient(source))),
|
||||
)
|
||||
)
|
||||
process.exit(2);
|
||||
|
||||
const program = ts.createProgram(parsed.fileNames, parsed.options, host);
|
||||
const diagnostics = [...parsed.errors, ...ts.getPreEmitDiagnostics(program)];
|
||||
if (diagnostics.length) {
|
||||
console.error(ts.formatDiagnosticsWithColorAndContext(diagnostics, host));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { measureBuild } from "./build-profile.mjs";
|
||||
|
||||
// Component witnesses share bindings and usually the same source graph.
|
||||
// Check a bounded batch once when distinct source closures cannot supply
|
||||
// ambient declarations to one another. Otherwise use the original checks.
|
||||
export function checkComponentTypes({ node, tsc, work, witnesses, assets, props, packageRevision }) {
|
||||
const checkTypes = (files, combined = false) =>
|
||||
execFileSync(
|
||||
node,
|
||||
[
|
||||
...(combined ? [fileURLToPath(new URL("./component-type-batch.mjs", import.meta.url))] : []),
|
||||
tsc,
|
||||
...(combined ? [String(files.length)] : []),
|
||||
"--noEmit",
|
||||
"--strict",
|
||||
"--skipLibCheck",
|
||||
"false",
|
||||
"--target",
|
||||
"es2024",
|
||||
"--lib",
|
||||
"es2024,dom,dom.iterable,esnext.disposable",
|
||||
"--module",
|
||||
"nodenext",
|
||||
"--moduleResolution",
|
||||
"nodenext",
|
||||
"--jsx",
|
||||
"react-jsx",
|
||||
...files,
|
||||
assets,
|
||||
props,
|
||||
],
|
||||
{ cwd: work, stdio: combined ? "pipe" : "inherit" },
|
||||
);
|
||||
measureBuild("component-typecheck", { package: packageRevision, components: witnesses.length }, () => {
|
||||
for (let start = 0; start < witnesses.length; start += 1000) {
|
||||
const batch = witnesses.slice(start, start + 1000);
|
||||
if (batch.length === 1) checkTypes(batch);
|
||||
else {
|
||||
try {
|
||||
checkTypes(batch, true);
|
||||
} catch (error) {
|
||||
if (!(error.status > 0) && error.code !== "ENOBUFS") throw error;
|
||||
console.error("Component batch requires independent witness checks");
|
||||
for (const witness of batch) checkTypes([witness]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { checkComponentTypes } from "./component-types.mjs";
|
||||
|
||||
const tsc = process.env.QUIXOS_TYPESCRIPT;
|
||||
if (!tsc) throw Error("Pass the selected TypeScript compiler path");
|
||||
async function fixture(context, left, right) {
|
||||
const work = await mkdtemp(path.join(os.tmpdir(), "component-types-"));
|
||||
context.after(() => rm(work, { recursive: true, force: true }));
|
||||
await Promise.all(
|
||||
Object.entries({ "left.ts": left, "right.ts": right, "assets.d.ts": "", "props.d.ts": "" }).map(([file, source]) =>
|
||||
writeFile(path.join(work, file), source),
|
||||
),
|
||||
);
|
||||
return {
|
||||
node: process.execPath,
|
||||
tsc,
|
||||
work,
|
||||
witnesses: ["left.ts", "right.ts"],
|
||||
assets: "assets.d.ts",
|
||||
props: "props.d.ts",
|
||||
packageRevision: "test",
|
||||
};
|
||||
}
|
||||
test("shared component typechecking accepts all valid witnesses", async (context) => {
|
||||
checkComponentTypes(await fixture(context, "export const left: string = 'ok';", "export const right: number = 1;"));
|
||||
});
|
||||
test("independently valid ambient declarations use the full individual check fallback", async (context) => {
|
||||
checkComponentTypes(
|
||||
await fixture(
|
||||
context,
|
||||
"export {}; declare global { interface Application { value: string; } }",
|
||||
"export {}; declare global { interface Application { value: number; } }",
|
||||
),
|
||||
);
|
||||
});
|
||||
test("a type error in any witness still rejects the artifact", async (context) => {
|
||||
const input = await fixture(context, "export const left: string = 'ok';", "export const right: string = 1;");
|
||||
assert.throws(() => checkComponentTypes(input));
|
||||
});
|
||||
test("one witness's globals cannot make an independently invalid witness pass", async (context) => {
|
||||
const input = await fixture(
|
||||
context,
|
||||
"export {}; declare global { interface Application { value: string; } }",
|
||||
"export const right: Application = {value: 'ok'};",
|
||||
);
|
||||
assert.throws(() => checkComponentTypes(input));
|
||||
});
|
||||
test("an ambient module from another source closure cannot resolve a missing import", async (context) => {
|
||||
const input = await fixture(
|
||||
context,
|
||||
"declare module 'missing-component-package' { export const value: string; }",
|
||||
"import {value} from 'missing-component-package'; export const right = value;",
|
||||
);
|
||||
assert.throws(() => checkComponentTypes(input));
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
# QX and recursive pins enter through the checked contract and build recipe.
|
||||
# The compiler receives authored implementation files, not resource provenance.
|
||||
{ lib, src }:
|
||||
builtins.path {
|
||||
path = src;
|
||||
name = "camino-implementation-source";
|
||||
filter =
|
||||
path: type:
|
||||
let
|
||||
name = builtins.baseNameOf path;
|
||||
in
|
||||
if type == "directory" then
|
||||
!(builtins.elem name [
|
||||
".git"
|
||||
".jj"
|
||||
"node_modules"
|
||||
])
|
||||
else
|
||||
!(builtins.elem name [
|
||||
"flake.nix"
|
||||
"flake.lock"
|
||||
"quixos.lock"
|
||||
"quixos.check.json"
|
||||
"quixos.react.json"
|
||||
"yarn-project.nix"
|
||||
"components.nix"
|
||||
"contracts.nix"
|
||||
])
|
||||
&& !(lib.hasSuffix ".qx" name);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
async function packages(directory) {
|
||||
const result = new Map();
|
||||
for (const entry of await fs.readdir(directory)) {
|
||||
if (entry.startsWith(".")) continue;
|
||||
if (entry.startsWith("@")) {
|
||||
for (const name of await fs.readdir(path.join(directory, entry)))
|
||||
result.set(`${entry}/${name}`, path.join(directory, entry, name));
|
||||
} else result.set(entry, path.join(directory, entry));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async function packageDigest(directory) {
|
||||
const digest = createHash("sha256");
|
||||
async function visit(relative) {
|
||||
for (const entry of (await fs.readdir(path.join(directory, relative), { withFileTypes: true })).sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
)) {
|
||||
if (entry.name === "node_modules") continue;
|
||||
const name = path.join(relative, entry.name),
|
||||
file = path.join(directory, name);
|
||||
if (entry.isDirectory()) await visit(name);
|
||||
else {
|
||||
const bytes = await fs.readFile(file);
|
||||
digest.update(JSON.stringify([name, bytes.length]));
|
||||
digest.update(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
await visit("");
|
||||
return digest.digest("hex");
|
||||
}
|
||||
/** Merge at package granularity: a scope such as @types is not a reserved package. */
|
||||
export async function installDependencies({ destination, application, defaults = [], selected = {}, reserved = [] }) {
|
||||
const entries = new Map();
|
||||
const authored = new Set();
|
||||
for (const root of defaults) for (const [name, file] of await packages(root)) entries.set(name, file);
|
||||
for (const [name, file] of Object.entries(selected)) entries.set(name, file);
|
||||
if (application)
|
||||
for (const [name, file] of await packages(application)) {
|
||||
if (reserved.includes(name)) {
|
||||
const canonical = entries.get(name);
|
||||
if (!canonical || (await packageDigest(file)) !== (await packageDigest(canonical)))
|
||||
throw Error("Application dependencies cannot substitute selected React/client types or checked SDK: " + name);
|
||||
} else {
|
||||
entries.set(name, file);
|
||||
authored.add(name);
|
||||
}
|
||||
}
|
||||
for (const [name, file] of entries) {
|
||||
await fs.mkdir(path.dirname(path.join(destination, name)), { recursive: true });
|
||||
// Application packages resolve siblings from this assembled node_modules.
|
||||
// Keep SDK links canonical: copying those would duplicate branded types and
|
||||
// React declarations from their own retained dependency closures.
|
||||
if (authored.has(name)) await fs.cp(file, path.join(destination, name), { recursive: true, dereference: true });
|
||||
else await fs.symlink(file, path.join(destination, name));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# The selected protocol owns semantic projection; builders receive its result.
|
||||
args@{ protocol, ... }: import "${protocol}/share/package-context.nix" args
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
pkgs,
|
||||
nixpkgs,
|
||||
system,
|
||||
}:
|
||||
let
|
||||
packageSet = (import ./quixos-package-helpers.nix).packageSet;
|
||||
shared = {
|
||||
marker = "shared";
|
||||
};
|
||||
environment = {
|
||||
inherit system;
|
||||
nixpkgsSource = toString nixpkgs;
|
||||
pkgs = shared;
|
||||
narHash = nixpkgs.narHash;
|
||||
};
|
||||
standard = {
|
||||
inherit nixpkgs system;
|
||||
context.buildEnvironment = environment;
|
||||
};
|
||||
fallback = packageSet { inherit nixpkgs system; };
|
||||
in
|
||||
assert packageSet standard == shared;
|
||||
assert
|
||||
packageSet (
|
||||
standard
|
||||
// {
|
||||
context.buildEnvironment = environment // {
|
||||
nixpkgsSource = "same-content-under-another-name";
|
||||
};
|
||||
}
|
||||
) == shared;
|
||||
assert
|
||||
(packageSet (
|
||||
standard
|
||||
// {
|
||||
context.buildEnvironment = environment // {
|
||||
system = "another-system";
|
||||
};
|
||||
}
|
||||
)).path == fallback.path;
|
||||
assert
|
||||
(packageSet (
|
||||
standard
|
||||
// {
|
||||
context.buildEnvironment = environment // {
|
||||
nixpkgsSource = "another-source";
|
||||
narHash = "another-hash";
|
||||
};
|
||||
}
|
||||
)).path == fallback.path;
|
||||
assert
|
||||
(packageSet (standard // { overlays = [ (_: _: { marker = "overlay"; }) ]; })).marker == "overlay";
|
||||
assert (packageSet (standard // { config.allowBroken = true; })).config.allowBroken;
|
||||
pkgs.runCommand "camino-package-set-check" { } ''
|
||||
touch "$out"
|
||||
''
|
||||
@@ -0,0 +1,171 @@
|
||||
/** Generated-service transport. The supervisor owns process isolation, deadlines and termination.
|
||||
* stdout is reserved for this protocol; application diagnostics belong on stderr. */
|
||||
export function servePortableRegistry(
|
||||
registry,
|
||||
{ maxBytes = 4 * 1024 * 1024, maxInvocations = 64, maxPortCalls = 10000 } = {},
|
||||
) {
|
||||
const abi = "quixos-transaction-ports-v1";
|
||||
const active = new Map();
|
||||
const pending = new Map();
|
||||
let buffer = Buffer.alloc(0),
|
||||
stopped = false;
|
||||
const send = (message) => {
|
||||
const encoded = JSON.stringify(message);
|
||||
if (Buffer.byteLength(encoded) > maxBytes) throw Error("SERVICE_MESSAGE_LIMIT");
|
||||
// A slow or absent coordinator must not cause an unbounded stdout queue.
|
||||
if (process.stdout.writableLength + Buffer.byteLength(encoded) > maxBytes * 2) throw Error("SERVICE_BACKPRESSURE");
|
||||
process.stdout.write(encoded + "\n");
|
||||
};
|
||||
const errorBody = (error) => ({
|
||||
code: typeof error?.code === "string" ? error.code.slice(0, 1024) : "PACKAGE_ERROR",
|
||||
message: String(error?.message ?? error).slice(0, 4096),
|
||||
});
|
||||
const rejectPorts = (id, reason) => {
|
||||
for (const [key, call] of pending)
|
||||
if (call.invocation === id) {
|
||||
pending.delete(key);
|
||||
call.reject(reason);
|
||||
}
|
||||
};
|
||||
const cancel = (id, reason) => {
|
||||
const token = active.get(id);
|
||||
if (token) {
|
||||
token.cancelled = true;
|
||||
token.controller.abort(reason);
|
||||
}
|
||||
rejectPorts(id, reason);
|
||||
};
|
||||
const fatal = (error) => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
for (const id of active.keys()) cancel(id, error);
|
||||
process.stderr.write(String(error?.message ?? error) + "\n");
|
||||
process.exitCode = 1;
|
||||
process.stdin.destroy();
|
||||
};
|
||||
const receive = (message) => {
|
||||
if (!message || message.abi !== abi) throw Error("SERVICE_ABI_MISMATCH");
|
||||
if (message.kind === "shutdown") {
|
||||
for (const id of active.keys()) cancel(id, Error("SERVICE_SHUTDOWN"));
|
||||
stopped = true;
|
||||
process.stdin.destroy();
|
||||
return;
|
||||
}
|
||||
if (message.kind === "cancel") {
|
||||
const token = active.get(message.invocation);
|
||||
if (token && token.frame !== message.frame) throw Error("INVALID_CANCELLATION_FRAME");
|
||||
cancel(message.invocation, Error("INVOCATION_CANCELLED"));
|
||||
// A completed result may already be in flight. Acknowledge without resurrecting its context.
|
||||
if (!token) send({ abi, kind: "cancelled", invocation: message.invocation, frame: message.frame });
|
||||
return;
|
||||
}
|
||||
if (message.kind === "port-result") {
|
||||
const key = JSON.stringify([message.invocation, message.sequence]);
|
||||
const call = pending.get(key);
|
||||
if (!call && (!active.has(message.invocation) || active.get(message.invocation).cancelled)) return;
|
||||
if (!call || message.frame !== call.frame) throw Error("UNEXPECTED_PORT_RESULT");
|
||||
pending.delete(key);
|
||||
if (message.status === "returned") call.resolve(message.output);
|
||||
else if (message.status === "failed")
|
||||
call.reject(
|
||||
Object.assign(Error(String(message.error?.message ?? "Port failed")), {
|
||||
code: String(message.error?.code ?? "PORT_FAILED"),
|
||||
}),
|
||||
);
|
||||
else throw Error("INVALID_PORT_RESULT");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
message.kind !== "invoke" ||
|
||||
typeof message.invocation !== "string" ||
|
||||
typeof message.frame !== "string" ||
|
||||
!message.invocation ||
|
||||
!message.frame ||
|
||||
message.invocation.length > 4096 ||
|
||||
message.frame.length > 4096
|
||||
)
|
||||
throw Error("INVALID_INVOCATION");
|
||||
if (active.has(message.invocation) || active.size >= maxInvocations) throw Error("INVOCATION_LIMIT_OR_DUPLICATE");
|
||||
const token = { frame: message.frame, controller: new AbortController(), cancelled: false };
|
||||
active.set(message.invocation, token);
|
||||
let sequence = 0;
|
||||
const channel = (request) => {
|
||||
if (active.get(message.invocation) !== token || token.cancelled)
|
||||
return Promise.reject(Error("EXPIRED_INVOCATION"));
|
||||
if (++sequence > maxPortCalls || pending.size >= maxPortCalls) return Promise.reject(Error("PORT_CALL_LIMIT"));
|
||||
const key = JSON.stringify([message.invocation, sequence]);
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(key, { invocation: message.invocation, frame: message.frame, resolve, reject });
|
||||
try {
|
||||
send({
|
||||
abi,
|
||||
kind: "port",
|
||||
frame: message.frame,
|
||||
invocation: message.invocation,
|
||||
sequence,
|
||||
portId: request.portId,
|
||||
action: { ...request.action, ...(request.key === undefined ? {} : { key: request.key }) },
|
||||
input: request.input,
|
||||
});
|
||||
} catch (error) {
|
||||
pending.delete(key);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
void registry
|
||||
.invoke(
|
||||
{ exportId: message.exportId, workspace: message.workspace, receiver: message.receiver, input: message.input },
|
||||
channel,
|
||||
Object.freeze({ signal: token.controller.signal, effect: message.effect }),
|
||||
)
|
||||
.then(
|
||||
(output) => {
|
||||
if (active.get(message.invocation) === token && !token.cancelled)
|
||||
send({ abi, invocation: message.invocation, status: "returned", output });
|
||||
},
|
||||
(error) => {
|
||||
if (active.get(message.invocation) === token && !token.cancelled)
|
||||
send({ abi, invocation: message.invocation, status: "failed", error: errorBody(error) });
|
||||
},
|
||||
)
|
||||
.catch(fatal)
|
||||
.finally(() => {
|
||||
if (active.get(message.invocation) === token) {
|
||||
active.delete(message.invocation);
|
||||
token.controller.abort(Error("INVOCATION_ENDED"));
|
||||
rejectPorts(message.invocation, Error("INVOCATION_ENDED"));
|
||||
if (token.cancelled && !stopped)
|
||||
send({ abi, kind: "cancelled", invocation: message.invocation, frame: token.frame });
|
||||
}
|
||||
})
|
||||
.catch(fatal);
|
||||
};
|
||||
process.stdin.on("data", (bytes) => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
// Consume each line before retaining the remainder; combined transport chunks may contain many frames.
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const newline = bytes.indexOf(10, offset);
|
||||
const end = newline < 0 ? bytes.length : newline;
|
||||
if (buffer.length + end - offset > maxBytes) throw Error("SERVICE_MESSAGE_LIMIT");
|
||||
buffer = Buffer.concat([buffer, bytes.subarray(offset, end)]);
|
||||
if (newline < 0) break;
|
||||
receive(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer)));
|
||||
buffer = Buffer.alloc(0);
|
||||
offset = newline + 1;
|
||||
if (stopped) break;
|
||||
}
|
||||
} catch (error) {
|
||||
fatal(error);
|
||||
}
|
||||
});
|
||||
process.stdin.on("end", () => {
|
||||
if (buffer.length) fatal(Error("TRUNCATED_SERVICE_MESSAGE"));
|
||||
for (const id of active.keys()) cancel(id, Error("COORDINATOR_DISCONNECTED"));
|
||||
});
|
||||
process.stdin.on("error", fatal);
|
||||
process.stdout.on("error", fatal);
|
||||
send({ abi, kind: "ready", exports: registry.exports });
|
||||
}
|
||||
+543
-332
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
pkgs,
|
||||
context,
|
||||
src,
|
||||
members,
|
||||
dependencies ? null,
|
||||
}:
|
||||
let
|
||||
buildTools = import ./build-tools.nix;
|
||||
config = pkgs.writeText "camino-react-contracts.json" (
|
||||
builtins.toJSON {
|
||||
source = import ./implementation-source.nix {
|
||||
inherit (pkgs) lib;
|
||||
inherit src;
|
||||
};
|
||||
interface = builtins.toFile "camino-interface-contract.json" (
|
||||
builtins.unsafeDiscardStringContext (
|
||||
builtins.toJSON (
|
||||
let
|
||||
resource = builtins.fromJSON (builtins.readFile context.interface);
|
||||
in
|
||||
{
|
||||
kind = resource.kind;
|
||||
revision = builtins.removeAttrs resource.revision [ "source" ];
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
inherit members dependencies;
|
||||
reactSdk = context.reactSdk or null;
|
||||
node = "${pkgs.nodejs_24}/bin/node";
|
||||
tsc = "${context.typescript}/node_modules/typescript/bin/tsc";
|
||||
}
|
||||
);
|
||||
in
|
||||
pkgs.runCommand "camino-react-contracts" { } ''
|
||||
${pkgs.nodejs_24}/bin/node ${buildTools}/build-react-contracts.mjs ${config}
|
||||
''
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
pkgs,
|
||||
context,
|
||||
src,
|
||||
modules,
|
||||
bindingOutput ? "src/gen/client.ts",
|
||||
dependencies ? null,
|
||||
}:
|
||||
let
|
||||
group = import ./artifacts.nix {
|
||||
inherit pkgs src;
|
||||
inherit (context)
|
||||
buildContract
|
||||
core
|
||||
sharedRuntime
|
||||
typescript
|
||||
packageRevisionId
|
||||
;
|
||||
protocol = context.generator;
|
||||
group = true;
|
||||
nodeModules = dependencies;
|
||||
components = {
|
||||
inherit bindingOutput;
|
||||
inherit (context.react) sdk runtime;
|
||||
entries = modules;
|
||||
contracts = context.react.contracts or [ ];
|
||||
};
|
||||
};
|
||||
package = import ./assemble-package.nix {
|
||||
inherit pkgs context;
|
||||
artifacts = [ group ];
|
||||
};
|
||||
in
|
||||
package // { caminoGroup = group; }
|
||||
@@ -0,0 +1,84 @@
|
||||
{ pkgs, core }:
|
||||
let
|
||||
workerd = import ./workerd.nix { inherit pkgs; };
|
||||
sandbox = import ./sandbox.nix {
|
||||
inherit pkgs;
|
||||
runtime = workerd;
|
||||
executable = "${workerd}/bin/workerd";
|
||||
};
|
||||
nativeProbe =
|
||||
pkgs.runCommand "quixos-restricted-native-probe"
|
||||
{
|
||||
nativeBuildInputs = [ pkgs.stdenv.cc ];
|
||||
}
|
||||
''
|
||||
mkdir -p "$out/bin"
|
||||
$CC -O2 -Wall -Wextra -Werror ${./sandbox-probe.c} -o "$out/bin/probe"
|
||||
'';
|
||||
nativeProbeSandbox = import ./sandbox.nix {
|
||||
inherit pkgs;
|
||||
runtime = nativeProbe;
|
||||
executable = "${nativeProbe}/bin/probe";
|
||||
};
|
||||
mkIdentityLauncher =
|
||||
settings: import ./identity-launcher.nix ({ inherit pkgs sandbox; } // settings);
|
||||
identityLauncher = mkIdentityLauncher { };
|
||||
# Transaction-port/value adapters do not need the browser's CRDT/Wasm engine.
|
||||
# Keep this surface explicit; it is bundled into checked restricted handlers.
|
||||
sdk = pkgs.runCommand "quixos-restricted-sdk" { nativeBuildInputs = [ pkgs.esbuild ]; } ''
|
||||
mkdir -p "$out"
|
||||
cat > entry.mjs <<'JS'
|
||||
export {definePortableRegistry,QxReject} from '${core}/dist/portable.js';
|
||||
export {encodeArgument,decodeArgument,canonicalJson,parseJson,hashJson} from '${core}/dist/value.js';
|
||||
export {workspaceId,operationId,objectId,entryId,hashIdentity} from '${core}/dist/identity.js';
|
||||
export {reference} from '${core}/dist/references.js';
|
||||
export {CodecError,ExecutionError} from '${core}/dist/limits.js';
|
||||
JS
|
||||
esbuild entry.mjs --bundle --platform=neutral --format=esm --target=es2024 --outfile="$out/core.mjs"
|
||||
'';
|
||||
runtime =
|
||||
pkgs.runCommand "quixos-restricted-execution" { nativeBuildInputs = [ pkgs.makeWrapper ]; }
|
||||
''
|
||||
mkdir -p "$out/lib" "$out/bin"
|
||||
cp ${./service.mjs} "$out/lib/service.mjs"
|
||||
cp ${../socket-address.mjs} "$out/lib/socket-address.mjs"
|
||||
substituteInPlace "$out/lib/service.mjs" --replace-fail '../socket-address.mjs' './socket-address.mjs'
|
||||
cp ${./worker.mjs} "$out/lib/worker.mjs"
|
||||
makeWrapper ${pkgs.nodejs_24}/bin/node "$out/bin/quixos-restricted-registry" \
|
||||
--add-flags "$out/lib/service.mjs" \
|
||||
--set QUIXOS_RESTRICTED_WORKERD ${workerd}/bin/workerd \
|
||||
--set QUIXOS_RESTRICTED_SANDBOX ${sandbox}/bin/quixos-restricted-sandbox \
|
||||
--set QUIXOS_RESTRICTED_WORKER "$out/lib/worker.mjs"
|
||||
'';
|
||||
qualificationSource = pkgs.lib.fileset.toSource {
|
||||
root = ../.;
|
||||
fileset = pkgs.lib.fileset.unions [
|
||||
(pkgs.lib.fileset.fileFilter (file: file.hasExt "mjs") ./.)
|
||||
../socket-address.mjs
|
||||
];
|
||||
};
|
||||
qualification =
|
||||
pkgs.runCommand "quixos-restricted-execution-qualification"
|
||||
{
|
||||
nativeBuildInputs = [ pkgs.nodejs_24 ];
|
||||
QUIXOS_TEST_WORKERD = "${workerd}/bin/workerd";
|
||||
QUIXOS_TEST_SDK = "${sdk}/core.mjs";
|
||||
}
|
||||
''
|
||||
node --test ${qualificationSource}/restricted-execution/service.test.mjs
|
||||
touch "$out"
|
||||
'';
|
||||
in
|
||||
{
|
||||
inherit
|
||||
workerd
|
||||
sandbox
|
||||
identityLauncher
|
||||
mkIdentityLauncher
|
||||
nativeProbeSandbox
|
||||
sdk
|
||||
runtime
|
||||
qualificationSource
|
||||
qualification
|
||||
;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
#define _GNU_SOURCE
|
||||
#include <dirent.h>
|
||||
#include <fcntl.h>
|
||||
#include <grp.h>
|
||||
#include <pwd.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/resource.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* Installed only as a narrowly executable NixOS wrapper. This trusted helper
|
||||
* accepts no executable, environment, filesystem root or arbitrary UID. The
|
||||
* caller is platform authority, never a workspace guest or authored Worker. */
|
||||
static void fail(const char *message) {
|
||||
fprintf(stderr, "RESTRICTED_IDENTITY: %s\n", message);
|
||||
exit(125);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
const char *root = ENGINE_DIRECTORY "/";
|
||||
uid_t caller = getuid();
|
||||
gid_t group = getgid();
|
||||
struct passwd *owner = getpwnam(AUTHORITY_USER);
|
||||
if (geteuid() != 0) fail("privileged wrapper unavailable");
|
||||
if (!owner) fail("authority identity unavailable");
|
||||
if (caller != owner->pw_uid) fail("caller identity mismatch");
|
||||
if (argc != 3) fail("invalid arguments");
|
||||
char *end;
|
||||
unsigned long number = strtoul(argv[2], &end, 10);
|
||||
if (!*argv[2] || *end || number < ENGINE_FIRST_UID || number >= ENGINE_FIRST_UID + ENGINE_IDENTITY_COUNT)
|
||||
fail("invalid engine identity");
|
||||
uid_t identity = (uid_t)number;
|
||||
if (strlen(argv[1]) != strlen(root) + 16 || strncmp(argv[1], root, strlen(root)))
|
||||
fail("invalid engine directory");
|
||||
const char *name = argv[1] + strlen(root);
|
||||
if (strncmp(name, "qx-engine-", 10) ||
|
||||
strlen(name) != 16 || strspn(name + 10, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") != 6)
|
||||
fail("invalid engine directory");
|
||||
int parent = open(ENGINE_DIRECTORY, O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
|
||||
struct stat info;
|
||||
if (parent < 0 || fstat(parent, &info) || info.st_uid != caller || (info.st_mode & 066))
|
||||
fail("invalid engine root");
|
||||
int directory = openat(parent, name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
|
||||
if (directory < 0 || fstat(directory, &info) || info.st_uid != caller || (info.st_mode & 077))
|
||||
fail("invalid engine owner");
|
||||
DIR *entries = fdopendir(dup(directory));
|
||||
if (!entries) fail("engine entries");
|
||||
struct dirent *entry;
|
||||
unsigned int files = 0;
|
||||
while ((entry = readdir(entries))) {
|
||||
const char *file = entry->d_name;
|
||||
if (!strcmp(file, ".") || !strcmp(file, "..")) continue;
|
||||
int socket = !strcmp(file, "broker.sock");
|
||||
if ((!socket && strcmp(file, "config.capnp") && strcmp(file, "parent.mjs")) ||
|
||||
fstatat(directory, file, &info, AT_SYMLINK_NOFOLLOW) || info.st_uid != caller ||
|
||||
info.st_nlink != 1 || (socket ? !S_ISSOCK(info.st_mode) : !S_ISREG(info.st_mode)) ||
|
||||
info.st_size > 4 * 1024 * 1024)
|
||||
fail("unexpected engine file");
|
||||
files++;
|
||||
}
|
||||
closedir(entries);
|
||||
if (files != 3) fail("incomplete engine configuration");
|
||||
const char *files_to_transfer[] = {"config.capnp", "parent.mjs", "broker.sock"};
|
||||
for (size_t i = 0; i < 3; i++) {
|
||||
if (fchownat(directory, files_to_transfer[i], identity, group, AT_SYMLINK_NOFOLLOW) ||
|
||||
fchmodat(directory, files_to_transfer[i], 0660, 0))
|
||||
fail("engine file ownership");
|
||||
}
|
||||
if (fchown(directory, identity, group) || fchmod(directory, 0770))
|
||||
fail("engine directory ownership");
|
||||
close(directory);
|
||||
close(parent);
|
||||
struct rlimit core = {0, 0};
|
||||
if (setrlimit(RLIMIT_CORE, &core) || setgroups(0, NULL) ||
|
||||
setresgid(identity, identity, identity) || setresuid(identity, identity, identity) ||
|
||||
prctl(PR_SET_DUMPABLE, 0) || prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0))
|
||||
fail("identity confinement");
|
||||
/* The containing directory grants only its owner and authority access. A
|
||||
* socket created by workerd must also admit authority's different UID/GID. */
|
||||
umask(0000);
|
||||
char *args[] = {SANDBOX_EXECUTABLE, argv[1], NULL};
|
||||
clearenv();
|
||||
execv(SANDBOX_EXECUTABLE, args);
|
||||
fail("sandbox launch");
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# Trusted host construction only. Authored Workers cannot choose the caller,
|
||||
# reserved identities, engine directory or sandbox executable.
|
||||
{
|
||||
pkgs,
|
||||
sandbox,
|
||||
authorityUser ? "quixos-graph",
|
||||
engineDirectory ? "/run/quixos-restricted-engines",
|
||||
firstUid ? 610000,
|
||||
identityCount ? 64,
|
||||
}:
|
||||
assert builtins.match "[a-z][a-z0-9_-]{0,63}" authorityUser != null;
|
||||
assert builtins.match "/run/[a-zA-Z0-9_/-]+" engineDirectory != null;
|
||||
assert !(pkgs.lib.hasInfix ".." engineDirectory);
|
||||
assert !(pkgs.lib.hasSuffix "/" engineDirectory);
|
||||
assert builtins.isInt firstUid && builtins.isInt identityCount;
|
||||
assert firstUid >= 610000 && firstUid + identityCount <= 2147483647;
|
||||
assert identityCount >= 1 && identityCount <= 64;
|
||||
pkgs.runCommand "quixos-restricted-identity-launcher"
|
||||
{
|
||||
nativeBuildInputs = [
|
||||
pkgs.stdenv.cc
|
||||
pkgs.nodejs_24
|
||||
];
|
||||
QUIXOS_RESTRICTED_ENGINE_DIRECTORY = engineDirectory;
|
||||
}
|
||||
''
|
||||
# Validate the complete generated filename with the shared UTF-8 policy,
|
||||
# before compiling or installing a launcher for this configured /run root.
|
||||
node --input-type=module <<'JS'
|
||||
import { checkedSocketPath } from '${../socket-address.mjs}';
|
||||
for (const filename of ["broker.sock", "engine.sock"])
|
||||
checkedSocketPath(process.env.QUIXOS_RESTRICTED_ENGINE_DIRECTORY + "/qx-engine-XXXXXX/" + filename);
|
||||
JS
|
||||
mkdir -p "$out/bin"
|
||||
$CC -O2 -Wall -Wextra -Werror ${./identity-launcher.c} \
|
||||
-DSANDBOX_EXECUTABLE='"${sandbox}/bin/quixos-restricted-sandbox"' \
|
||||
-DAUTHORITY_USER='"${authorityUser}"' \
|
||||
-DENGINE_DIRECTORY='"${engineDirectory}"' \
|
||||
-DENGINE_FIRST_UID=${toString firstUid} \
|
||||
-DENGINE_IDENTITY_COUNT=${toString identityCount} \
|
||||
-o "$out/bin/quixos-restricted-engine"
|
||||
''
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
executor,
|
||||
enable,
|
||||
controlGroup,
|
||||
authorityUser ? "quixos-graph",
|
||||
engineDirectory ? "/run/quixos-restricted-engines",
|
||||
firstUid ? 610000,
|
||||
identityCount ? 64,
|
||||
wrapperName ? "quixos-restricted-engine",
|
||||
}:
|
||||
{ config, lib, ... }:
|
||||
{
|
||||
config = lib.mkIf enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = lib.all (
|
||||
user: user.uid == null || user.uid < firstUid || user.uid >= firstUid + identityCount
|
||||
) (builtins.attrValues config.users.users);
|
||||
message = "UIDs ${toString firstUid}–${
|
||||
toString (firstUid + identityCount - 1)
|
||||
} are reserved for restricted engine identities.";
|
||||
}
|
||||
{
|
||||
assertion = lib.all (
|
||||
group: group.gid == null || group.gid < firstUid || group.gid >= firstUid + identityCount
|
||||
) (builtins.attrValues config.users.groups);
|
||||
message = "Restricted engine GIDs must not overlap host groups.";
|
||||
}
|
||||
];
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${engineDirectory} 0711 ${authorityUser} ${controlGroup} -"
|
||||
];
|
||||
security.wrappers.${wrapperName} = {
|
||||
source = "${
|
||||
executor.mkIdentityLauncher {
|
||||
inherit
|
||||
authorityUser
|
||||
engineDirectory
|
||||
firstUid
|
||||
identityCount
|
||||
;
|
||||
}
|
||||
}/bin/quixos-restricted-engine";
|
||||
owner = "root";
|
||||
group = controlGroup;
|
||||
permissions = "u+rx,g+rx,o-rwx";
|
||||
setuid = true;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sched.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/ptrace.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/un.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static void check(int condition, const char *label) {
|
||||
if (!condition) {
|
||||
fprintf(stderr, "ESCAPE: %s (%s)\n", label, strerror(errno));
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
int main(void) {
|
||||
for (int fd = 3; fd < 256; fd++)
|
||||
check(fcntl(fd, F_GETFD) < 0 && errno == EBADF, "inherited descriptor");
|
||||
check(!getenv("QUIXOS_VM_SECRET"), "parent environment");
|
||||
check(open("/etc/test-secret", O_RDONLY) < 0, "authority filesystem");
|
||||
check(open("/sys/fs/cgroup/cgroup.procs", O_WRONLY) < 0, "resource controls");
|
||||
check(open("/proc/1/root/etc/test-secret", O_RDONLY) < 0, "proc root escape");
|
||||
check(socket(AF_INET, SOCK_STREAM, 0) < 0 && errno == EPERM, "IPv4 socket");
|
||||
check(socket(AF_INET6, SOCK_STREAM, 0) < 0 && errno == EPERM, "IPv6 socket");
|
||||
check(socket(AF_NETLINK, SOCK_RAW, 0) < 0 && errno == EPERM, "netlink socket");
|
||||
check(ptrace(PTRACE_TRACEME, 0, NULL, NULL) < 0 && errno == EPERM, "ptrace");
|
||||
check(unshare(CLONE_NEWUSER) < 0, "nested user namespace");
|
||||
pid_t child = fork();
|
||||
if (child == 0) _exit(2);
|
||||
check(child < 0 && errno == EPERM, "subprocess");
|
||||
check(access("/bin/sh", X_OK) < 0, "ambient executable");
|
||||
check(getppid() <= 1, "host process visibility");
|
||||
struct sockaddr_un address = {.sun_family = AF_UNIX};
|
||||
int outside = socket(AF_UNIX, SOCK_STREAM, 0);
|
||||
check(outside >= 0, "Unix socket support");
|
||||
strcpy(address.sun_path, "/run/quixos-native-authority/test.sock");
|
||||
check(connect(outside, (struct sockaddr *)&address, sizeof(address)) < 0,
|
||||
"authority Unix socket");
|
||||
close(outside);
|
||||
outside = socket(AF_UNIX, SOCK_STREAM, 0);
|
||||
memset(address.sun_path, 0, sizeof(address.sun_path));
|
||||
strcpy(address.sun_path + 1, "quixos-native-authority");
|
||||
check(connect(outside, (struct sockaddr *)&address,
|
||||
offsetof(struct sockaddr_un, sun_path) + 1 + strlen(address.sun_path + 1)) < 0,
|
||||
"authority abstract socket");
|
||||
close(outside);
|
||||
char directory[4096];
|
||||
check(getcwd(directory, sizeof(directory)) != NULL, "engine directory");
|
||||
check(snprintf(address.sun_path, sizeof(address.sun_path), "%s/broker.sock", directory) <
|
||||
(int)sizeof(address.sun_path), "broker path length");
|
||||
int broker = socket(AF_UNIX, SOCK_STREAM, 0);
|
||||
check(broker >= 0 && connect(broker, (struct sockaddr *)&address, sizeof(address)) == 0,
|
||||
"authorized broker");
|
||||
check(write(broker, "sandbox-probe", 13) == 13, "broker write");
|
||||
close(broker);
|
||||
puts("RESTRICTED_NATIVE_SANDBOX_PASSED");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* Trusted, single-process admission gate. The supervisor charges this PID to
|
||||
* its engine cgroup before releasing stdin. All subsequently created namespace
|
||||
* helpers and threads inherit that charge. No authored code runs here. */
|
||||
static void fail(const char *message) {
|
||||
fprintf(stderr, "RESTRICTED_SANDBOX: %s\n", message);
|
||||
exit(125);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
struct stat info;
|
||||
if (argc != 2 || argv[1][0] != '/' || strstr(argv[1], "/../") ||
|
||||
lstat(argv[1], &info) || !S_ISDIR(info.st_mode) ||
|
||||
info.st_uid != getuid() || (info.st_mode & 007))
|
||||
fail("invalid engine directory");
|
||||
char gate;
|
||||
if (read(STDIN_FILENO, &gate, 1) != 1 || gate != 1)
|
||||
fail("engine admission was not released");
|
||||
int input = open("/dev/null", O_RDONLY);
|
||||
if (input < 0 || dup2(input, STDIN_FILENO) < 0)
|
||||
fail("stdin");
|
||||
close(input);
|
||||
int filter = open(SANDBOX_FILTER, O_RDONLY);
|
||||
if (filter < 0)
|
||||
fail("seccomp filter");
|
||||
char filter_fd[32];
|
||||
snprintf(filter_fd, sizeof(filter_fd), "%d", filter);
|
||||
char *args[4096];
|
||||
size_t count = 0;
|
||||
#define ARG(value) args[count++] = (value)
|
||||
ARG(SANDBOX_BWRAP);
|
||||
ARG("--unshare-all");
|
||||
ARG("--unshare-user");
|
||||
ARG("--die-with-parent");
|
||||
ARG("--disable-userns");
|
||||
ARG("--assert-userns-disabled");
|
||||
ARG("--cap-drop"); ARG("ALL");
|
||||
ARG("--hostname"); ARG("quixos-restricted");
|
||||
ARG("--clearenv");
|
||||
ARG("--setenv"); ARG("TZ"); ARG("UTC");
|
||||
ARG("--setenv"); ARG("LANG"); ARG("C.UTF-8");
|
||||
FILE *closure = fopen(SANDBOX_CLOSURE, "r");
|
||||
if (!closure)
|
||||
fail("runtime closure");
|
||||
char *line = NULL;
|
||||
size_t capacity = 0;
|
||||
while (getline(&line, &capacity, closure) >= 0) {
|
||||
line[strcspn(line, "\n")] = 0;
|
||||
if (strncmp(line, "/nix/store/", 11) || strchr(line, ' ') || count > 4000)
|
||||
fail("runtime closure entry");
|
||||
char *path = strdup(line);
|
||||
if (!path)
|
||||
fail("runtime closure allocation");
|
||||
ARG("--ro-bind"); ARG(path); ARG(path);
|
||||
}
|
||||
fclose(closure);
|
||||
free(line);
|
||||
ARG("--proc"); ARG("/proc");
|
||||
ARG("--dev"); ARG("/dev");
|
||||
ARG("--tmpfs"); ARG("/tmp");
|
||||
ARG("--bind"); ARG(argv[1]); ARG(argv[1]);
|
||||
ARG("--chdir"); ARG(argv[1]);
|
||||
ARG("--seccomp"); ARG(filter_fd);
|
||||
ARG("--"); ARG(SANDBOX_EXECUTABLE);
|
||||
ARG("serve"); ARG("--experimental"); ARG("config.capnp");
|
||||
ARG(NULL);
|
||||
execv(SANDBOX_BWRAP, args);
|
||||
fail("bubblewrap failed");
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
pkgs,
|
||||
runtime,
|
||||
executable,
|
||||
}:
|
||||
let
|
||||
closure = pkgs.closureInfo { rootPaths = [ runtime ]; };
|
||||
filter =
|
||||
pkgs.runCommand "quixos-restricted-seccomp"
|
||||
{
|
||||
nativeBuildInputs = [
|
||||
pkgs.stdenv.cc
|
||||
pkgs.pkg-config
|
||||
];
|
||||
buildInputs = [ pkgs.libseccomp ];
|
||||
}
|
||||
''
|
||||
$CC -O2 -Wall -Wextra -Werror ${./seccomp.c} $(pkg-config --cflags --libs libseccomp) -o filter
|
||||
./filter > "$out"
|
||||
'';
|
||||
in
|
||||
pkgs.runCommand "quixos-restricted-sandbox" { nativeBuildInputs = [ pkgs.stdenv.cc ]; } ''
|
||||
mkdir -p "$out/bin"
|
||||
$CC -O2 -Wall -Wextra -Werror ${./sandbox.c} \
|
||||
-DSANDBOX_BWRAP='"${pkgs.bubblewrap}/bin/bwrap"' \
|
||||
-DSANDBOX_EXECUTABLE='"${executable}"' \
|
||||
-DSANDBOX_FILTER='"${filter}"' \
|
||||
-DSANDBOX_CLOSURE='"${closure}/store-paths"' \
|
||||
-o "$out/bin/quixos-restricted-sandbox"
|
||||
''
|
||||
@@ -0,0 +1,54 @@
|
||||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <linux/sched.h>
|
||||
#include <seccomp.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* Layered on an empty mount tree, fresh PID/user/network namespaces, no caps,
|
||||
* no inherited descriptors and an immutable runtime closure. Deny kernel and
|
||||
* process escape surfaces; retain V8's ordinary memory/thread syscalls. */
|
||||
static void checked(int result) {
|
||||
if (result < 0) {
|
||||
fprintf(stderr, "seccomp rule: %d\n", result);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
int main(void) {
|
||||
scmp_filter_ctx filter = seccomp_init(SCMP_ACT_ALLOW);
|
||||
if (!filter) return 1;
|
||||
const char *denied[] = {
|
||||
"fork", "vfork", "execveat", "ptrace", "process_vm_readv", "process_vm_writev",
|
||||
"pidfd_getfd", "kcmp", "bpf", "perf_event_open", "userfaultfd", "io_uring_setup",
|
||||
"mount", "umount2", "pivot_root", "chroot", "move_mount", "open_tree", "fsopen",
|
||||
"fsconfig", "fsmount", "fspick", "mount_setattr", "open_by_handle_at",
|
||||
"name_to_handle_at", "setns", "unshare", "keyctl", "add_key", "request_key",
|
||||
"reboot", "kexec_load", "kexec_file_load", "init_module", "finit_module",
|
||||
"delete_module", "swapon", "swapoff", "syslog", "acct", "sethostname",
|
||||
"setdomainname", "iopl", "ioperm", "lookup_dcookie", NULL
|
||||
};
|
||||
for (size_t i = 0; denied[i]; i++) {
|
||||
int syscall = seccomp_syscall_resolve_name(denied[i]);
|
||||
if (syscall != __NR_SCMP_ERROR)
|
||||
checked(seccomp_rule_add(filter, SCMP_ACT_ERRNO(EPERM), syscall, 0));
|
||||
}
|
||||
/* glibc falls back to clone when clone3 is unavailable. Only thread creation
|
||||
* is admitted; subprocesses and new namespaces cannot be manufactured. */
|
||||
checked(seccomp_rule_add(filter, SCMP_ACT_ERRNO(ENOSYS), SCMP_SYS(clone3), 0));
|
||||
checked(seccomp_rule_add(filter, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(clone), 1,
|
||||
SCMP_A0(SCMP_CMP_MASKED_EQ, CLONE_THREAD, 0)));
|
||||
const unsigned long namespaces[] = {CLONE_NEWUSER, CLONE_NEWNS, CLONE_NEWNET,
|
||||
CLONE_NEWPID, CLONE_NEWIPC, CLONE_NEWUTS, CLONE_NEWCGROUP};
|
||||
for (size_t i = 0; i < sizeof(namespaces) / sizeof(namespaces[0]); i++)
|
||||
checked(seccomp_rule_add(filter, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(clone), 1,
|
||||
SCMP_A0(SCMP_CMP_MASKED_EQ, namespaces[i], namespaces[i])));
|
||||
checked(seccomp_rule_add(filter, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(socket), 1,
|
||||
SCMP_A0(SCMP_CMP_NE, AF_UNIX)));
|
||||
checked(seccomp_rule_add(filter, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(ioctl), 1,
|
||||
SCMP_A1(SCMP_CMP_EQ, 0x5412))); /* TIOCSTI */
|
||||
checked(seccomp_export_bpf(filter, STDOUT_FILENO));
|
||||
seccomp_release(filter);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createServer, request as httpRequest } from "node:http";
|
||||
import { mkdtemp, readFile, writeFile, rm, open, lstat, readdir } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
import {
|
||||
checkedSocketPath,
|
||||
socketDirectory,
|
||||
prepareSocketDirectory,
|
||||
removeEmptySocketDirectory,
|
||||
} from "../socket-address.mjs";
|
||||
|
||||
const abi = "quixos-transaction-ports-v1";
|
||||
/** Reserved supervisor faults are not authored domain rejections. A graph
|
||||
* transaction must roll back without recording a permanent rejected outcome
|
||||
* when its executor dies, exceeds its limits or cannot start. */
|
||||
export const restrictedExecutionFailureCodes = Object.freeze([
|
||||
"RESTRICTED_ABI_MISMATCH",
|
||||
"RESTRICTED_BACKPRESSURE",
|
||||
"RESTRICTED_CPU_ACCOUNTING",
|
||||
"RESTRICTED_DEADLINE",
|
||||
"RESTRICTED_DIAGNOSTIC_LIMIT",
|
||||
"RESTRICTED_DUPLICATE_INVOCATION",
|
||||
"RESTRICTED_ENGINE_EXITED",
|
||||
"RESTRICTED_ENGINE_LIMIT",
|
||||
"RESTRICTED_ENTRY_LIMIT",
|
||||
"RESTRICTED_IDENTITY_UNAVAILABLE",
|
||||
"RESTRICTED_MESSAGE_LIMIT",
|
||||
"RESTRICTED_PACKAGE_FAILED",
|
||||
"RESTRICTED_PORT_FAILED",
|
||||
"RESTRICTED_PORT_LIMIT",
|
||||
"RESTRICTED_PROCESS_LIMIT",
|
||||
"RESTRICTED_QUEUE_FULL",
|
||||
"RESTRICTED_SDK_LIMIT",
|
||||
"RESTRICTED_SOURCE_LIMIT",
|
||||
"RESTRICTED_START_FAILED",
|
||||
"RESTRICTED_UNKNOWN_INVOCATION",
|
||||
"RESTRICTED_UNKNOWN_PORT",
|
||||
]);
|
||||
const fixedError = (code) => Object.assign(Error(code), { code });
|
||||
const object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
||||
const absolute = (value) =>
|
||||
typeof value === "string" &&
|
||||
value.startsWith("/") &&
|
||||
!/[\s\u0000-\u001f]/.test(value) &&
|
||||
!value.split("/").includes("..");
|
||||
|
||||
/** Host-wide engine reservations. Only trusted adapters hold these callbacks;
|
||||
* workers cannot alter admission or evict another invocation's engine. */
|
||||
export class RestrictedEngineBudget {
|
||||
#leases = new Set();
|
||||
constructor(limit = 8, reserve = async () => ({})) {
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) throw fixedError("INVALID_ENGINE_BUDGET");
|
||||
this.limit = limit;
|
||||
this.reserve = reserve;
|
||||
}
|
||||
async acquire(idle, evict) {
|
||||
while (this.#leases.size >= this.limit) {
|
||||
const victim = [...this.#leases].find((entry) => entry.idle());
|
||||
if (!victim) throw fixedError("RESTRICTED_ENGINE_LIMIT");
|
||||
await victim.evict();
|
||||
}
|
||||
const lease = { idle, evict };
|
||||
this.#leases.add(lease);
|
||||
try {
|
||||
const resource = await this.reserve();
|
||||
let releasing;
|
||||
const release = () =>
|
||||
(releasing ??= (async () => {
|
||||
await resource.release?.();
|
||||
this.#leases.delete(lease);
|
||||
})());
|
||||
release.attach = resource.attach;
|
||||
release.usage = resource.usage;
|
||||
release.kill = resource.kill;
|
||||
release.uid = resource.uid;
|
||||
return release;
|
||||
} catch (error) {
|
||||
this.#leases.delete(lease);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
stats() {
|
||||
return { engines: this.#leases.size, limit: this.limit };
|
||||
}
|
||||
}
|
||||
async function boundedSource(path, maximum, code) {
|
||||
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile() || stat.size > maximum) throw fixedError(code);
|
||||
const bytes = Buffer.alloc(stat.size + 1);
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const read = await handle.read(bytes, offset, bytes.length - offset, offset);
|
||||
if (!read.bytesRead) break;
|
||||
offset += read.bytesRead;
|
||||
}
|
||||
if (offset !== stat.size) throw fixedError(code);
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, offset));
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Trusted stdio adapter inside one workspace VM. Engine processes never own
|
||||
* coordinator credentials. The supervising service must impose VM/OS limits.
|
||||
* Each engine runs one fresh Worker per invocation. Host tokens expire before
|
||||
* reuse; cancellation and watchdogs destroy the affected engine, not neighbors. */
|
||||
export async function serveRestrictedRegistry(config, io = process) {
|
||||
if (
|
||||
!object(config) ||
|
||||
!absolute(config.workerd) ||
|
||||
!absolute(config.worker) ||
|
||||
!absolute(config.module) ||
|
||||
(config.sandbox !== undefined && !absolute(config.sandbox)) ||
|
||||
(config.sandboxIdentity !== undefined && typeof config.sandboxIdentity !== "boolean") ||
|
||||
!/^[$A-Z_a-z][$\w]*$/.test(config.registryExport) ||
|
||||
!Array.isArray(config.exports) ||
|
||||
!config.exports.length ||
|
||||
config.exports.length > 10000 ||
|
||||
config.exports.some((x) => typeof x !== "string" || !x || x.length > 4096) ||
|
||||
new Set(config.exports).size !== config.exports.length ||
|
||||
!Array.isArray(config.sdk) ||
|
||||
config.sdk.length > 8 ||
|
||||
config.sdk.some(
|
||||
(m) =>
|
||||
!object(m) ||
|
||||
!absolute(m.path) ||
|
||||
typeof m.name !== "string" ||
|
||||
!m.name ||
|
||||
/[\u0000-\u001f]/.test(m.name) ||
|
||||
["entry.mjs", "package.mjs"].includes(m.name),
|
||||
) ||
|
||||
new Set(config.sdk.map((m) => m.name)).size !== config.sdk.length
|
||||
)
|
||||
throw fixedError("INVALID_RESTRICTED_CONFIGURATION");
|
||||
const limits = {
|
||||
maxBytes: config.maxBytes ?? 1024 * 1024,
|
||||
maxPortCalls: config.maxPortCalls ?? 1000,
|
||||
deadlineMs: config.deadlineMs ?? 10000,
|
||||
maxQueued: config.maxQueued ?? 8,
|
||||
maxActive: config.maxActive ?? 2,
|
||||
maxNested: config.maxNested ?? 4,
|
||||
engineIdleMs: config.engineIdleMs ?? 30000,
|
||||
};
|
||||
if (
|
||||
!Number.isSafeInteger(limits.maxBytes) ||
|
||||
limits.maxBytes < 1024 ||
|
||||
limits.maxBytes > 4 * 1024 * 1024 ||
|
||||
!Number.isSafeInteger(limits.maxPortCalls) ||
|
||||
limits.maxPortCalls < 1 ||
|
||||
limits.maxPortCalls > 10000 ||
|
||||
!Number.isSafeInteger(limits.deadlineMs) ||
|
||||
limits.deadlineMs < 1 ||
|
||||
limits.deadlineMs > 60000 ||
|
||||
!Number.isSafeInteger(limits.maxQueued) ||
|
||||
limits.maxQueued < 0 ||
|
||||
limits.maxQueued > 64 ||
|
||||
!Number.isSafeInteger(limits.maxActive) ||
|
||||
limits.maxActive < 1 ||
|
||||
limits.maxActive > 16 ||
|
||||
!Number.isSafeInteger(limits.maxNested) ||
|
||||
limits.maxNested < 1 ||
|
||||
limits.maxNested > 16 ||
|
||||
!Number.isSafeInteger(limits.engineIdleMs) ||
|
||||
limits.engineIdleMs < 1 ||
|
||||
limits.engineIdleMs > 60000
|
||||
)
|
||||
throw fixedError("INVALID_RESTRICTED_LIMITS");
|
||||
const packageSource = await boundedSource(config.module, 1024 * 1024, "RESTRICTED_SOURCE_LIMIT");
|
||||
const workerSource = await boundedSource(config.worker, 65536, "RESTRICTED_ENTRY_LIMIT");
|
||||
const sdk = [];
|
||||
for (const m of config.sdk)
|
||||
sdk.push({ ...m, source: await boundedSource(m.path, 2 * 1024 * 1024, "RESTRICTED_SDK_LIMIT") });
|
||||
const engineBudget = config.engineBudget ?? new RestrictedEngineBudget(limits.maxActive + limits.maxNested);
|
||||
const active = new Map(),
|
||||
engines = new Set(),
|
||||
idleEngines = [];
|
||||
const metrics = { starts: 0, reused: 0, completed: 0, peakRSSBytes: 0 };
|
||||
const changed = () => {
|
||||
try {
|
||||
config.onChange?.();
|
||||
} catch {}
|
||||
};
|
||||
let buffered = Buffer.alloc(0),
|
||||
stopped = false,
|
||||
failure;
|
||||
const queued = [];
|
||||
const send = (value) => {
|
||||
if (stopped) return;
|
||||
const line = JSON.stringify({ abi, ...value }) + "\n";
|
||||
if (
|
||||
Buffer.byteLength(line) > limits.maxBytes ||
|
||||
io.stdout.writableLength + Buffer.byteLength(line) > limits.maxBytes * 2
|
||||
)
|
||||
throw fixedError("RESTRICTED_BACKPRESSURE");
|
||||
io.stdout.write(line);
|
||||
};
|
||||
// A sandbox has namespace helpers as well as workerd. Charge the complete
|
||||
// delegated cgroup when available; the qualification fallback follows the
|
||||
// bounded process tree, never trusting a worker's self-reported counters.
|
||||
const engineUsage = async (engine) => {
|
||||
if (engine.release?.usage) return engine.release.usage();
|
||||
engine.processTicks ??= new Map();
|
||||
const pending = [engine.child.pid];
|
||||
let memoryBytes = 0,
|
||||
visited = 0;
|
||||
while (pending.length) {
|
||||
if (++visited > 64) throw fixedError("RESTRICTED_PROCESS_LIMIT");
|
||||
const pid = pending.pop();
|
||||
try {
|
||||
const stat = await readFile(`/proc/${pid}/stat`, "utf8");
|
||||
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
||||
engine.processTicks.set(`${pid}:${fields[19]}`, Number(fields[11]) + Number(fields[12]));
|
||||
const status = await readFile(`/proc/${pid}/status`, "utf8");
|
||||
memoryBytes += Number(status.match(/^VmRSS:\s+(\d+)/m)?.[1] ?? 0) * 1024;
|
||||
const children = await readFile(`/proc/${pid}/task/${pid}/children`, "utf8");
|
||||
pending.push(...children.trim().split(/\s+/).filter(Boolean).map(Number));
|
||||
} catch (error) {
|
||||
if (!["ENOENT", "ESRCH"].includes(error.code)) throw error;
|
||||
}
|
||||
}
|
||||
return {
|
||||
cpuMicroseconds: [...engine.processTicks.values()].reduce((sum, ticks) => sum + ticks * 10000, 0),
|
||||
memoryBytes,
|
||||
};
|
||||
};
|
||||
const sampleCPU = async (call) => {
|
||||
if (!call.child?.pid) return;
|
||||
const { cpuMicroseconds: total } = await engineUsage(call.engine);
|
||||
const cpuMicroseconds = total - (call.cpuBaseline ?? 0);
|
||||
if (call.engine) call.engine.cpuTotal = total;
|
||||
if (!Number.isSafeInteger(cpuMicroseconds) || cpuMicroseconds < (call.cpuMicroseconds ?? 0))
|
||||
throw fixedError("RESTRICTED_CPU_ACCOUNTING");
|
||||
if (cpuMicroseconds !== (call.cpuMicroseconds ?? 0)) {
|
||||
call.cpuMicroseconds = cpuMicroseconds;
|
||||
send({ kind: "usage", invocation: call.message.invocation, frame: call.message.frame, cpuMicroseconds });
|
||||
}
|
||||
};
|
||||
const watchCPU = (call) => {
|
||||
call.cpuTimer = setTimeout(() => {
|
||||
call.cpuRead = sampleCPU(call).then(() => {
|
||||
if (call.open) watchCPU(call);
|
||||
});
|
||||
void call.cpuRead.catch(fail);
|
||||
}, 20);
|
||||
};
|
||||
const retire = async (call, reason) => {
|
||||
if (call.retiring) return call.retiring;
|
||||
call.open = false;
|
||||
call.retiring = (async () => {
|
||||
clearTimeout(call.timer);
|
||||
clearTimeout(call.cpuTimer);
|
||||
const index = queued.indexOf(call);
|
||||
if (index >= 0) queued.splice(index, 1);
|
||||
for (const pending of call.pending.values()) pending.reject(reason);
|
||||
call.pending.clear();
|
||||
// Cancellation can arrive during async directory/socket preparation.
|
||||
// Wait for it to settle before claiming every process/handle is gone.
|
||||
await call.preparing?.catch(() => {});
|
||||
clearTimeout(call.cpuTimer);
|
||||
await call.cpuRead?.catch(() => {});
|
||||
let usageFailure;
|
||||
try {
|
||||
await sampleCPU(call);
|
||||
} catch (error) {
|
||||
usageFailure = error;
|
||||
}
|
||||
const engine = call.engine;
|
||||
if (engine) {
|
||||
if (!stopped && reason.code === "INVOCATION_ENDED" && !usageFailure && !engine.destroying) {
|
||||
const rss = (await engineUsage(engine)).memoryBytes;
|
||||
metrics.peakRSSBytes = Math.max(metrics.peakRSSBytes, rss);
|
||||
// Bound retained engine heaps as well as lifetime. Hard aggregate
|
||||
// physical memory remains the supervising process/cgroup's contract.
|
||||
// Keep the lease busy through every await. Another artifact can evict
|
||||
// an idle lease immediately; never publish an engine still retiring
|
||||
// or one whose process died while its final usage read was pending.
|
||||
if (rss <= 128 * 1024 * 1024 && !stopped && !engine.destroying && engine.current === call) {
|
||||
// Revoke the frame token atomically with publishing idle capacity.
|
||||
engine.current = null;
|
||||
idleEngines.push(engine);
|
||||
engine.idleUntil = Date.now() + limits.engineIdleMs;
|
||||
engine.idleTimer = setTimeout(() => void destroyEngine(engine).catch(fail), limits.engineIdleMs);
|
||||
} else await destroyEngine(engine);
|
||||
} else await destroyEngine(engine);
|
||||
}
|
||||
active.delete(call.message.invocation);
|
||||
if (usageFailure) throw usageFailure;
|
||||
})();
|
||||
return call.retiring;
|
||||
};
|
||||
const fail = (error) => {
|
||||
if (failure) return failure;
|
||||
stopped = true;
|
||||
return (failure = (async () => {
|
||||
io.stdin.destroy();
|
||||
for (const call of [...queued]) await retire(call, error);
|
||||
await Promise.all([...active.values()].map((call) => retire(call, error)));
|
||||
await Promise.all([...engines].map(destroyEngine));
|
||||
io.stdout.end();
|
||||
io.exitCode = 1;
|
||||
})());
|
||||
};
|
||||
const body = async (stream) => {
|
||||
const chunks = [];
|
||||
let bytes = 0;
|
||||
for await (const chunk of stream) {
|
||||
bytes += chunk.length;
|
||||
if (bytes > limits.maxBytes) throw fixedError("RESTRICTED_MESSAGE_LIMIT");
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)));
|
||||
};
|
||||
const destroyEngine = async (engine) => {
|
||||
if (engine.destroying) return engine.destroying;
|
||||
engine.destroying = (async () => {
|
||||
clearTimeout(engine.idleTimer);
|
||||
const index = idleEngines.indexOf(engine);
|
||||
if (index >= 0) idleEngines.splice(index, 1);
|
||||
engine.current = null;
|
||||
if (engine.child?.pid) {
|
||||
if (engine.release.kill) await engine.release.kill();
|
||||
else {
|
||||
try {
|
||||
process.kill(-engine.child.pid, "SIGKILL");
|
||||
} catch (error) {
|
||||
if (error.code !== "ESRCH") throw error;
|
||||
}
|
||||
}
|
||||
await engine.exited;
|
||||
}
|
||||
const sameDirectory = async (directory, owner) => {
|
||||
const current = await lstat(directory);
|
||||
if (
|
||||
!owner ||
|
||||
!current.isDirectory() ||
|
||||
current.dev !== owner.dev ||
|
||||
current.ino !== owner.ino ||
|
||||
current.uid !== owner.uid ||
|
||||
current.mode & 0o077
|
||||
)
|
||||
throw fixedError("RESTRICTED_START_FAILED");
|
||||
};
|
||||
// A bounded ordinary-engine parent is private to this admitted lifetime.
|
||||
// Never close/unlink through a replacement directory or release its lease
|
||||
// while the original namespace's ownership remains uncertain.
|
||||
if (engine.socketParent) {
|
||||
await sameDirectory(engine.socketParent, engine.socketParentOwner);
|
||||
if (engine.directory) await sameDirectory(engine.directory, engine.directoryOwner);
|
||||
if (engine.broker) {
|
||||
const current = await lstat(engine.broker).catch((error) => {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
return null;
|
||||
});
|
||||
if (
|
||||
current &&
|
||||
(!engine.brokerOwner ||
|
||||
!current.isSocket() ||
|
||||
current.dev !== engine.brokerOwner.dev ||
|
||||
current.ino !== engine.brokerOwner.ino)
|
||||
)
|
||||
throw fixedError("RESTRICTED_START_FAILED");
|
||||
}
|
||||
}
|
||||
engine.server?.closeAllConnections();
|
||||
if (engine.server) await new Promise((resolve) => engine.server.close(resolve));
|
||||
if (engine.socketParent) {
|
||||
await sameDirectory(engine.socketParent, engine.socketParentOwner);
|
||||
if (engine.directory) await sameDirectory(engine.directory, engine.directoryOwner);
|
||||
}
|
||||
if (engine.directory) await rm(engine.directory, { recursive: true, force: true });
|
||||
if (engine.socketParent) {
|
||||
await removeEmptySocketDirectory(engine.socketIdentity);
|
||||
const remaining = await lstat(engine.socketParent).catch((error) => {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
return null;
|
||||
});
|
||||
if (remaining) throw fixedError("RESTRICTED_START_FAILED");
|
||||
}
|
||||
engines.delete(engine);
|
||||
await engine.release?.();
|
||||
changed();
|
||||
})();
|
||||
return engine.destroying;
|
||||
};
|
||||
const prepareEngine = async (call) => {
|
||||
const cached = idleEngines.pop();
|
||||
if (cached) {
|
||||
clearTimeout(cached.idleTimer);
|
||||
cached.current = call;
|
||||
call.engine = cached;
|
||||
call.child = cached.child;
|
||||
call.cpuBaseline = cached.cpuTotal ?? 0;
|
||||
metrics.reused++;
|
||||
watchCPU(call);
|
||||
return;
|
||||
}
|
||||
const engine = { current: call, cpuTotal: 0 };
|
||||
engines.add(engine);
|
||||
call.engine = engine;
|
||||
engine.release = await engineBudget.acquire(
|
||||
() => !engine.current && !engine.destroying,
|
||||
() => destroyEngine(engine),
|
||||
);
|
||||
if (!call.open) return;
|
||||
let directoryRoot;
|
||||
if (config.sandboxIdentity) {
|
||||
// The privileged helper owns a fixed explicit /run root and requires
|
||||
// this exact basename. Keep its cross-UID ancestry and identity rules.
|
||||
directoryRoot = tmpdir();
|
||||
} else {
|
||||
engine.socketIdentity = { kind: "restricted-engine", process: process.pid, lifetime: randomUUID() };
|
||||
directoryRoot = socketDirectory(engine.socketIdentity);
|
||||
}
|
||||
for (const filename of ["broker.sock", "engine.sock"])
|
||||
checkedSocketPath(directoryRoot + "/qx-engine-XXXXXX/" + filename);
|
||||
if (!config.sandboxIdentity) {
|
||||
engine.socketParent = await prepareSocketDirectory(engine.socketIdentity);
|
||||
engine.socketParentOwner = await lstat(engine.socketParent);
|
||||
if ((await readdir(engine.socketParent)).length) throw fixedError("RESTRICTED_START_FAILED");
|
||||
}
|
||||
engine.directory = await mkdtemp(join(directoryRoot, "qx-engine-"));
|
||||
if (engine.socketParent) engine.directoryOwner = await lstat(engine.directory);
|
||||
if (!call.open) return;
|
||||
const broker = checkedSocketPath(join(engine.directory, "broker.sock"));
|
||||
engine.socket = checkedSocketPath(join(engine.directory, "engine.sock"));
|
||||
engine.server = createServer(async (request, response) => {
|
||||
const call = engine.current;
|
||||
const message = call?.message;
|
||||
try {
|
||||
if (!call?.open || request.method !== "POST" || request.url !== "/port/" + call.token)
|
||||
throw fixedError("EXPIRED_CONTEXT");
|
||||
const port = await body(request);
|
||||
if (
|
||||
!call.open ||
|
||||
!object(port) ||
|
||||
Object.keys(port).some((key) => !["portId", "action", "input", "key"].includes(key)) ||
|
||||
typeof port.portId !== "string" ||
|
||||
!object(port.action) ||
|
||||
!Object.hasOwn(port, "input")
|
||||
)
|
||||
throw fixedError("INVALID_RESTRICTED_PORT");
|
||||
if (
|
||||
++call.sequence > Math.min(limits.maxPortCalls, message.budget?.calls ?? limits.maxPortCalls) ||
|
||||
call.pending.size >= limits.maxPortCalls
|
||||
)
|
||||
throw fixedError("RESTRICTED_PORT_LIMIT");
|
||||
const sequence = call.sequence;
|
||||
const result = new Promise((resolve, reject) => call.pending.set(sequence, { resolve, reject }));
|
||||
void result.catch(() => {});
|
||||
send({
|
||||
kind: "port",
|
||||
invocation: message.invocation,
|
||||
frame: message.frame,
|
||||
sequence,
|
||||
portId: port.portId,
|
||||
action: { ...port.action, ...(port.key === undefined ? {} : { key: port.key }) },
|
||||
input: port.input,
|
||||
});
|
||||
const value = await result;
|
||||
if (!call.open) throw fixedError("EXPIRED_CONTEXT");
|
||||
const encoded = JSON.stringify(value);
|
||||
if (Buffer.byteLength(encoded) > limits.maxBytes) throw fixedError("RESTRICTED_MESSAGE_LIMIT");
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(encoded);
|
||||
} catch {
|
||||
response.writeHead(409);
|
||||
response.end('{"status":"failed","error":{"code":"RESTRICTED_PORT_FAILED"}}');
|
||||
}
|
||||
});
|
||||
|
||||
if (engine.socketParent) engine.broker = broker;
|
||||
await new Promise((resolve, reject) => {
|
||||
engine.server.once("error", reject);
|
||||
engine.server.listen(broker, resolve);
|
||||
});
|
||||
if (engine.socketParent) {
|
||||
engine.brokerOwner = await lstat(broker);
|
||||
}
|
||||
const modules = { "entry.mjs": { js: workerSource }, "package.mjs": { js: packageSource } };
|
||||
for (const module of sdk) modules[module.name] = { js: module.source };
|
||||
const setting = { registryExport: config.registryExport, exports: config.exports, maxBytes: limits.maxBytes };
|
||||
// The parent contains immutable checked source only. It never imports the
|
||||
// authored module or keeps a child stub, request, result or frame handle.
|
||||
const parent = `const modules=${JSON.stringify(modules)}, setting=${JSON.stringify(setting)};
|
||||
export default {async fetch(request,env) {
|
||||
const input=await request.json();
|
||||
const child=env.LOADER.load({compatibilityDate:"2026-10-05",mainModule:"entry.mjs",modules,
|
||||
globalOutbound:null,env:{BROKER:env.BROKER,CONFIG:{...setting,frameToken:input.frameToken}}});
|
||||
const response=await child.getEntrypoint().fetch(new Request("http://invocation/",{
|
||||
method:"POST",body:JSON.stringify(input.message)}));
|
||||
const body=await response.arrayBuffer();
|
||||
return new Response(body,{status:response.status,headers:response.headers});
|
||||
}};`;
|
||||
await writeFile(join(engine.directory, "parent.mjs"), parent);
|
||||
const configuration = `using Workerd = import "/workerd/workerd.capnp"; const config :Workerd.Config = (
|
||||
services=[(name="main",worker=(modules=[(name="parent.mjs",esModule=embed "parent.mjs")],
|
||||
compatibilityDate="2026-10-05",compatibilityFlags=["experimental"],globalOutbound="deny",
|
||||
bindings=[(name="LOADER",workerLoader=()),(name="BROKER",service="broker")])),
|
||||
(name="broker",external=(address=${JSON.stringify("unix:" + broker)},http=())),
|
||||
(name="deny",network=(allow=[]))],
|
||||
sockets=[(name="frame",address=${JSON.stringify("unix:" + engine.socket)},http=(),service="main")]);`;
|
||||
const file = join(engine.directory, "config.capnp");
|
||||
await writeFile(file, configuration);
|
||||
if (!call.open) return;
|
||||
if (config.sandboxIdentity && !Number.isSafeInteger(engine.release.uid))
|
||||
throw fixedError("RESTRICTED_IDENTITY_UNAVAILABLE");
|
||||
engine.child = spawn(
|
||||
config.sandbox ?? config.workerd,
|
||||
config.sandbox
|
||||
? [engine.directory, ...(config.sandboxIdentity ? [String(engine.release.uid)] : [])]
|
||||
: ["serve", "--experimental", file],
|
||||
{
|
||||
detached: true,
|
||||
env: { TZ: "UTC", LANG: "C.UTF-8" },
|
||||
cwd: engine.directory,
|
||||
stdio: [config.sandbox ? "pipe" : "ignore", "ignore", "pipe"],
|
||||
},
|
||||
);
|
||||
metrics.starts++;
|
||||
changed();
|
||||
engine.exited = new Promise((resolve) => {
|
||||
engine.child.once("exit", resolve);
|
||||
engine.child.once("error", resolve);
|
||||
});
|
||||
engine.child.stdin?.on("error", () => engine.current?.terminate("RESTRICTED_START_FAILED"));
|
||||
call.child = engine.child;
|
||||
let diagnostics = 0;
|
||||
engine.child.stderr.on("data", (chunk) => {
|
||||
if ((diagnostics += chunk.length) > 65536) {
|
||||
if (engine.current?.open) engine.current.terminate("RESTRICTED_DIAGNOSTIC_LIMIT");
|
||||
else void destroyEngine(engine).catch(fail);
|
||||
} else config.diagnostics?.(chunk);
|
||||
});
|
||||
await engine.release.attach?.(engine.child.pid);
|
||||
if (config.sandbox && call.open) engine.child.stdin.end(Buffer.from([1]));
|
||||
watchCPU(call);
|
||||
engine.child.once("error", () => engine.current?.terminate("RESTRICTED_START_FAILED"));
|
||||
void engine.exited.then(() => {
|
||||
if (engine.current?.open) engine.current.terminate("RESTRICTED_ENGINE_EXITED");
|
||||
else void destroyEngine(engine).catch(fail);
|
||||
});
|
||||
};
|
||||
const available = (call) =>
|
||||
[...active.values()].filter((entry) => entry.nested === call.nested).length <
|
||||
(call.nested ? limits.maxNested : limits.maxActive);
|
||||
const pump = () => {
|
||||
while (!stopped) {
|
||||
const index = queued.findIndex(available);
|
||||
if (index < 0) return;
|
||||
void invoke(queued.splice(index, 1)[0]).catch(fail);
|
||||
}
|
||||
};
|
||||
const finish = async (call, reason, reply) => {
|
||||
if (reply.kind === "cancelled") call.cancellationReply = reply;
|
||||
return (call.finished ??= (async () => {
|
||||
await retire(call, reason);
|
||||
metrics.lastInvocation = {
|
||||
queueMs: (call.startedAt ?? performance.now()) - call.receivedAt,
|
||||
preparationMs: call.preparationMs ?? 0,
|
||||
roundtripMs: call.roundtripMs ?? 0,
|
||||
initializationMs: call.initializationMs ?? null,
|
||||
executionMs: call.executionMs ?? null,
|
||||
totalMs: performance.now() - call.receivedAt,
|
||||
};
|
||||
metrics.lastFailure =
|
||||
reply.status === "failed" && /^[A-Z0-9_]{1,128}$/.test(reply.error?.code) ? reply.error.code : null;
|
||||
changed();
|
||||
send(call.cancellationReply ?? reply);
|
||||
pump();
|
||||
})());
|
||||
};
|
||||
const invoke = async (call) => {
|
||||
const { message } = call;
|
||||
call.startedAt = performance.now();
|
||||
active.set(message.invocation, call);
|
||||
changed();
|
||||
const terminate = (code) => {
|
||||
if (!call.open) return;
|
||||
void finish(call, fixedError(code), {
|
||||
invocation: message.invocation,
|
||||
status: "failed",
|
||||
error: { code, message: code },
|
||||
}).catch(fail);
|
||||
};
|
||||
call.terminate = terminate;
|
||||
try {
|
||||
call.token = randomUUID();
|
||||
call.preparing = prepareEngine(call);
|
||||
await call.preparing;
|
||||
call.preparationMs = performance.now() - call.startedAt;
|
||||
if (!call.open) return;
|
||||
const roundtripAt = performance.now();
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const attempt = () => {
|
||||
if (!call.open) {
|
||||
reject(fixedError("EXPIRED_CONTEXT"));
|
||||
return;
|
||||
}
|
||||
const request = httpRequest(
|
||||
{ socketPath: call.engine.socket, path: "/", method: "POST", agent: false },
|
||||
async (response) => {
|
||||
try {
|
||||
const result = await body(response);
|
||||
for (const [header, key] of [
|
||||
["x-quixos-initialization-ms", "initializationMs"],
|
||||
["x-quixos-execution-ms", "executionMs"],
|
||||
]) {
|
||||
const value = Number(response.headers[header]);
|
||||
if (Number.isFinite(value) && value >= 0 && value <= limits.deadlineMs) call[key] = value;
|
||||
}
|
||||
if (response.statusCode !== 200) throw fixedError("RESTRICTED_PACKAGE_FAILED");
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
request.once("error", (error) => {
|
||||
if (call.open && ["ENOENT", "ECONNREFUSED"].includes(error.code)) setTimeout(attempt, 5);
|
||||
else reject(error);
|
||||
});
|
||||
request.end(JSON.stringify({ message, frameToken: call.token }));
|
||||
};
|
||||
attempt();
|
||||
});
|
||||
call.roundtripMs = performance.now() - roundtripAt;
|
||||
if (!call.open) return;
|
||||
if (
|
||||
!object(result) ||
|
||||
!["returned", "failed"].includes(result.status) ||
|
||||
Object.keys(result).some(
|
||||
(key) => !(result.status === "returned" ? ["status", "output"] : ["status", "error"]).includes(key),
|
||||
) ||
|
||||
(result.status === "returned"
|
||||
? !Object.hasOwn(result, "output")
|
||||
: !object(result.error) || !/^[A-Z0-9_]{1,128}$/.test(result.error.code))
|
||||
)
|
||||
throw fixedError("RESTRICTED_PACKAGE_FAILED");
|
||||
// Authored code can alter serialization prototypes. Treat even the
|
||||
// platform worker's JSON as untrusted; never accept routing/ABI fields.
|
||||
const reply =
|
||||
result.status === "returned"
|
||||
? { invocation: message.invocation, status: "returned", output: result.output }
|
||||
: {
|
||||
invocation: message.invocation,
|
||||
status: "failed",
|
||||
error: { code: result.error.code, message: result.error.code },
|
||||
};
|
||||
await finish(call, fixedError("INVOCATION_ENDED"), reply);
|
||||
metrics.completed++;
|
||||
} catch (error) {
|
||||
config.diagnostics?.(Buffer.from(`RESTRICTED_HOST_ERROR:${error.code ?? error.name}\n`));
|
||||
if (call.open) {
|
||||
const code = /^RESTRICTED_[A-Z_]+$/.test(error.code ?? "") ? error.code : "RESTRICTED_PACKAGE_FAILED";
|
||||
await finish(call, error, { invocation: message.invocation, status: "failed", error: { code, message: code } });
|
||||
}
|
||||
}
|
||||
};
|
||||
const receive = (message) => {
|
||||
if (!object(message) || message.abi !== abi) throw fixedError("RESTRICTED_ABI_MISMATCH");
|
||||
if (message.kind === "shutdown") {
|
||||
void fail(fixedError("SERVICE_SHUTDOWN"));
|
||||
return;
|
||||
}
|
||||
if (message.kind === "invoke") {
|
||||
if (
|
||||
!config.exports.includes(message.exportId) ||
|
||||
!["snapshot", "transaction"].includes(message.effect) ||
|
||||
typeof message.invocation !== "string" ||
|
||||
!message.invocation ||
|
||||
message.invocation.length > 4096 ||
|
||||
typeof message.frame !== "string" ||
|
||||
!message.frame ||
|
||||
message.frame.length > 4096
|
||||
)
|
||||
throw fixedError("INVALID_RESTRICTED_INVOCATION");
|
||||
if (
|
||||
message.budget !== undefined &&
|
||||
(!object(message.budget) ||
|
||||
Object.keys(message.budget).sort().join(",") !== "calls,deadlineMs,depth,root" ||
|
||||
typeof message.budget.root !== "string" ||
|
||||
!message.budget.root ||
|
||||
message.budget.root.length > 4096 ||
|
||||
!Number.isSafeInteger(message.budget.depth) ||
|
||||
message.budget.depth < 0 ||
|
||||
message.budget.depth > 32 ||
|
||||
!Number.isSafeInteger(message.budget.calls) ||
|
||||
message.budget.calls < 1 ||
|
||||
message.budget.calls > 1000000 ||
|
||||
!Number.isSafeInteger(message.budget.deadlineMs) ||
|
||||
message.budget.deadlineMs < 1 ||
|
||||
message.budget.deadlineMs > 60000)
|
||||
)
|
||||
throw fixedError("INVALID_RESTRICTED_BUDGET");
|
||||
if (active.has(message.invocation) || queued.some((call) => call.message.invocation === message.invocation))
|
||||
throw fixedError("RESTRICTED_DUPLICATE_INVOCATION");
|
||||
const call = {
|
||||
message,
|
||||
receivedAt: performance.now(),
|
||||
open: true,
|
||||
pending: new Map(),
|
||||
sequence: 0,
|
||||
nested: (message.budget?.depth ?? 0) > 1,
|
||||
};
|
||||
// Descendants cannot wait behind ancestors that hold all nested capacity.
|
||||
// Reject excess fan-out/depth rather than deadlock an admitted call tree.
|
||||
if (!available(call) && (call.nested || queued.length >= limits.maxQueued)) {
|
||||
send({
|
||||
invocation: message.invocation,
|
||||
status: "failed",
|
||||
error: { code: "RESTRICTED_QUEUE_FULL", message: "RESTRICTED_QUEUE_FULL" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Deadline includes queue wait: overload cannot extend a transaction frame.
|
||||
call.timer = setTimeout(
|
||||
() => {
|
||||
if (!call.open) return;
|
||||
void finish(call, fixedError("RESTRICTED_DEADLINE"), {
|
||||
invocation: message.invocation,
|
||||
status: "failed",
|
||||
error: { code: "RESTRICTED_DEADLINE", message: "RESTRICTED_DEADLINE" },
|
||||
}).catch(fail);
|
||||
},
|
||||
Math.min(limits.deadlineMs, message.budget?.deadlineMs ?? limits.deadlineMs),
|
||||
);
|
||||
queued.push(call);
|
||||
pump();
|
||||
return;
|
||||
}
|
||||
const waiting = queued.find(
|
||||
(call) => call.message.invocation === message.invocation && call.message.frame === message.frame,
|
||||
);
|
||||
if (waiting && message.kind === "cancel") {
|
||||
void finish(waiting, fixedError("INVOCATION_CANCELLED"), {
|
||||
kind: "cancelled",
|
||||
invocation: message.invocation,
|
||||
frame: message.frame,
|
||||
}).catch(fail);
|
||||
return;
|
||||
}
|
||||
const call = active.get(message.invocation);
|
||||
if (!call || message.invocation !== call.message.invocation || message.frame !== call.message.frame) {
|
||||
if (message.kind === "cancel") send({ kind: "cancelled", invocation: message.invocation, frame: message.frame });
|
||||
else if (message.kind !== "port-result") throw fixedError("RESTRICTED_UNKNOWN_INVOCATION");
|
||||
return;
|
||||
}
|
||||
if (message.kind === "cancel") {
|
||||
void finish(call, fixedError("INVOCATION_CANCELLED"), {
|
||||
kind: "cancelled",
|
||||
invocation: message.invocation,
|
||||
frame: message.frame,
|
||||
}).catch(fail);
|
||||
return;
|
||||
}
|
||||
if (message.kind !== "port-result" || !["returned", "failed"].includes(message.status))
|
||||
throw fixedError("INVALID_RESTRICTED_RESULT");
|
||||
const pending = call.pending.get(message.sequence);
|
||||
if (!pending) throw fixedError("RESTRICTED_UNKNOWN_PORT");
|
||||
call.pending.delete(message.sequence);
|
||||
pending.resolve(message);
|
||||
};
|
||||
io.stdin.on("data", (chunk) => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
let offset = 0;
|
||||
while (offset < chunk.length) {
|
||||
const newline = chunk.indexOf(10, offset),
|
||||
end = newline < 0 ? chunk.length : newline;
|
||||
if (buffered.length + end - offset > limits.maxBytes) throw fixedError("RESTRICTED_MESSAGE_LIMIT");
|
||||
buffered = Buffer.concat([buffered, chunk.subarray(offset, end)]);
|
||||
if (newline < 0) break;
|
||||
receive(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffered)));
|
||||
buffered = Buffer.alloc(0);
|
||||
offset = newline + 1;
|
||||
if (stopped) break;
|
||||
}
|
||||
} catch (error) {
|
||||
void fail(error);
|
||||
}
|
||||
});
|
||||
io.stdin.on("end", () => void fail(fixedError("COORDINATOR_DISCONNECTED")));
|
||||
io.stdin.on("error", (error) => void fail(error));
|
||||
io.stdout.on("error", (error) => void fail(error));
|
||||
send({ kind: "ready", exports: config.exports });
|
||||
return {
|
||||
close: () => fail(fixedError("SERVICE_SHUTDOWN")),
|
||||
stats: () => ({
|
||||
...metrics,
|
||||
active: active.size,
|
||||
queued: queued.length,
|
||||
engines: engines.size,
|
||||
idle: idleEngines.length,
|
||||
idleDeadline: idleEngines.length
|
||||
? new Date(Math.min(...idleEngines.map((engine) => engine.idleUntil))).toISOString()
|
||||
: null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (process.argv[1] === new URL(import.meta.url).pathname) {
|
||||
const config = JSON.parse(await readFile(process.argv[2], "utf8"));
|
||||
await serveRestrictedRegistry({
|
||||
...config,
|
||||
workerd: process.env.QUIXOS_RESTRICTED_WORKERD,
|
||||
worker: process.env.QUIXOS_RESTRICTED_WORKER,
|
||||
sandbox: process.env.QUIXOS_RESTRICTED_SANDBOX,
|
||||
module: join(dirname(process.argv[2]), config.module),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { mkdtemp, writeFile, rm, mkdir, readdir, lstat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { serveRestrictedRegistry, RestrictedEngineBudget } from "./service.mjs";
|
||||
import { socketDirectory, checkedSocketPath } from "../socket-address.mjs";
|
||||
|
||||
const workerd = process.env.QUIXOS_TEST_WORKERD;
|
||||
if (!workerd) throw Error("Set QUIXOS_TEST_WORKERD to the pinned engine");
|
||||
const worker = fileURLToPath(new URL("./worker.mjs", import.meta.url));
|
||||
const abi = "quixos-transaction-ports-v1";
|
||||
let next = 0;
|
||||
async function fixture(source, options = {}) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "qx-restricted-test-"));
|
||||
const module = join(directory, "module.mjs");
|
||||
await writeFile(module, source);
|
||||
const io = { stdin: new PassThrough(), stdout: new PassThrough(), exitCode: 0 };
|
||||
const waiting = [],
|
||||
queued = [];
|
||||
const usage = [];
|
||||
let buffer = "";
|
||||
io.stdout.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
let index;
|
||||
while ((index = buffer.indexOf("\n")) >= 0) {
|
||||
const value = JSON.parse(buffer.slice(0, index));
|
||||
buffer = buffer.slice(index + 1);
|
||||
if (value.kind === "usage") {
|
||||
usage.push(value);
|
||||
continue;
|
||||
}
|
||||
if (waiting.length) waiting.shift()(value);
|
||||
else queued.push(value);
|
||||
}
|
||||
});
|
||||
const receive = async () => {
|
||||
if (queued.length) return queued.shift();
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
new Promise((resolve) => waiting.push(resolve)),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(Error("TEST_MESSAGE_TIMEOUT")), 5000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
const service = await serveRestrictedRegistry(
|
||||
{
|
||||
workerd,
|
||||
worker,
|
||||
module,
|
||||
sandbox: process.env.QUIXOS_TEST_SANDBOX,
|
||||
registryExport: "registry",
|
||||
exports: ["test"],
|
||||
sdk: [],
|
||||
...options,
|
||||
},
|
||||
io,
|
||||
);
|
||||
assert.deepEqual(await receive(), { abi, kind: "ready", exports: ["test"] });
|
||||
const send = (value) => io.stdin.write(JSON.stringify({ abi, ...value }) + "\n");
|
||||
const start = (input = null, budget) => {
|
||||
const invocation = "invoke-" + ++next,
|
||||
frame = "frame-" + next;
|
||||
send({
|
||||
kind: "invoke",
|
||||
invocation,
|
||||
frame,
|
||||
exportId: "test",
|
||||
workspace: "workspace:restricted",
|
||||
receiver: null,
|
||||
input,
|
||||
effect: "snapshot",
|
||||
...(budget ? { budget } : {}),
|
||||
});
|
||||
return { invocation, frame };
|
||||
};
|
||||
return {
|
||||
io,
|
||||
receive,
|
||||
send,
|
||||
start,
|
||||
usage,
|
||||
stats: service.stats,
|
||||
async close() {
|
||||
await service.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
const registry = (code) => `export const registry={exports:['test'],async invoke(request,channel){${code}}};`;
|
||||
|
||||
test("real engine sockets and retirement are independent of deep Unicode TMPDIR", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "restricted-deep-"));
|
||||
const deep = join(root, "保留された状態".repeat(12));
|
||||
await mkdir(deep);
|
||||
const oldTmp = process.env.TMPDIR;
|
||||
const socketRoot = dirname(socketDirectory({ kind: "qualification-probe" }));
|
||||
const names = async () =>
|
||||
readdir(socketRoot).catch((error) => {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
return [];
|
||||
});
|
||||
const before = new Set(await names());
|
||||
let f, namespace;
|
||||
process.env.TMPDIR = deep;
|
||||
try {
|
||||
assert.ok(Buffer.byteLength(join(deep, "qx-engine-XXXXXX", "engine.sock")) > 100);
|
||||
f = await fixture(registry("return request.input;"));
|
||||
f.start("deep runtime");
|
||||
const result = await f.receive();
|
||||
assert.equal(result.status, "returned");
|
||||
assert.equal(result.output, "deep runtime");
|
||||
assert.equal(f.stats().starts, 1);
|
||||
const created = (await names()).filter((name) => !before.has(name));
|
||||
assert.equal(created.length, 1);
|
||||
namespace = join(socketRoot, created[0]);
|
||||
const engines = await readdir(namespace);
|
||||
assert.equal(engines.length, 1);
|
||||
for (const name of ["broker.sock", "engine.sock"]) {
|
||||
const endpoint = checkedSocketPath(join(namespace, engines[0], name));
|
||||
assert.ok((await lstat(endpoint)).isSocket());
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await f?.close();
|
||||
} finally {
|
||||
if (oldTmp === undefined) delete process.env.TMPDIR;
|
||||
else process.env.TMPDIR = oldTmp;
|
||||
}
|
||||
}
|
||||
await assert.rejects(lstat(namespace), { code: "ENOENT" });
|
||||
assert.ok((await lstat(deep)).isDirectory());
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("uncertain OS cleanup cannot release engine capacity", async () => {
|
||||
const budget = new RestrictedEngineBudget(1, async () => ({
|
||||
async release() {
|
||||
throw Error("kernel cleanup not confirmed");
|
||||
},
|
||||
}));
|
||||
const release = await budget.acquire(
|
||||
() => false,
|
||||
async () => {},
|
||||
);
|
||||
await assert.rejects(release(), /cleanup not confirmed/);
|
||||
assert.equal(budget.stats().engines, 1);
|
||||
await assert.rejects(
|
||||
budget.acquire(
|
||||
() => false,
|
||||
async () => {},
|
||||
),
|
||||
{ code: "RESTRICTED_ENGINE_LIMIT" },
|
||||
);
|
||||
});
|
||||
|
||||
test("parallel frames route independently and cancellation cannot retire a neighbor", async () => {
|
||||
const f = await fixture(
|
||||
registry("return await channel({portId:'read',action:{kind:'state',primitive:'read'},input:request.input});"),
|
||||
);
|
||||
try {
|
||||
const first = f.start("a"),
|
||||
second = f.start("b");
|
||||
const ports = new Map([await f.receive(), await f.receive()].map((port) => [port.invocation, port]));
|
||||
assert.equal(ports.get(first.invocation).input, "a");
|
||||
assert.equal(ports.get(second.invocation).input, "b");
|
||||
f.send({ kind: "cancel", ...first });
|
||||
assert.equal((await f.receive()).kind, "cancelled");
|
||||
f.send({
|
||||
kind: "port-result",
|
||||
...first,
|
||||
sequence: ports.get(first.invocation).sequence,
|
||||
status: "returned",
|
||||
output: "late",
|
||||
});
|
||||
f.send({
|
||||
kind: "port-result",
|
||||
...second,
|
||||
sequence: ports.get(second.invocation).sequence,
|
||||
status: "returned",
|
||||
output: "survived",
|
||||
});
|
||||
const result = await f.receive();
|
||||
assert.equal(result.invocation, second.invocation);
|
||||
assert.equal(result.output, "survived");
|
||||
assert.equal(f.io.exitCode, 0);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("A to B to A uses reserved nested capacity while both root lanes are saturated", async () => {
|
||||
const source = registry(
|
||||
"if(request.input==='leaf')return 'done'; return await channel({portId:'next',action:{kind:'state',primitive:'read'},input:request.input});",
|
||||
);
|
||||
const a = await fixture(source, { maxActive: 1, maxNested: 1, maxQueued: 1 });
|
||||
const b = await fixture(source, { maxActive: 1, maxNested: 1, maxQueued: 1 });
|
||||
const budget = (depth) => ({ root: "root-chain", depth, deadlineMs: 2000, calls: 20 });
|
||||
try {
|
||||
const parent = a.start("B", budget(1)),
|
||||
aPort = await a.receive();
|
||||
const neighbor = b.start("held", { ...budget(1), root: "other-root" });
|
||||
await b.receive();
|
||||
const child = b.start("A", budget(2)),
|
||||
bPort = await b.receive();
|
||||
const leaf = a.start("leaf", budget(3));
|
||||
const leafResult = await a.receive();
|
||||
assert.equal(leafResult.invocation, leaf.invocation);
|
||||
assert.equal(leafResult.output, "done");
|
||||
b.send({ kind: "port-result", ...child, sequence: bPort.sequence, status: "returned", output: leafResult.output });
|
||||
const childResult = await b.receive();
|
||||
a.send({
|
||||
kind: "port-result",
|
||||
...parent,
|
||||
sequence: aPort.sequence,
|
||||
status: "returned",
|
||||
output: childResult.output,
|
||||
});
|
||||
assert.equal((await a.receive()).output, "done");
|
||||
b.send({ kind: "cancel", ...neighbor });
|
||||
assert.equal((await b.receive()).kind, "cancelled");
|
||||
} finally {
|
||||
await a.close();
|
||||
await b.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the remaining root wall budget overrides a longer adapter deadline", async () => {
|
||||
const f = await fixture(
|
||||
registry("return await channel({portId:'held',action:{kind:'state',primitive:'read'},input:null});"),
|
||||
{ deadlineMs: 2000 },
|
||||
);
|
||||
try {
|
||||
const started = performance.now();
|
||||
f.start(null, { root: "short-root", depth: 2, deadlineMs: 150, calls: 10 });
|
||||
await f.receive();
|
||||
const result = await f.receive();
|
||||
assert.equal(result.error.code, "RESTRICTED_DEADLINE");
|
||||
assert.ok(performance.now() - started < 1000);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("real engine returns results and resets all globals between frames", async () => {
|
||||
const f = await fixture(
|
||||
registry(
|
||||
"globalThis.retained=(globalThis.retained??0)+1; return {count:globalThis.retained,text:new Intl.Segmenter('zh',{granularity:'word'}).segment('雨后的书店').containing(3).segment};",
|
||||
),
|
||||
);
|
||||
try {
|
||||
for (let n = 0; n < 3; n++) {
|
||||
const call = f.start();
|
||||
const result = await f.receive();
|
||||
assert.equal(result.invocation, call.invocation);
|
||||
assert.equal(result.status, "returned");
|
||||
assert.equal(result.output.count, 1);
|
||||
assert.equal(typeof result.output.text, "string");
|
||||
}
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test("captured frame is the sole source of nested port identities", async () => {
|
||||
const f = await fixture(
|
||||
registry("return await channel({portId:'read',action:{kind:'state',primitive:'read'},input:null});"),
|
||||
);
|
||||
try {
|
||||
const call = f.start();
|
||||
const port = await f.receive();
|
||||
assert.equal(port.kind, "port");
|
||||
assert.equal(port.frame, call.frame);
|
||||
assert.equal(port.invocation, call.invocation);
|
||||
f.send({ kind: "port-result", ...call, sequence: port.sequence, status: "returned", output: 42 });
|
||||
const result = await f.receive();
|
||||
assert.equal(result.status, "returned");
|
||||
assert.equal(result.output, 42);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test("authored serialization cannot forge result routing or ABI fields", async () => {
|
||||
const f = await fixture(
|
||||
registry(
|
||||
"Object.prototype.toJSON=function(){return this.status==='returned'?{status:'returned',output:42,invocation:'forged',abi:'forged'}:this;};return 42;",
|
||||
),
|
||||
);
|
||||
try {
|
||||
const call = f.start();
|
||||
const result = await f.receive();
|
||||
assert.equal(result.invocation, call.invocation);
|
||||
assert.equal(result.abi, abi);
|
||||
assert.equal(result.status, "failed");
|
||||
assert.equal(result.error.code, "RESTRICTED_PACKAGE_FAILED");
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test("the checked portable SDK keeps codecs and nested graph ports inside the frame", async () => {
|
||||
const sdk = process.env.QUIXOS_TEST_SDK;
|
||||
assert.ok(sdk, "Set QUIXOS_TEST_SDK to the checked portable SDK");
|
||||
const C = await import(sdk),
|
||||
workspace = C.workspaceId("workspace:restricted");
|
||||
const integer = { kind: "scalar", name: "int64" },
|
||||
unit = { kind: "builtin", name: "unit" };
|
||||
const declarations = [
|
||||
{
|
||||
id: "test",
|
||||
name: "test",
|
||||
effect: "snapshot",
|
||||
receiver: null,
|
||||
input: integer,
|
||||
output: integer,
|
||||
methods: [
|
||||
{
|
||||
name: "get",
|
||||
portName: "count",
|
||||
portId: "count-read",
|
||||
action: { kind: "state", primitive: "read" },
|
||||
input: unit,
|
||||
output: integer,
|
||||
keyed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const source = `import {definePortableRegistry} from 'qx:core'; export const registry=definePortableRegistry(${JSON.stringify(declarations)},{test:async ctx=>ctx.input+await ctx.ports.count.get()});`;
|
||||
const f = await fixture(source, { sdk: [{ name: "qx:core", path: sdk }] });
|
||||
try {
|
||||
const call = f.start(C.encodeArgument(integer, 2n, { workspace }));
|
||||
const port = await f.receive();
|
||||
assert.equal(port.kind, "port");
|
||||
assert.equal(C.decodeArgument(unit, port.input, { workspace }), null);
|
||||
f.send({
|
||||
kind: "port-result",
|
||||
...call,
|
||||
sequence: port.sequence,
|
||||
status: "returned",
|
||||
output: C.encodeArgument(integer, 4n, { workspace }),
|
||||
});
|
||||
const result = await f.receive();
|
||||
assert.equal(result.status, "returned");
|
||||
assert.equal(C.decodeArgument(integer, result.output, { workspace }), 6n);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test("checked handlers hash canonical values without ambient capabilities", async () => {
|
||||
const sdk = process.env.QUIXOS_TEST_SDK;
|
||||
const C = await import(sdk);
|
||||
const workspace = C.workspaceId("workspace:restricted");
|
||||
const text = { kind: "scalar", name: "string" };
|
||||
const value = {
|
||||
kind: "record",
|
||||
fields: {
|
||||
text,
|
||||
options: {
|
||||
kind: "record",
|
||||
fields: {
|
||||
level: { kind: "scalar", name: "int32" },
|
||||
enabled: { kind: "scalar", name: "bool" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const declarations = [
|
||||
{ id: "test", name: "test", effect: "snapshot", receiver: null, input: value, output: text, methods: [] },
|
||||
];
|
||||
const source = `import {definePortableRegistry,hashJson} from 'qx:core'; export const registry=definePortableRegistry(${JSON.stringify(declarations)},{test:ctx=>hashJson('test-content',ctx.input)});`;
|
||||
const f = await fixture(source, { sdk: [{ name: "qx:core", path: sdk }] });
|
||||
try {
|
||||
const input = { text: "雨后的书店", options: { level: 3, enabled: true } };
|
||||
let expected;
|
||||
for (const candidate of [input, { options: input.options, text: input.text }, { ...input, text: "別の文" }]) {
|
||||
f.start(C.encodeArgument(value, candidate, { workspace }));
|
||||
const result = await f.receive();
|
||||
assert.equal(result.status, "returned");
|
||||
const actual = C.decodeArgument(text, result.output, { workspace });
|
||||
assert.equal(actual, await C.hashJson("test-content", candidate));
|
||||
if (candidate.text === input.text) {
|
||||
expected ??= actual;
|
||||
assert.equal(actual, expected, "Object key order must not invalidate content caches");
|
||||
} else assert.notEqual(actual, expected, "Content changes must invalidate the key");
|
||||
}
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test("ambient clock, entropy, network and native imports are denied", async () => {
|
||||
for (const code of [
|
||||
"Date.now()",
|
||||
"new Date()",
|
||||
"new Intl.DateTimeFormat().format()",
|
||||
"new Intl.DateTimeFormat().formatToParts()",
|
||||
"Math.random()",
|
||||
"crypto.randomUUID()",
|
||||
"fetch('https://example.com')",
|
||||
"(await import('node:fs')).readFileSync('/etc/passwd')",
|
||||
"(await import('./unlisted.mjs')).secret",
|
||||
]) {
|
||||
const f = await fixture(registry(`return ${code};`));
|
||||
try {
|
||||
f.start();
|
||||
assert.equal((await f.receive()).status, "failed", code);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
test("explicit dates remain useful without an implicit clock", async () => {
|
||||
const f = await fixture(registry("return new Date('2026-01-01T00:00:00Z').toISOString();"));
|
||||
try {
|
||||
f.start();
|
||||
assert.equal((await f.receive()).output, "2026-01-01T00:00:00.000Z");
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test("initialization and regex loops are killed, then a fresh frame can start", async () => {
|
||||
for (const source of [
|
||||
"while(true){};" + registry("return 1;"),
|
||||
registry("return /(a+)+$/.test('a'.repeat(100000)+'!');"),
|
||||
]) {
|
||||
const f = await fixture(source, { deadlineMs: 350 });
|
||||
try {
|
||||
for (let n = 0; n < 2; n++) {
|
||||
f.start();
|
||||
const result = await f.receive();
|
||||
assert.equal(result.error.code, "RESTRICTED_DEADLINE");
|
||||
assert.ok(
|
||||
f.usage.some((value) => value.cpuMicroseconds >= 50000),
|
||||
"External watchdog did not account spinning engine CPU",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
test("cancellation kills the entire frame before acknowledgment and rejects late port replies", async () => {
|
||||
const f = await fixture(
|
||||
registry("return await channel({portId:'read',action:{kind:'state',primitive:'read'},input:null});"),
|
||||
);
|
||||
try {
|
||||
const call = f.start();
|
||||
const port = await f.receive();
|
||||
f.send({ kind: "cancel", ...call });
|
||||
const cancelled = await f.receive();
|
||||
assert.equal(cancelled.kind, "cancelled");
|
||||
f.send({ kind: "port-result", ...call, sequence: port.sequence, status: "returned", output: "late" });
|
||||
const next = f.start();
|
||||
const nextPort = await f.receive();
|
||||
assert.equal(nextPort.invocation, next.invocation);
|
||||
f.send({ kind: "port-result", ...next, sequence: nextPort.sequence, status: "returned", output: "fresh" });
|
||||
const result = await f.receive();
|
||||
assert.equal(result.output, "fresh");
|
||||
assert.equal(result.invocation, next.invocation);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test("cancellation during asynchronous preparation leaves no running frame", async () => {
|
||||
const f = await fixture(registry("return 1;"));
|
||||
try {
|
||||
for (let n = 0; n < 5; n++) {
|
||||
const call = f.start();
|
||||
f.send({ kind: "cancel", ...call });
|
||||
assert.equal((await f.receive()).kind, "cancelled");
|
||||
}
|
||||
f.start();
|
||||
assert.equal((await f.receive()).output, 1);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("bounded FIFO rejects overload without losing active or queued frames", async () => {
|
||||
const f = await fixture(
|
||||
registry("return await channel({portId:'read',action:{kind:'state',primitive:'read'},input:request.input});"),
|
||||
{ maxQueued: 2, maxActive: 1 },
|
||||
);
|
||||
try {
|
||||
const first = f.start(1),
|
||||
port = await f.receive();
|
||||
const second = f.start(2),
|
||||
third = f.start(3),
|
||||
overflow = f.start(4);
|
||||
const refused = await f.receive();
|
||||
assert.equal(refused.invocation, overflow.invocation);
|
||||
assert.equal(refused.error.code, "RESTRICTED_QUEUE_FULL");
|
||||
f.send({ kind: "cancel", ...second });
|
||||
assert.equal((await f.receive()).invocation, second.invocation);
|
||||
f.send({ kind: "port-result", ...first, sequence: port.sequence, status: "returned", output: 1 });
|
||||
assert.equal((await f.receive()).output, 1);
|
||||
const nextPort = await f.receive();
|
||||
assert.equal(nextPort.invocation, third.invocation);
|
||||
assert.equal(nextPort.input, 3);
|
||||
f.send({ kind: "port-result", ...third, sequence: nextPort.sequence, status: "returned", output: 3 });
|
||||
assert.equal((await f.receive()).output, 3);
|
||||
f.start(5);
|
||||
const fresh = await f.receive();
|
||||
f.send({
|
||||
kind: "port-result",
|
||||
invocation: fresh.invocation,
|
||||
frame: fresh.frame,
|
||||
sequence: fresh.sequence,
|
||||
status: "returned",
|
||||
output: 5,
|
||||
});
|
||||
assert.equal((await f.receive()).output, 5);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("queue wait consumes the deadline and cancelled work never reaches its ports", async () => {
|
||||
const f = await fixture(
|
||||
registry("return await channel({portId:'read',action:{kind:'state',primitive:'read'},input:null});"),
|
||||
{ deadlineMs: 4000, maxActive: 1 },
|
||||
);
|
||||
try {
|
||||
const first = f.start();
|
||||
assert.equal((await f.receive()).kind, "port");
|
||||
// Expire while the first call is deliberately blocked. Two equal relative
|
||||
// deadlines let fast startup legitimately begin the queued call first.
|
||||
const queued = f.start(null, { root: "queued-deadline", deadlineMs: 50, depth: 1, calls: 10 });
|
||||
const expired = await f.receive();
|
||||
assert.equal(expired.invocation, queued.invocation);
|
||||
assert.equal(expired.error.code, "RESTRICTED_DEADLINE");
|
||||
f.send({ kind: "cancel", ...first });
|
||||
const cancelled = await f.receive();
|
||||
assert.equal(cancelled.invocation, first.invocation);
|
||||
assert.equal(cancelled.kind, "cancelled");
|
||||
const fresh = f.start();
|
||||
const port = await f.receive();
|
||||
assert.equal(port.invocation, fresh.invocation);
|
||||
f.send({ kind: "port-result", ...fresh, sequence: port.sequence, status: "returned", output: "fresh" });
|
||||
assert.equal((await f.receive()).output, "fresh");
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("warm engines create fresh transitive module state, closures and built-ins", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "qx-transitive-"));
|
||||
const helper = join(directory, "helper.mjs");
|
||||
await writeFile(
|
||||
helper,
|
||||
"let value=0, retained; export function touch(channel){const old=retained; retained=channel; return {count:++value, captured:!!old};}",
|
||||
);
|
||||
const f = await fixture(
|
||||
`import {touch} from 'helper.mjs'; let count=0;
|
||||
export const registry={exports:['test'],async invoke(request,channel){
|
||||
const before={object:Object.prototype.retained??null,array:Array.prototype.retained??null,
|
||||
map:Map.prototype.retained??null, global:globalThis.retained??null};
|
||||
Object.prototype.retained=request.input; Array.prototype.retained=request.input;
|
||||
Map.prototype.retained=request.input; globalThis.retained=request.input;
|
||||
return {count:++count, helper:touch(channel), before};
|
||||
}};`,
|
||||
{ sdk: [{ name: "helper.mjs", path: helper }] },
|
||||
);
|
||||
try {
|
||||
for (const value of ["private graph A", "private graph B", "private graph C"]) {
|
||||
f.start(value);
|
||||
assert.deepEqual((await f.receive()).output, {
|
||||
count: 1,
|
||||
helper: { count: 1, captured: false },
|
||||
before: { object: null, array: null, map: null, global: null },
|
||||
});
|
||||
}
|
||||
assert.equal(f.stats().starts, 1, "Qualification must exercise actual engine reuse");
|
||||
assert.equal(f.stats().reused, 2);
|
||||
assert.equal(f.stats().idle, 1);
|
||||
} finally {
|
||||
await f.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("an expired frame token cannot borrow a later invocation's broker", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "qx-token-"));
|
||||
const worker = join(directory, "token-test.mjs");
|
||||
// Trusted test entrypoint deliberately replays the old token. Authored
|
||||
// registries never receive CONFIG or BROKER directly.
|
||||
await writeFile(
|
||||
worker,
|
||||
`export default {async fetch(request,env) {
|
||||
const message=await request.json();
|
||||
const output=message.input ? (await env.BROKER.fetch('http://frame/port/'+message.input,{
|
||||
method:'POST',body:JSON.stringify({portId:'read',action:{kind:'state',primitive:'read'},input:null})})).status
|
||||
: env.CONFIG.frameToken;
|
||||
return Response.json({status:'returned',output});
|
||||
}};`,
|
||||
);
|
||||
const f = await fixture(registry("return null;"), { worker });
|
||||
try {
|
||||
f.start();
|
||||
const token = (await f.receive()).output;
|
||||
assert.match(token, /^[a-f0-9-]{36}$/);
|
||||
f.start(token);
|
||||
assert.equal((await f.receive()).output, 409);
|
||||
assert.equal(f.stats().starts, 1);
|
||||
assert.equal(f.stats().reused, 1);
|
||||
} finally {
|
||||
await f.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("idle engines evict without a graph poll and restart with fresh state", async () => {
|
||||
const updates = [];
|
||||
const f = await fixture(registry("return request.input;"), {
|
||||
engineIdleMs: 30,
|
||||
onChange: () => updates.push(f.stats()),
|
||||
});
|
||||
try {
|
||||
f.start("first");
|
||||
assert.equal((await f.receive()).output, "first");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
assert.equal(f.stats().engines, 0);
|
||||
assert.ok(
|
||||
updates.some((s) => s.active > 0),
|
||||
"Inspectors observe active invocation admission",
|
||||
);
|
||||
assert.ok(
|
||||
updates.some((s) => s.idle > 0 && s.idleDeadline),
|
||||
"Idle eviction deadline is visible",
|
||||
);
|
||||
assert.equal(updates.at(-1).engines, 0, "Eviction pushes an update without a graph poll");
|
||||
f.start("second");
|
||||
assert.equal((await f.receive()).output, "second");
|
||||
assert.equal(f.stats().starts, 2);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
for (const loop of [
|
||||
"while(true) await Promise.resolve();",
|
||||
"const memory=[]; for(let n=0;n<128;n++) memory.push(new Uint8Array(256*1024)); while(true) await Promise.resolve(memory);",
|
||||
])
|
||||
test("await/allocation pressure is fenced without delaying a neighboring engine: " + loop.slice(0, 28), async () => {
|
||||
const f = await fixture(registry(`if(request.input==='spin'){${loop}} return request.input;`), {
|
||||
deadlineMs: 1500,
|
||||
});
|
||||
try {
|
||||
const stuck = f.start("spin", { root: "stuck", depth: 1, deadlineMs: 350, calls: 10 });
|
||||
const before = performance.now();
|
||||
const healthy = f.start("healthy");
|
||||
const replies = [await f.receive(), await f.receive()];
|
||||
assert.equal(replies.find((r) => r.invocation === healthy.invocation).output, "healthy");
|
||||
assert.equal(replies.find((r) => r.invocation === stuck.invocation).error.code, "RESTRICTED_DEADLINE");
|
||||
assert.equal(replies[0].invocation, healthy.invocation, "Healthy work must finish before the stuck deadline");
|
||||
assert.ok(performance.now() - before < 1200);
|
||||
f.start("after fault");
|
||||
assert.equal((await f.receive()).output, "after fault");
|
||||
assert.equal(f.stats().starts, 2, "The healthy engine survives its neighbor's termination");
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("detached microtask loops cannot escape the invocation wall deadline", async () => {
|
||||
const f = await fixture(
|
||||
registry("void(async()=>{while(true)await Promise.resolve();})(); return 'nominal return';"),
|
||||
{ deadlineMs: 200 },
|
||||
);
|
||||
try {
|
||||
const before = performance.now();
|
||||
f.start();
|
||||
assert.equal((await f.receive()).error.code, "RESTRICTED_DEADLINE");
|
||||
assert.ok(performance.now() - before < 1000);
|
||||
assert.equal(f.stats().engines, 0);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("detached digest callbacks cannot use a completed frame or contaminate reuse", async () => {
|
||||
const f = await fixture(
|
||||
registry(`
|
||||
void(async()=>{
|
||||
for(let n=0;n<100;n++)await crypto.subtle.digest('SHA-256',new Uint8Array(32768));
|
||||
await channel({portId:'late',action:{kind:'state',primitive:'read'},input:request.input});
|
||||
})().catch(()=>{});
|
||||
return request.input;
|
||||
`),
|
||||
);
|
||||
try {
|
||||
f.start("private A");
|
||||
assert.equal((await f.receive()).output, "private A");
|
||||
await new Promise((resolve) => setTimeout(resolve, 75));
|
||||
f.start("private B");
|
||||
const reply = await f.receive();
|
||||
assert.equal(reply.status, "returned", "No late graph port may appear after completion");
|
||||
assert.equal(reply.output, "private B");
|
||||
assert.equal(f.stats().starts, 1);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the shared engine budget evicts idle code families and rejects busy oversubscription", async () => {
|
||||
const engineBudget = new RestrictedEngineBudget(1);
|
||||
const a = await fixture(registry("return request.input;"), { engineBudget });
|
||||
const b = await fixture(
|
||||
registry("return await channel({portId:'read',action:{kind:'state',primitive:'read'},input:null});"),
|
||||
{ engineBudget },
|
||||
);
|
||||
try {
|
||||
a.start("first family");
|
||||
assert.equal((await a.receive()).output, "first family");
|
||||
const busy = b.start();
|
||||
const port = await b.receive();
|
||||
assert.equal(a.stats().engines, 0, "Idle foreign code is evicted before allocating another engine");
|
||||
assert.equal(engineBudget.stats().engines, 1);
|
||||
a.start("overload");
|
||||
assert.equal((await a.receive()).error.code, "RESTRICTED_ENGINE_LIMIT");
|
||||
assert.equal(engineBudget.stats().engines, 1);
|
||||
b.send({ kind: "port-result", ...busy, sequence: port.sequence, status: "returned", output: "survived" });
|
||||
assert.equal((await b.receive()).output, "survived");
|
||||
} finally {
|
||||
await Promise.all([a.close(), b.close()]);
|
||||
}
|
||||
assert.equal(engineBudget.stats().engines, 0);
|
||||
});
|
||||
|
||||
test("engine retirement accounting keeps its admission lease busy", async () => {
|
||||
const observations = [];
|
||||
let idle;
|
||||
const engineBudget = new RestrictedEngineBudget(1, async () => ({
|
||||
usage: async () => {
|
||||
observations.push(idle());
|
||||
await new Promise((done) => setImmediate(done));
|
||||
return { cpuMicroseconds: 0, memoryBytes: 1024 };
|
||||
},
|
||||
release: async () => {},
|
||||
}));
|
||||
const acquire = engineBudget.acquire.bind(engineBudget);
|
||||
engineBudget.acquire = (isIdle, evict) => {
|
||||
idle = isIdle;
|
||||
return acquire(isIdle, evict);
|
||||
};
|
||||
const f = await fixture(registry("return request.input;"), { engineBudget });
|
||||
try {
|
||||
f.start("first");
|
||||
assert.equal((await f.receive()).output, "first");
|
||||
assert.ok(observations.length > 0);
|
||||
assert.ok(
|
||||
observations.every((value) => !value),
|
||||
"An engine must not become evictable during asynchronous retirement accounting",
|
||||
);
|
||||
assert.equal(f.stats().idle, 1);
|
||||
assert.equal(idle(), true);
|
||||
f.start("second");
|
||||
assert.equal((await f.receive()).output, "second");
|
||||
assert.equal(f.stats().starts, 1);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
assert.equal(engineBudget.stats().engines, 0);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
// Platform entrypoint, evaluated before loading the authored registry. A fresh
|
||||
// Worker owns exactly one frame. The parent engine is reusable; BROKER's host
|
||||
// token grants only this frame and expires before the next invocation.
|
||||
const monotonic = performance.now.bind(performance);
|
||||
const deny = () => {
|
||||
throw Error("RESTRICTED_AMBIENT_ACCESS");
|
||||
};
|
||||
const replace = (object, key, value) =>
|
||||
Object.defineProperty(object, key, { value, configurable: false, writable: false });
|
||||
const NativeDate = Date;
|
||||
const FixedDate = new Proxy(NativeDate, {
|
||||
construct(target, args) {
|
||||
if (!args.length) deny();
|
||||
return Reflect.construct(target, args, target);
|
||||
},
|
||||
apply() {
|
||||
deny();
|
||||
},
|
||||
});
|
||||
replace(NativeDate, "now", deny);
|
||||
replace(NativeDate.prototype, "constructor", FixedDate);
|
||||
replace(globalThis, "Date", FixedDate);
|
||||
replace(Math, "random", deny);
|
||||
// Identity/value codecs need deterministic SHA-256, never entropy or key creation.
|
||||
const digest = crypto.subtle.digest.bind(crypto.subtle);
|
||||
replace(globalThis, "crypto", Object.freeze({ subtle: Object.freeze({ digest }) }));
|
||||
for (const name of ["performance", "Temporal", "scheduler", "WebSocket", "EventSource", "SharedArrayBuffer", "Atomics"])
|
||||
replace(globalThis, name, undefined);
|
||||
for (const name of ["setTimeout", "setInterval", "fetch"]) replace(globalThis, name, deny);
|
||||
// Intl's no-argument format defaults to the clock without consulting Date.now.
|
||||
const format = Object.getOwnPropertyDescriptor(Intl.DateTimeFormat.prototype, "format").get;
|
||||
Object.defineProperty(Intl.DateTimeFormat.prototype, "format", {
|
||||
configurable: false,
|
||||
get() {
|
||||
const bound = format.call(this);
|
||||
return (value) => {
|
||||
if (value === undefined) deny();
|
||||
return bound(value);
|
||||
};
|
||||
},
|
||||
});
|
||||
const formatToParts = Intl.DateTimeFormat.prototype.formatToParts;
|
||||
replace(Intl.DateTimeFormat.prototype, "formatToParts", function (value) {
|
||||
if (value === undefined) deny();
|
||||
return formatToParts.call(this, value);
|
||||
});
|
||||
let used = false;
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
if (used) return new Response("FRAME_EXPIRED", { status: 409 });
|
||||
used = true;
|
||||
const input = await request.json();
|
||||
const loading = monotonic();
|
||||
const source = await import("./package.mjs");
|
||||
const initializationMs = monotonic() - loading;
|
||||
const registry = source[env.CONFIG.registryExport];
|
||||
if (
|
||||
!registry ||
|
||||
typeof registry.invoke !== "function" ||
|
||||
JSON.stringify(registry.exports) !== JSON.stringify(env.CONFIG.exports)
|
||||
)
|
||||
throw Error("RESTRICTED_REGISTRY_MISMATCH");
|
||||
let open = true;
|
||||
const channel = async (value) => {
|
||||
if (!open) throw Error("EXPIRED_CONTEXT");
|
||||
const response = await env.BROKER.fetch("http://frame/port/" + env.CONFIG.frameToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(value),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!open) throw Error("EXPIRED_CONTEXT");
|
||||
if (result.status !== "returned")
|
||||
throw Object.assign(Error(result.error?.message ?? "Port failed"), {
|
||||
code: result.error?.code ?? "PORT_FAILED",
|
||||
});
|
||||
return result.output;
|
||||
};
|
||||
const running = monotonic();
|
||||
try {
|
||||
const output = await registry.invoke(
|
||||
{ exportId: input.exportId, workspace: input.workspace, receiver: input.receiver, input: input.input },
|
||||
channel,
|
||||
);
|
||||
const body = JSON.stringify({ status: "returned", output });
|
||||
if (new TextEncoder().encode(body).length > env.CONFIG.maxBytes) throw Error("RESTRICTED_OUTPUT_LIMIT");
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-quixos-initialization-ms": String(initializationMs),
|
||||
"x-quixos-execution-ms": String(monotonic() - running),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Only authored/codec rejection codes cross the engine boundary. Engine
|
||||
// diagnostics stay private to the supervisor, never become protocol data.
|
||||
const code =
|
||||
typeof error?.code === "string" && /^[A-Z0-9_]{1,128}$/.test(error.code)
|
||||
? error.code
|
||||
: "RESTRICTED_PACKAGE_FAILED";
|
||||
return Response.json({ status: "failed", error: { code, message: code } });
|
||||
} finally {
|
||||
open = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
# Pinned upstream engine. This binary supplies an import/capability boundary;
|
||||
# production execution additionally requires the qualified OS identity,
|
||||
# namespace, syscall, cgroup and invocation-broker boundaries.
|
||||
{ pkgs }:
|
||||
let
|
||||
version = "1.20261005.1";
|
||||
platforms = {
|
||||
x86_64-linux = {
|
||||
name = "linux-64";
|
||||
hash = "sha256-zm0T3YLo49ORPwR4iwP+WkQBMkiZQYftc+ArBQEoNGY=";
|
||||
};
|
||||
aarch64-linux = {
|
||||
name = "linux-arm64";
|
||||
hash = "sha256-jkvHyexr8Sn2wyqmLJJEOOGfAkfaVPLI7llHOX+FbxU=";
|
||||
};
|
||||
};
|
||||
platform =
|
||||
platforms.${pkgs.stdenv.hostPlatform.system}
|
||||
or (throw "Restricted execution requires Linux x86_64 or aarch64");
|
||||
in
|
||||
pkgs.stdenvNoCC.mkDerivation {
|
||||
pname = "quixos-workerd";
|
||||
inherit version;
|
||||
src = pkgs.fetchurl {
|
||||
url = "https://registry.npmjs.org/@cloudflare/workerd-${platform.name}/-/workerd-${platform.name}-${version}.tgz";
|
||||
inherit (platform) hash;
|
||||
};
|
||||
nativeBuildInputs = [ pkgs.autoPatchelfHook ];
|
||||
buildInputs = [ pkgs.stdenv.cc.cc.lib ];
|
||||
sourceRoot = "package";
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
install -Dm755 bin/workerd "$out/bin/workerd"
|
||||
runHook postInstall
|
||||
'';
|
||||
doInstallCheck = true;
|
||||
installCheckPhase = ''
|
||||
"$out/bin/workerd" --version | grep -Fx 'workerd 2026-10-05'
|
||||
'';
|
||||
meta = {
|
||||
description = "Pinned workerd engine for Quixos restricted execution";
|
||||
homepage = "https://github.com/cloudflare/workerd";
|
||||
license = pkgs.lib.licenses.asl20;
|
||||
platforms = builtins.attrNames platforms;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Language bridge to the shared socket policy; identities contain no credentials.
|
||||
import {
|
||||
checkedSocketPath,
|
||||
prepareSocketDirectory,
|
||||
removeEmptySocketDirectory,
|
||||
socketAddress,
|
||||
} from "./socket-address.mjs";
|
||||
|
||||
const [operation, role = "bootstrap"] = process.argv.slice(2);
|
||||
if (
|
||||
!["check-path", "prepare", "prepare-directory", "address", "cleanup"].includes(operation) ||
|
||||
process.argv.length > 4 ||
|
||||
(process.argv.length === 4 && !["prepare", "address"].includes(operation))
|
||||
)
|
||||
throw Error("Select a socket operation and an optional address role");
|
||||
const chunks = [];
|
||||
let length = 0;
|
||||
for await (const chunk of process.stdin) {
|
||||
length += chunk.length;
|
||||
if (length > 1024 * 1024) throw Error("Socket command input exceeds its bound");
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const identity = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)));
|
||||
switch (operation) {
|
||||
case "check-path":
|
||||
console.log(checkedSocketPath(identity));
|
||||
break;
|
||||
case "prepare": {
|
||||
// Validate the role before creating any directories.
|
||||
const address = socketAddress(identity, role);
|
||||
await prepareSocketDirectory(identity);
|
||||
console.log(address);
|
||||
break;
|
||||
}
|
||||
case "prepare-directory":
|
||||
console.log(await prepareSocketDirectory(identity));
|
||||
break;
|
||||
case "address":
|
||||
console.log(socketAddress(identity, role));
|
||||
break;
|
||||
case "cleanup":
|
||||
await removeEmptySocketDirectory(identity);
|
||||
break;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{ pkgs }:
|
||||
let
|
||||
code = pkgs.runCommand "quixos-socket-address-code" { } ''
|
||||
mkdir "$out"
|
||||
cp ${./socket-address-cli.mjs} "$out/socket-address-cli.mjs"
|
||||
cp ${./socket-address.mjs} "$out/socket-address.mjs"
|
||||
'';
|
||||
in
|
||||
pkgs.writeShellApplication {
|
||||
name = "quixos-socket-address";
|
||||
text = ''
|
||||
exec ${pkgs.nodejs_24}/bin/node --max-old-space-size=64 ${code}/socket-address-cli.mjs "$@"
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type SocketOptions = { uid?: number; root?: string };
|
||||
export const socketPathBytes: number;
|
||||
export function checkedSocketPath(value: unknown): string;
|
||||
export function socketDirectory(identity: unknown, options?: SocketOptions): string;
|
||||
export function socketAddress(identity: unknown, role: string, options?: SocketOptions): string;
|
||||
export function prepareSocketDirectory(identity: unknown, options?: SocketOptions): Promise<string>;
|
||||
export function prepareSocketParent(socket: string): Promise<string>;
|
||||
export function removeEmptySocketDirectory(identity: unknown, options?: SocketOptions): Promise<void>;
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export const socketPathBytes = 100;
|
||||
|
||||
function canonical(value, ancestors = new Set()) {
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
|
||||
if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
|
||||
if (!value || typeof value !== "object" || ancestors.has(value) || ancestors.size >= 16)
|
||||
throw Error("Socket identity must be finite, acyclic JSON");
|
||||
if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
|
||||
throw Error("Socket identity must use plain JSON objects");
|
||||
ancestors.add(value);
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
if (
|
||||
Object.keys(value).length !== value.length ||
|
||||
!Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean)
|
||||
)
|
||||
throw Error("Socket identity cannot contain sparse arrays");
|
||||
return `[${value.map((entry) => canonical(entry, ancestors)).join(",")}]`;
|
||||
}
|
||||
return `{${Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${canonical(value[key], ancestors)}`)
|
||||
.join(",")}}`;
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function checkedSocketPath(value) {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!path.isAbsolute(value) ||
|
||||
value.includes("\0") ||
|
||||
path.normalize(value) !== value ||
|
||||
Buffer.byteLength(value) > socketPathBytes
|
||||
)
|
||||
throw Error(`Unix socket address must be absolute and at most ${socketPathBytes} bytes`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function settings(options = {}) {
|
||||
const uid = options.uid ?? process.getuid?.();
|
||||
if (!Number.isInteger(uid) || uid < 0 || uid > 4294967295)
|
||||
throw Error("Socket namespace requires an explicit Unix UID");
|
||||
const root = options.root ?? `/tmp/qx-${uid}`;
|
||||
if (
|
||||
typeof root !== "string" ||
|
||||
!path.isAbsolute(root) ||
|
||||
path.normalize(root) !== root ||
|
||||
root.includes("\0") ||
|
||||
Buffer.byteLength(root) > 18
|
||||
)
|
||||
throw Error("Socket runtime root must be an explicit absolute path of at most18 bytes");
|
||||
return { uid, root };
|
||||
}
|
||||
|
||||
/** Public deterministic locator, never a capability or ownership claim. The
|
||||
* caller supplies its admitted owner/lifetime; guest input grants no authority. */
|
||||
export function socketDirectory(identity, options) {
|
||||
const { uid, root } = settings(options);
|
||||
const input = canonical(["quixos-unix-socket-v1", uid, identity]);
|
||||
if (Buffer.byteLength(input) > 1024 * 1024) throw Error("Socket identity exceeds its bound");
|
||||
// Preserve all 256 bits while leaving room for protocol-added socket suffixes.
|
||||
return path.join(root, createHash("sha256").update(input).digest("base64url"));
|
||||
}
|
||||
|
||||
export function socketAddress(identity, role, options) {
|
||||
if (typeof role !== "string" || role !== role.trim() || !/^[a-z][a-z0-9-]{0,10}$/.test(role))
|
||||
throw Error("Invalid bounded socket role");
|
||||
return checkedSocketPath(path.join(socketDirectory(identity, options), role + ".sock"));
|
||||
}
|
||||
|
||||
async function privateDirectory(directory, uid) {
|
||||
await fs.mkdir(directory, { mode: 0o700 }).catch((error) => {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
});
|
||||
const stat = await fs.lstat(directory);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== uid || stat.mode & 0o077)
|
||||
throw Error("Socket runtime directory must already be owned and private");
|
||||
}
|
||||
|
||||
/** Validate ancestors before creating the private endpoint directory. A
|
||||
* predictable name is safe only inside a filesystem namespace we actually own. */
|
||||
export async function prepareSocketParent(socket) {
|
||||
const directory = path.dirname(checkedSocketPath(socket)),
|
||||
uid = process.getuid?.();
|
||||
if (!Number.isInteger(uid)) throw Error("Socket namespace requires a Unix principal");
|
||||
let current = "/";
|
||||
const components = directory.split("/").filter(Boolean);
|
||||
for (let index = 0; index < components.length; index++) {
|
||||
current = path.join(current, components[index]);
|
||||
await fs.mkdir(current, { mode: 0o700 }).catch((error) => {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
});
|
||||
let stat = await fs.lstat(current);
|
||||
if (current === "/tmp" && stat.isSymbolicLink() && stat.uid === 0) stat = await fs.stat(current); // macOS's OS-owned /tmp alias.
|
||||
const final = index === components.length - 1;
|
||||
// The OS-provided sticky /tmp, like / itself, is a bootstrap trust anchor.
|
||||
// Nix user namespaces can report its owner as an unmapped UID. This does
|
||||
// not admit arbitrary foreign sticky directories or relax child ownership.
|
||||
const systemTemporary = current === "/tmp" && Boolean(stat.mode & 0o1000);
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
(!systemTemporary && ![0, uid].includes(stat.uid)) ||
|
||||
(stat.mode & 0o022 && !(stat.mode & 0o1000)) ||
|
||||
(final && (stat.uid !== uid || stat.mode & 0o077))
|
||||
)
|
||||
throw Error("Socket directory ancestry must be owned and protected");
|
||||
}
|
||||
return directory;
|
||||
}
|
||||
|
||||
/** Materialize ordinary IPC directories only. No registry, claim, lock or
|
||||
* socket unlink occurs here; existing transport ownership still controls bind. */
|
||||
export async function prepareSocketDirectory(identity, options) {
|
||||
const { uid, root } = settings(options);
|
||||
if (uid !== process.getuid?.()) throw Error("Cannot prepare another Unix principal's socket namespace");
|
||||
const directory = socketDirectory(identity, options);
|
||||
await prepareSocketParent(path.join(root, "probe.sock"));
|
||||
await privateDirectory(directory, uid);
|
||||
return directory;
|
||||
}
|
||||
|
||||
export async function removeEmptySocketDirectory(identity, options) {
|
||||
const { uid, root } = settings(options),
|
||||
directory = socketDirectory(identity, options);
|
||||
if (uid !== process.getuid?.()) throw Error("Cannot clean another Unix principal's socket namespace");
|
||||
const parent = await fs.lstat(root).catch((error) => {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
return null;
|
||||
});
|
||||
if (!parent) return;
|
||||
if (!parent.isDirectory() || parent.isSymbolicLink() || parent.uid !== uid || parent.mode & 0o077)
|
||||
throw Error("Socket cleanup parent ownership differs");
|
||||
const stat = await fs.lstat(directory).catch((error) => {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
return null;
|
||||
});
|
||||
if (!stat) return;
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== uid || stat.mode & 0o077)
|
||||
throw Error("Socket cleanup directory ownership differs");
|
||||
await fs.rmdir(directory).catch((error) => {
|
||||
if (!["ENOTEMPTY", "ENOENT"].includes(error.code)) throw error;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Tiny generated-project interface fixture. Real offline Yarn installation
|
||||
# remains covered by the consuming Studio and Package-builder checks.
|
||||
{ stdenvNoCC, nodejs }:
|
||||
{ src, overrideAttrs ? null }:
|
||||
let
|
||||
project = stdenvNoCC.mkDerivation {
|
||||
name = "yarn-dependencies-transfer-fixture";
|
||||
inherit src;
|
||||
configurePhase = ''
|
||||
mkdir -p node_modules/fixture node_modules/.bin .yarn/cache
|
||||
printf '%s\n' '{"main":"index.cjs"}' > node_modules/fixture/package.json
|
||||
printf '%s\n' 'module.exports = {value: "retained dependency"};' > node_modules/fixture/index.cjs
|
||||
ln node_modules/fixture/index.cjs node_modules/fixture/twin.cjs
|
||||
ln -s fixture node_modules/alias
|
||||
printf '%s\n' '#!${nodejs}/bin/node' 'console.log("linked executable");' > node_modules/fixture/cli.cjs
|
||||
chmod +x node_modules/fixture/cli.cjs
|
||||
ln -s ../fixture/cli.cjs node_modules/.bin/fixture
|
||||
printf '%s\n' 'hidden dependency data' > node_modules/.fixture-data
|
||||
printf '%s\n' 'disposable linker state' > node_modules/.yarn-state.yml
|
||||
printf '%s\n' 'disposable archive' > .yarn/cache/fixture.zip
|
||||
'';
|
||||
postInstall = ''
|
||||
test ! -e node_modules
|
||||
test ! -e .yarn/cache
|
||||
test ! -e "$out/.yarn-state.yml"
|
||||
test ! -e "$out/node_modules"
|
||||
test "$(stat -c %i "$out/fixture/index.cjs")" = "$(stat -c %i "$out/fixture/twin.cjs")"
|
||||
'';
|
||||
};
|
||||
in
|
||||
if overrideAttrs == null then project else project.overrideAttrs overrideAttrs
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
pkgs,
|
||||
context,
|
||||
src,
|
||||
entry,
|
||||
targets ? [
|
||||
"browser"
|
||||
"server"
|
||||
],
|
||||
entries ? { },
|
||||
bindingOutput ? "src/gen/qx.ts",
|
||||
dependencies ? null,
|
||||
}:
|
||||
let
|
||||
group = import ./artifacts.nix {
|
||||
inherit pkgs src;
|
||||
inherit (context)
|
||||
buildContract
|
||||
core
|
||||
sharedRuntime
|
||||
typescript
|
||||
packageRevisionId
|
||||
;
|
||||
protocol = context.generator;
|
||||
group = true;
|
||||
nodeModules = dependencies;
|
||||
portable = {
|
||||
inherit
|
||||
entry
|
||||
entries
|
||||
targets
|
||||
bindingOutput
|
||||
;
|
||||
};
|
||||
};
|
||||
package = import ./assemble-package.nix {
|
||||
inherit pkgs context;
|
||||
artifacts = [ group ];
|
||||
};
|
||||
in
|
||||
package // { caminoGroup = group; }
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
pkgs,
|
||||
context,
|
||||
src,
|
||||
entry,
|
||||
bindingOutput ? "src/gen/remote.ts",
|
||||
dependencies ? null,
|
||||
service ? { },
|
||||
}:
|
||||
let
|
||||
group = import ./artifacts.nix {
|
||||
inherit pkgs src service;
|
||||
inherit (context)
|
||||
buildContract
|
||||
core
|
||||
sharedRuntime
|
||||
typescript
|
||||
packageRevisionId
|
||||
;
|
||||
protocol = context.generator;
|
||||
group = true;
|
||||
nodeModules = dependencies;
|
||||
remote = { inherit entry bindingOutput; };
|
||||
};
|
||||
package = import ./assemble-package.nix {
|
||||
inherit pkgs context;
|
||||
artifacts = [ group ];
|
||||
};
|
||||
in
|
||||
package // { caminoGroup = group; }
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
pkgs,
|
||||
src,
|
||||
nodejs ? pkgs.nodejs_24,
|
||||
}:
|
||||
let
|
||||
project = (pkgs.callPackage (src + "/yarn-project.nix") { inherit nodejs; }) {
|
||||
inherit src;
|
||||
overrideAttrs = _: {
|
||||
buildPhase = "runHook preBuild; runHook postBuild";
|
||||
installPhase = ''
|
||||
set -o pipefail
|
||||
runHook preInstall
|
||||
test -d node_modules
|
||||
test ! -L node_modules
|
||||
mkdir -p "$out"
|
||||
# Installation is finished: retire its disposable archive cache, then
|
||||
# stream dependencies across the build/store mount boundary without
|
||||
# retaining a second complete tree. This is the same installation
|
||||
# policy used by Central's dependency derivations.
|
||||
rm -rf .yarn/cache
|
||||
${pkgs.gnutar}/bin/tar --create --file=- --remove-files node_modules \
|
||||
| ${pkgs.gnutar}/bin/tar --extract --file=- --strip-components=1 --directory="$out"
|
||||
# Node resolves installed packages directly. This generated index is for
|
||||
# Yarn's linker, not a runtime input; do not export build-session state.
|
||||
rm -f "$out/.yarn-state.yml"
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
};
|
||||
in
|
||||
project
|
||||
// {
|
||||
devShell = pkgs.mkShell {
|
||||
packages = [
|
||||
nodejs
|
||||
project.yarn-freestanding
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{ pkgs }:
|
||||
let
|
||||
dependencies = import ./yarn-dependencies.nix {
|
||||
inherit pkgs;
|
||||
src = ./test/yarn-dependencies;
|
||||
};
|
||||
in
|
||||
pkgs.runCommand "quixos-yarn-dependencies-check" { nativeBuildInputs = [ pkgs.nodejs_24 ]; } ''
|
||||
node - ${dependencies} <<'JS'
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {execFileSync} = require('node:child_process');
|
||||
const root = process.argv[2];
|
||||
assert.deepEqual(require(path.join(root, 'fixture')), {value: 'retained dependency'});
|
||||
assert.deepEqual(require(path.join(root, 'alias')), {value: 'retained dependency'});
|
||||
assert.equal(fs.readlinkSync(path.join(root, 'alias')), 'fixture');
|
||||
assert.equal(fs.readlinkSync(path.join(root, '.bin/fixture')), '../fixture/cli.cjs');
|
||||
assert.equal(execFileSync(path.join(root, '.bin/fixture'), {encoding: 'utf8'}), 'linked executable\n');
|
||||
assert.equal(fs.readFileSync(path.join(root, '.fixture-data'), 'utf8'), 'hidden dependency data\n');
|
||||
assert.equal(fs.existsSync(path.join(root, '.yarn-state.yml')), false);
|
||||
JS
|
||||
touch "$out"
|
||||
''
|
||||
@@ -0,0 +1,22 @@
|
||||
{ pkgs }:
|
||||
let
|
||||
yarnCli = pkgs.fetchurl {
|
||||
url = "https://repo.yarnpkg.com/4.18.0/packages/yarnpkg-cli/bin/yarn.js";
|
||||
hash = "sha512-/Lhxb+fNDuzhQf/Bi5IZOp35IEwbqDGJwoiDUiP8C75kr0c7qw1emSen2utcryuwfrJ4fMkzjKBA6hJfKh8vfg==";
|
||||
};
|
||||
yarn = pkgs.writeShellScriptBin "yarn" ''
|
||||
exec ${pkgs.nodejs_24}/bin/node ${yarnCli} "$@"
|
||||
'';
|
||||
in
|
||||
pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.nodejs_24
|
||||
yarn
|
||||
];
|
||||
shellHook = ''
|
||||
if [ ! -f .yarn/plugins/yarn-plugin-nixify.cjs ]; then
|
||||
mkdir -p .yarn/plugins
|
||||
cp ${./.yarn/plugins/yarn-plugin-nixify.cjs} .yarn/plugins/yarn-plugin-nixify.cjs
|
||||
fi
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user