Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ Current supervisor options:
Supervisor state:

- default state directory: `<projectRoot>/supervisor`
- shared child auth keys: `auth/auth_keys.json`, unless `SPACE_AUTH_PASSWORD_SEAL_KEY` and `SPACE_AUTH_SESSION_HMAC_KEY` are already injected
- shared child auth keys: the same server-side auth store used by `node space user create`, which defaults to `server/data/auth_keys.json` unless `SPACE_AUTH_PASSWORD_SEAL_KEY` and `SPACE_AUTH_SESSION_HMAC_KEY` are already injected
- staged source releases: `releases/<revision>/`

The supervisor intentionally avoids changing `server/` lifecycle code. Its only runtime assumptions about a child are that `node space serve` prints the existing listening URL line and that `/api/health` succeeds after startup.
Expand Down
7 changes: 4 additions & 3 deletions commands/lib/supervisor/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ The supervisor keeps `server/` agnostic: it does not require server-owned cluste

Current files:

- `auth_keys.js`: supervisor-owned shared auth-key storage and environment injection for child servers
- `auth_keys.js`: supervisor auth-key loading and environment injection for child servers
- `child_process.js`: `space serve` child startup, readiness detection, health checking, crash/stop handling, and proxied-stream activity tracking
- `git_releases.js`: Git remote polling, release cloning, dependency installation, and release metadata
- `http_proxy.js`: public HTTP and upgrade proxying to the active child
Expand All @@ -32,7 +32,7 @@ Stable behavior:
- supervisor state defaults to `<projectRoot>/supervisor`
- staged releases live under that project-root supervisor directory, separate from both the live source files and `CUSTOMWARE_PATH`
- auto-update polling uses `--auto-update-interval`, defaults to `300` seconds, and is disabled when the interval is less than or equal to `0`
- auth keys are either inherited from `SPACE_AUTH_PASSWORD_SEAL_KEY` and `SPACE_AUTH_SESSION_HMAC_KEY` or generated once under supervisor state and injected into every child
- auth keys are either inherited from `SPACE_AUTH_PASSWORD_SEAL_KEY` and `SPACE_AUTH_SESSION_HMAC_KEY` or loaded from the shared server-side auth store and injected into every child
- `supervise` should stay independent from server runtime-param parsing so new `space serve` flags can flow through without a supervisor-specific change
- the watched update repository is resolved in shared order: `--remote-url`, then `GIT_URL`, then the local `origin` remote URL, then the canonical fallback
- GitHub update checks and staged release clones use the same `SPACE_GITHUB_TOKEN` auth rule as `node space update`, and send no GitHub auth header when that variable is unset
Expand All @@ -49,9 +49,10 @@ The public command owns argument parsing. Helper modules should receive normaliz

Runtime state written by this subtree:

- `<projectRoot>/supervisor/auth/auth_keys.json` by default, unless auth keys are injected through environment variables
- `<projectRoot>/supervisor/releases/<revision>/` release directories by default

Shared auth keys are sourced from the same loader used by the server-side auth subsystem, so the supervisor reuses `server/data/auth_keys.json` by default unless environment-injected secrets are already present.

External commands used by this subtree:

- `git` for source checkout discovery, remote polling, cloning, and exact checkout
Expand Down
93 changes: 11 additions & 82 deletions commands/lib/supervisor/auth_keys.js
Original file line number Diff line number Diff line change
@@ -1,101 +1,30 @@
import { randomBytes } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { loadAuthKeys } from "../../../server/lib/auth/keys_manage.js";

const AUTH_KEYS_FILENAME = "auth_keys.json";
const PASSWORD_SEAL_KEY_ENV_NAME = "SPACE_AUTH_PASSWORD_SEAL_KEY";
const SESSION_HMAC_KEY_ENV_NAME = "SPACE_AUTH_SESSION_HMAC_KEY";
const PASSWORD_SEAL_KEY_NAME = "password_seal_key";
const SESSION_HMAC_KEY_NAME = "session_hmac_key";
const SECRET_KEY_LENGTH = 32;

function encodeBase64Url(value) {
return Buffer.from(value).toString("base64url");
}

function decodeBase64Url(value) {
return Buffer.from(String(value || ""), "base64url");
}

function parseSecretKey(record, fieldName, sourceName) {
const rawValue = String(record?.[fieldName] || "").trim();

if (!rawValue) {
throw new Error(`Missing ${fieldName} in ${sourceName}.`);
}

const decoded = decodeBase64Url(rawValue);
if (decoded.length !== SECRET_KEY_LENGTH) {
if (Buffer.from(rawValue, "base64url").length !== SECRET_KEY_LENGTH) {
throw new Error(`Invalid ${fieldName} length in ${sourceName}.`);
}

return rawValue;
}

function createAuthKeysPayload() {
return {
created_at: new Date().toISOString(),
[PASSWORD_SEAL_KEY_NAME]: encodeBase64Url(randomBytes(SECRET_KEY_LENGTH)),
[SESSION_HMAC_KEY_NAME]: encodeBase64Url(randomBytes(SECRET_KEY_LENGTH))
};
}

async function readJsonFile(filePath) {
return JSON.parse(await fs.readFile(filePath, "utf8"));
}

async function readOrCreateSupervisorAuthKeys(stateDir) {
const dataDir = path.join(stateDir, "auth");
const filePath = path.join(dataDir, AUTH_KEYS_FILENAME);

await fs.mkdir(dataDir, {
mode: 0o700,
recursive: true
});
await fs.chmod(dataDir, 0o700).catch(() => {});

try {
const payload = await readJsonFile(filePath);

return {
filePath,
passwordSealKey: parseSecretKey(payload, PASSWORD_SEAL_KEY_NAME, filePath),
sessionHmacKey: parseSecretKey(payload, SESSION_HMAC_KEY_NAME, filePath),
source: filePath
};
} catch (error) {
if (error.code !== "ENOENT") {
throw error;
}
}

const payload = createAuthKeysPayload();
const sourceText = `${JSON.stringify(payload, null, 2)}\n`;

try {
await fs.writeFile(filePath, sourceText, {
encoding: "utf8",
flag: "wx",
mode: 0o600
});
await fs.chmod(filePath, 0o600).catch(() => {});
} catch (error) {
if (error.code !== "EEXIST") {
throw error;
}
}

const storedPayload = await readJsonFile(filePath);

return {
filePath,
passwordSealKey: parseSecretKey(storedPayload, PASSWORD_SEAL_KEY_NAME, filePath),
sessionHmacKey: parseSecretKey(storedPayload, SESSION_HMAC_KEY_NAME, filePath),
source: filePath
};
function encodeAuthKey(value) {
return Buffer.isBuffer(value) ? Buffer.from(value).toString("base64url") : String(value || "");
}

async function loadSupervisorAuthEnv({ env = process.env, stateDir }) {
async function loadSupervisorAuthEnv({ env = process.env, projectRoot }) {
const passwordSealKey = String(env[PASSWORD_SEAL_KEY_ENV_NAME] || "").trim();
const sessionHmacKey = String(env[SESSION_HMAC_KEY_ENV_NAME] || "").trim();

Expand All @@ -115,17 +44,17 @@ async function loadSupervisorAuthEnv({ env = process.env, stateDir }) {
[SESSION_HMAC_KEY_ENV_NAME]: sessionHmacKey
},
source: "process.env"
};
}
};
}

const keys = await readOrCreateSupervisorAuthKeys(stateDir);
const keys = loadAuthKeys(projectRoot, env);

return {
env: {
[PASSWORD_SEAL_KEY_ENV_NAME]: keys.passwordSealKey,
[SESSION_HMAC_KEY_ENV_NAME]: keys.sessionHmacKey
[PASSWORD_SEAL_KEY_ENV_NAME]: encodeAuthKey(keys.passwordSealKey),
[SESSION_HMAC_KEY_ENV_NAME]: encodeAuthKey(keys.sessionHmacKey)
},
source: keys.source
source: keys.filePath || keys.source || "server/data/auth_keys.json"
};
}

Expand Down
4 changes: 2 additions & 2 deletions commands/supervise.js
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ export const help = {
"node space supervise --auto-update-interval 0 CUSTOMWARE_PATH=/srv/space/customware"
],
description:
"Starts a production-ready public reverse-proxy supervisor, runs real space serve children on private loopback ports, periodically stages source updates in release directories when the auto-update interval is greater than zero, and switches to a healthy replacement child. The supervisor only owns its own flags plus the public bind host and port; every other CLI argument is forwarded to space serve unchanged except that child HOST and PORT are forced to loopback and CUSTOMWARE_PATH is normalized to an absolute shared-state path.",
"Starts a production-ready public reverse-proxy supervisor, runs real space serve children on private loopback ports, periodically stages source updates in release directories when the auto-update interval is greater than zero, and switches to a healthy replacement child. The supervisor reuses the same local auth-key store as the server-side auth loader unless auth secrets are injected directly. It only owns its own flags plus the public bind host and port; every other CLI argument is forwarded to space serve unchanged except that child HOST and PORT are forced to loopback and CUSTOMWARE_PATH is normalized to an absolute shared-state path.",
options: [
{
flag: "--branch <branch>",
Expand Down Expand Up @@ -395,7 +395,7 @@ export async function execute(context) {
const releasesDir = path.join(stateDir, "releases");
const auth = await loadSupervisorAuthEnv({
env: process.env,
stateDir
projectRoot: context.projectRoot
});
const updateSource = await resolveSupervisorSource(
{
Expand Down
36 changes: 36 additions & 0 deletions tests/supervise_command_test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";

import { loadSupervisorAuthEnv } from "../commands/lib/supervisor/auth_keys.js";
import { __test as superviseTest } from "../commands/supervise.js";
import { buildServeProcessTitle, buildSupervisorProcessTitle } from "../server/lib/utils/process_title.js";

Expand Down Expand Up @@ -78,6 +81,39 @@ test("supervise defaults state dir to project-root supervisor folder", () => {
);
});

test("supervise shares the server data auth store with user creation by default", async () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "space-agent-"));
const authDir = path.join(projectRoot, "server", "data");
const authFile = path.join(authDir, "auth_keys.json");
const passwordSealKey = Buffer.alloc(32, 1).toString("base64url");
const sessionHmacKey = Buffer.alloc(32, 2).toString("base64url");

try {
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(
authFile,
`${JSON.stringify(
{
created_at: "2026-05-01T00:00:00.000Z",
password_seal_key: passwordSealKey,
session_hmac_key: sessionHmacKey
},
null,
2
)}\n`,
"utf8"
);

const auth = await loadSupervisorAuthEnv({ env: {}, projectRoot });

assert.equal(auth.source, authFile);
assert.equal(auth.env.SPACE_AUTH_PASSWORD_SEAL_KEY, passwordSealKey);
assert.equal(auth.env.SPACE_AUTH_SESSION_HMAC_KEY, sessionHmacKey);
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
});

test("runtime process titles stay distinct and short enough for htop-style listings", () => {
assert.equal(buildSupervisorProcessTitle(), "space-supervise");
assert.equal(buildServeProcessTitle(), "space-serve");
Expand Down