Compare commits

...
Author SHA1 Message Date
Quixos Subtree Publisher c2454054da Publish quixos-nix-helpers from 99248f90aba2ef64f089244a909159d432efb8af 2026-10-29 12:30:56 +00:00
Quixos Development Agent 01f26965-84fa-4a79-8069-725682c13065 a962586381 Retire staged Yarn dependencies during Nix installation (#473) 2026-10-29 12:30:56 +00:00
Quixos Development Agent 01f26965-84fa-4a79-8069-725682c13065 43fcf6b27a Bound ordinary Restricted Execution engine socket directories (#503) 2026-10-29 12:30:56 +00:00
Quixos Development Agent 01f26965-84fa-4a79-8069-725682c13065 3bb4324aa0 Budget OpenSSH temporary sockets in the shared address policy (#503) 2026-10-29 12:30:56 +00:00
Quixos Development Agent 01f26965-84fa-4a79-8069-725682c13065 df9f50ea31 Reuse the canonical language bridge in deep Agent tmux fixtures (#503) 2026-10-29 12:30:56 +00:00
Quixos Development Agent 01f26965-84fa-4a79-8069-725682c13065 4a87fc2c7d Validate complete engine addresses and retain shared socket inputs (#503) 2026-10-29 12:30:55 +00:00
Quixos Development Agent 01f26965-84fa-4a79-8069-725682c13065 97ce77d3de Share the canonical socket implementation with public Nix helpers (#503) 2026-10-29 12:30:55 +00:00
Quixos Subtree Publisher 5657043450 Publish quixos-nix-helpers from e4393946ba63cfef75580f6aa6ba840035e7d43c 2026-10-13 10:19:49 +00:00
Timothy J. Aveni 9797799e83 Compose independent native Central and System development lifecycle; qualify checked Graph and VM providers 2026-10-12 02:19:34 -07:00
Timothy J. Aveni f67294f26d Qualify hosted development and compose service ownership 2026-10-11 13:53:36 -07:00
Quixos Subtree Publisher eb38d91c1b Publish quixos-nix-helpers from 358e649a7d70828db2ef96d5fed18931e48bc498 2026-10-10 15:37:44 +00:00
Timothy J. Aveni 6e591d5588 Make Node dependency outputs reproducible without build caches 2026-10-10 08:37:44 -07:00
15 changed files with 554 additions and 39 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
{
"version": 1,
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos",
"sourceCommit": "286ada5419c4fc2184b387cea4715fae1a74bde5",
"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"
}
+25
View File
@@ -61,6 +61,31 @@ with their own versions. Authored source symlinks and non-bundled portable impor
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
+15 -12
View File
@@ -20,17 +20,9 @@ let
runtime = nativeProbe;
executable = "${nativeProbe}/bin/probe";
};
identityLauncher =
pkgs.runCommand "quixos-restricted-identity-launcher"
{
nativeBuildInputs = [ pkgs.stdenv.cc ];
}
''
mkdir -p "$out/bin"
$CC -O2 -Wall -Wextra -Werror ${./identity-launcher.c} \
-DSANDBOX_EXECUTABLE='"${sandbox}/bin/quixos-restricted-sandbox"' \
-o "$out/bin/quixos-restricted-engine"
'';
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 ]; } ''
@@ -49,6 +41,8 @@ let
''
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" \
@@ -56,6 +50,13 @@ let
--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"
{
@@ -64,7 +65,7 @@ let
QUIXOS_TEST_SDK = "${sdk}/core.mjs";
}
''
node --test ${./.}/service.test.mjs
node --test ${qualificationSource}/restricted-execution/service.test.mjs
touch "$out"
'';
in
@@ -73,9 +74,11 @@ in
workerd
sandbox
identityLauncher
mkIdentityLauncher
nativeProbeSandbox
sdk
runtime
qualificationSource
qualification
;
}
+4 -4
View File
@@ -20,17 +20,17 @@ static void fail(const char *message) {
}
int main(int argc, char **argv) {
const char *root = "/run/quixos-restricted-engines/";
const char *root = ENGINE_DIRECTORY "/";
uid_t caller = getuid();
gid_t group = getgid();
struct passwd *owner = getpwnam("quixos-graph");
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 < 610000 || number >= 610064)
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)))
@@ -39,7 +39,7 @@ int main(int argc, char **argv) {
if (strncmp(name, "qx-engine-", 10) ||
strlen(name) != 16 || strspn(name + 10, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") != 6)
fail("invalid engine directory");
int parent = open("/run/quixos-restricted-engines", O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
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");
@@ -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"
''
+29 -7
View File
@@ -2,23 +2,45 @@
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 < 610000 || user.uid >= 610064) (
builtins.attrValues config.users.users
);
message = "UIDs 610000–610063 are reserved for restricted engine identities.";
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 /run/quixos-restricted-engines 0711 quixos-graph ${controlGroup} -"
"d ${engineDirectory} 0711 ${authorityUser} ${controlGroup} -"
];
security.wrappers.quixos-restricted-engine = {
source = "${executor.identityLauncher}/bin/quixos-restricted-engine";
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";
+106 -10
View File
@@ -1,12 +1,45 @@
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 } from "node:fs/promises";
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) =>
@@ -301,15 +334,57 @@ export async function serveRestrictedRegistry(config, io = process) {
}
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));
try {
if (engine.directory) await rm(engine.directory, { recursive: true, force: true });
} finally {
engines.delete(engine);
await engine.release?.();
changed();
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;
};
@@ -333,10 +408,27 @@ export async function serveRestrictedRegistry(config, io = process) {
() => destroyEngine(engine),
);
if (!call.open) return;
engine.directory = await mkdtemp(join(tmpdir(), "qx-engine-"));
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 = join(engine.directory, "broker.sock");
engine.socket = join(engine.directory, "engine.sock");
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;
@@ -382,10 +474,14 @@ export async function serveRestrictedRegistry(config, io = process) {
}
});
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 };
+47 -2
View File
@@ -1,11 +1,12 @@
import test from "node:test";
import assert from "node:assert/strict";
import { PassThrough } from "node:stream";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { mkdtemp, writeFile, rm, mkdir, readdir, lstat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
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");
@@ -95,6 +96,50 @@ async function fixture(source, options = {}) {
}
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() {
+44
View File
@@ -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;
}
+14
View File
@@ -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 "$@"
'';
}
+8
View File
@@ -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>;
+150
View File
@@ -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;
});
}
+31
View File
@@ -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
+12 -1
View File
@@ -9,10 +9,21 @@ let
overrideAttrs = _: {
buildPhase = "runHook preBuild; runHook postBuild";
installPhase = ''
set -o pipefail
runHook preInstall
test -d node_modules
test ! -L node_modules
mkdir -p "$out"
cp -a node_modules/. "$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
'';
};
+24
View File
@@ -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"
''