579 lines
21 KiB
TypeScript
579 lines
21 KiB
TypeScript
import { createHash, randomUUID } from 'node:crypto';
|
|
import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
import { homedir } from 'node:os';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { compareCodePoints } from './deterministic-order.js';
|
|
|
|
export type AgentEnvironmentKind = 'generated' | 'local';
|
|
|
|
export interface AgentEnvironmentDiagnostic {
|
|
readonly code: string;
|
|
readonly key: string;
|
|
readonly sha256: string;
|
|
}
|
|
|
|
export interface AgentEnvironmentProjectionOptions {
|
|
readonly mosaicHome: string;
|
|
readonly agentEnvDir: string;
|
|
readonly agentName: string;
|
|
readonly generated: Readonly<Record<string, string>>;
|
|
}
|
|
|
|
export interface AgentGeneratedProjectionDeletionOptions {
|
|
readonly mosaicHome: string;
|
|
readonly agentEnvDir: string;
|
|
readonly agentName: string;
|
|
}
|
|
|
|
export interface AgentEnvironmentProjectionResult {
|
|
readonly generatedPath: string;
|
|
readonly localPath: string;
|
|
readonly quarantinePath?: string;
|
|
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
|
}
|
|
|
|
/** Sanitized, non-mutating projection evidence safe for migration output. */
|
|
export interface AgentEnvironmentProjectionPreview {
|
|
readonly agentName: string;
|
|
readonly generated: 'rebuild';
|
|
readonly legacy: 'absent' | 'regenerate-only' | 'relocate-local' | 'quarantine';
|
|
readonly relocatedKeys: readonly string[];
|
|
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
|
}
|
|
|
|
/** A projection fully validated without changing managed files. */
|
|
export interface PreparedAgentEnvironmentProjection {
|
|
readonly mosaicHome: string;
|
|
readonly agentEnvDir: string;
|
|
readonly generatedPath: string;
|
|
readonly localPath: string;
|
|
readonly legacyPath: string;
|
|
readonly generated: string;
|
|
readonly local: string;
|
|
readonly legacy?: string;
|
|
readonly legacyRelocatedKeys: readonly string[];
|
|
readonly quarantinePath?: string;
|
|
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
|
}
|
|
|
|
export class AgentEnvBoundaryError extends Error {
|
|
readonly diagnostic: AgentEnvironmentDiagnostic;
|
|
|
|
constructor(code: string, key: string, value: string) {
|
|
const diagnostic: AgentEnvironmentDiagnostic = {
|
|
code,
|
|
key,
|
|
sha256: hashValue(value),
|
|
};
|
|
super(`Agent environment rejected: code=${code} key=${key} sha256=${diagnostic.sha256}`);
|
|
this.name = 'AgentEnvBoundaryError';
|
|
this.diagnostic = diagnostic;
|
|
}
|
|
}
|
|
|
|
export const GENERATED_AGENT_ENV_KEYS = [
|
|
'MOSAIC_AGENT_NAME',
|
|
'MOSAIC_AGENT_CLASS',
|
|
'MOSAIC_AGENT_RUNTIME',
|
|
'MOSAIC_AGENT_MODEL',
|
|
'MOSAIC_AGENT_REASONING',
|
|
'MOSAIC_AGENT_TOOL_POLICY',
|
|
'MOSAIC_AGENT_WORKDIR',
|
|
'MOSAIC_TMUX_SOCKET',
|
|
] as const;
|
|
|
|
export const LOCAL_AGENT_ENV_KEYS = [
|
|
'MOSAIC_RUNTIME_BIN',
|
|
'MOSAIC_HEARTBEAT_RUN_DIR',
|
|
'MOSAIC_HEARTBEAT_INTERVAL',
|
|
'MOSAIC_CLAUDE_JSON',
|
|
'CLAUDE_CONFIG_DIR',
|
|
] as const;
|
|
|
|
const GENERATED_KEY_SET = new Set<string>(GENERATED_AGENT_ENV_KEYS);
|
|
const LOCAL_KEY_SET = new Set<string>(LOCAL_AGENT_ENV_KEYS);
|
|
const SENSITIVE_KEY = /(?:API[_-]?KEY|AUTH|CREDENTIAL|PASSWORD|PRIVATE|SECRET|TOKEN)/i;
|
|
const AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
const POLICY_NAME = /^[a-z][a-z0-9-]*$/;
|
|
const TMUX_SOCKET = /^[A-Za-z0-9_.-]*$/;
|
|
const MODEL = /^[A-Za-z0-9._/:+-]*$/;
|
|
/** Runtime launchers supported by the generated environment contract. */
|
|
export const GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES = [
|
|
'claude',
|
|
'codex',
|
|
'opencode',
|
|
'pi',
|
|
] as const;
|
|
|
|
const SUPPORTED_RUNTIMES = new Set<string>(GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES);
|
|
const REASONING = new Set(['', 'low', 'medium', 'high']);
|
|
|
|
/** Parses a strict data-only generated or local environment file without shell evaluation. */
|
|
export function parseAgentEnvironment(
|
|
source: string,
|
|
kind: AgentEnvironmentKind,
|
|
): Readonly<Record<string, string>> {
|
|
const values: Record<string, string> = {};
|
|
const seen = new Set<string>();
|
|
|
|
for (const line of source.split('\n')) {
|
|
if (line === '') continue;
|
|
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
|
if (!match) throw new AgentEnvBoundaryError('malformed-line', '(malformed)', line);
|
|
|
|
const [, key, value] = match;
|
|
if (key === undefined || value === undefined) {
|
|
throw new AgentEnvBoundaryError('malformed-line', '(malformed)', line);
|
|
}
|
|
if (seen.has(key)) throw new AgentEnvBoundaryError('duplicate-key', key, value);
|
|
seen.add(key);
|
|
if (SENSITIVE_KEY.test(key)) throw new AgentEnvBoundaryError('sensitive-key', key, value);
|
|
if (containsShellSyntax(value)) throw new AgentEnvBoundaryError('unsafe-value', key, value);
|
|
|
|
if (kind === 'generated') {
|
|
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
|
|
} else {
|
|
if (GENERATED_KEY_SET.has(key))
|
|
throw new AgentEnvBoundaryError('generated-key-shadow', key, value);
|
|
if (!LOCAL_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
|
|
}
|
|
values[key] = value;
|
|
}
|
|
|
|
if (kind === 'generated') assertGeneratedValues(values);
|
|
else assertLocalValues(values);
|
|
return Object.freeze(values);
|
|
}
|
|
|
|
/** Renders the roster-derived generated projection in a stable, complete key order. */
|
|
export function renderGeneratedAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
|
const normalized = normalizeGeneratedValues(values);
|
|
return `${GENERATED_AGENT_ENV_KEYS.map((key): string => `${key}=${normalized[key]}`).join('\n')}\n`;
|
|
}
|
|
|
|
/** Validates all deterministic projection inputs and target state without mutation. */
|
|
export async function prepareAgentEnvironmentProjection(
|
|
options: AgentEnvironmentProjectionOptions,
|
|
): Promise<PreparedAgentEnvironmentProjection> {
|
|
if (!AGENT_NAME.test(options.agentName)) {
|
|
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
|
|
}
|
|
|
|
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
|
|
const generatedPath = join(options.agentEnvDir, `${options.agentName}.env.generated`);
|
|
const localPath = join(options.agentEnvDir, `${options.agentName}.env.local`);
|
|
const legacyPath = join(options.agentEnvDir, `${options.agentName}.env`);
|
|
const generated = renderGeneratedAgentEnvironment(options.generated);
|
|
await assertPrivateRegularFileIfPresent(generatedPath);
|
|
const currentLocal = await readOptionalPrivateFile(localPath);
|
|
const parsedLocal =
|
|
currentLocal === undefined ? {} : parseAgentEnvironment(currentLocal, 'local');
|
|
const legacy = await readOptionalPrivateFile(legacyPath);
|
|
const legacyDisposition =
|
|
legacy === undefined ? emptyLegacyDisposition() : classifyLegacyEnvironment(legacy);
|
|
|
|
for (const [key, value] of Object.entries(legacyDisposition.localValues)) {
|
|
if (parsedLocal[key] !== undefined && parsedLocal[key] !== value) {
|
|
throw new AgentEnvBoundaryError('legacy-local-conflict', key, value);
|
|
}
|
|
}
|
|
|
|
const local = renderLocalAgentEnvironment({ ...legacyDisposition.localValues, ...parsedLocal });
|
|
const quarantinePath = legacyDisposition.diagnostics.length
|
|
? join(options.agentEnvDir, `${options.agentName}.env.quarantine`)
|
|
: undefined;
|
|
if (quarantinePath !== undefined) {
|
|
const existingQuarantine = await readOptionalPrivateFile(quarantinePath);
|
|
if (existingQuarantine !== undefined) {
|
|
throw new AgentEnvBoundaryError('quarantine-exists', '(quarantine)', existingQuarantine);
|
|
}
|
|
}
|
|
|
|
return {
|
|
mosaicHome: options.mosaicHome,
|
|
agentEnvDir: options.agentEnvDir,
|
|
generatedPath,
|
|
localPath,
|
|
legacyPath,
|
|
generated,
|
|
local,
|
|
...(legacy === undefined ? {} : { legacy }),
|
|
legacyRelocatedKeys: Object.keys(legacyDisposition.localValues).sort(compareCodePoints),
|
|
...(quarantinePath === undefined ? {} : { quarantinePath }),
|
|
diagnostics: legacyDisposition.diagnostics,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validates only the exact generated projection eligible for a roster delete.
|
|
* Local overrides, legacy input, and quarantine records are operator-retained and
|
|
* intentionally excluded from delete validation and cleanup.
|
|
*/
|
|
export async function prepareAgentGeneratedProjectionDeletion(
|
|
options: AgentGeneratedProjectionDeletionOptions,
|
|
): Promise<string> {
|
|
if (!AGENT_NAME.test(options.agentName)) {
|
|
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
|
|
}
|
|
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
|
|
const generatedPath = join(options.agentEnvDir, `${options.agentName}.env.generated`);
|
|
await assertPrivateRegularFileIfPresent(generatedPath);
|
|
return generatedPath;
|
|
}
|
|
|
|
export async function previewAgentEnvironmentProjection(
|
|
options: AgentEnvironmentProjectionOptions,
|
|
): Promise<AgentEnvironmentProjectionPreview> {
|
|
const prepared = await prepareAgentEnvironmentProjection(options);
|
|
const relocatedKeys = prepared.legacyRelocatedKeys;
|
|
const legacy =
|
|
prepared.legacy === undefined
|
|
? 'absent'
|
|
: prepared.diagnostics.length > 0
|
|
? 'quarantine'
|
|
: relocatedKeys.length > 0
|
|
? 'relocate-local'
|
|
: 'regenerate-only';
|
|
return {
|
|
agentName: options.agentName,
|
|
generated: 'rebuild',
|
|
legacy,
|
|
relocatedKeys,
|
|
diagnostics: [...prepared.diagnostics].sort((left, right): number =>
|
|
compareCodePoints(
|
|
`${left.key}:${left.code}:${left.sha256}`,
|
|
`${right.key}:${right.code}:${right.sha256}`,
|
|
),
|
|
),
|
|
};
|
|
}
|
|
|
|
/** Applies a previously prepared deterministic projection. */
|
|
export async function applyPreparedAgentEnvironmentProjection(
|
|
prepared: PreparedAgentEnvironmentProjection,
|
|
): Promise<AgentEnvironmentProjectionResult> {
|
|
await ensurePrivateProjectionDirectory(prepared.mosaicHome, prepared.agentEnvDir);
|
|
await writePrivateAtomically(prepared.generatedPath, prepared.generated);
|
|
if (prepared.local !== '') await writePrivateAtomically(prepared.localPath, prepared.local);
|
|
if (prepared.quarantinePath !== undefined && prepared.legacy !== undefined) {
|
|
await writePrivateAtomically(prepared.quarantinePath, prepared.legacy);
|
|
}
|
|
if (prepared.legacy !== undefined) await unlink(prepared.legacyPath);
|
|
|
|
return {
|
|
generatedPath: prepared.generatedPath,
|
|
localPath: prepared.localPath,
|
|
...(prepared.quarantinePath === undefined ? {} : { quarantinePath: prepared.quarantinePath }),
|
|
diagnostics: prepared.diagnostics,
|
|
};
|
|
}
|
|
|
|
/** Writes deterministic projection files and explicitly removes legacy `.env` authority. */
|
|
export async function writeAgentEnvironmentProjection(
|
|
options: AgentEnvironmentProjectionOptions,
|
|
): Promise<AgentEnvironmentProjectionResult> {
|
|
return applyPreparedAgentEnvironmentProjection(await prepareAgentEnvironmentProjection(options));
|
|
}
|
|
|
|
/** Creates the canonical managed roster path with private fresh-chain permissions. */
|
|
export async function writeManagedFleetRoster(
|
|
mosaicHome: string,
|
|
rosterPath: string,
|
|
content: string,
|
|
): Promise<void> {
|
|
const fleetDir = join(mosaicHome, 'fleet');
|
|
const expectedRosterPath = join(fleetDir, 'roster.yaml');
|
|
if (resolve(rosterPath) !== resolve(expectedRosterPath)) {
|
|
throw new AgentEnvBoundaryError('unsafe-file', '(roster)', rosterPath);
|
|
}
|
|
await ensureManagedDirectory(mosaicHome, false);
|
|
await ensureManagedDirectory(fleetDir, false);
|
|
await writePrivateAtomically(rosterPath, content);
|
|
}
|
|
|
|
/** Ensures the private install-derived identity used to own a named tmux server. */
|
|
export async function ensureFleetHolderIdentity(mosaicHome: string): Promise<string> {
|
|
const fleetDir = join(mosaicHome, 'fleet');
|
|
const runDir = join(fleetDir, 'run');
|
|
const identityPath = join(runDir, 'holder-owner');
|
|
await ensureManagedDirectory(mosaicHome, false);
|
|
await ensureManagedDirectory(fleetDir, false);
|
|
await ensureManagedDirectory(runDir, true);
|
|
const existing = await readOptionalPrivateFile(identityPath);
|
|
if (existing !== undefined) {
|
|
const identity = existing.trim();
|
|
if (!/^[a-f0-9-]{36}$/.test(identity)) {
|
|
throw new AgentEnvBoundaryError('unsafe-owner-identity', '(holder-owner)', existing);
|
|
}
|
|
return identity;
|
|
}
|
|
const identity = randomUUID();
|
|
await writePrivateAtomically(identityPath, `${identity}\n`);
|
|
return identity;
|
|
}
|
|
|
|
function normalizeGeneratedValues(
|
|
values: Readonly<Record<string, string>>,
|
|
): Readonly<Record<string, string>> {
|
|
const normalized: Record<string, string> = {};
|
|
for (const key of GENERATED_AGENT_ENV_KEYS) {
|
|
const value = values[key];
|
|
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
|
normalized[key] = key === 'MOSAIC_AGENT_WORKDIR' ? expandHomeDirectory(value) : value;
|
|
}
|
|
for (const [key, value] of Object.entries(values)) {
|
|
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
|
|
}
|
|
assertGeneratedValues(normalized);
|
|
return Object.freeze(normalized);
|
|
}
|
|
|
|
function expandHomeDirectory(path: string): string {
|
|
if (path === '~') return homedir();
|
|
if (!path.startsWith('~/') || path.split('/').includes('..')) return path;
|
|
return join(homedir(), path.slice(2));
|
|
}
|
|
|
|
function renderLocalAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
|
if (Object.keys(values).length === 0) return '';
|
|
const parsed = parseAgentEnvironment(
|
|
Object.entries(values)
|
|
.sort(([left], [right]): number => compareCodePoints(left, right))
|
|
.map(([key, value]): string => `${key}=${value}`)
|
|
.join('\n'),
|
|
'local',
|
|
);
|
|
return `${Object.entries(parsed)
|
|
.sort(([left], [right]): number => compareCodePoints(left, right))
|
|
.map(([key, value]): string => `${key}=${value}`)
|
|
.join('\n')}\n`;
|
|
}
|
|
|
|
function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
|
|
for (const key of GENERATED_AGENT_ENV_KEYS) {
|
|
const value = values[key];
|
|
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
|
}
|
|
const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME');
|
|
const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS');
|
|
const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME');
|
|
const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL');
|
|
const reasoning = requiredGeneratedValue(values, 'MOSAIC_AGENT_REASONING');
|
|
const toolPolicy = requiredGeneratedValue(values, 'MOSAIC_AGENT_TOOL_POLICY');
|
|
const workingDirectory = requiredGeneratedValue(values, 'MOSAIC_AGENT_WORKDIR');
|
|
const socket = requiredGeneratedValue(values, 'MOSAIC_TMUX_SOCKET');
|
|
|
|
if (!AGENT_NAME.test(name))
|
|
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name);
|
|
if (!POLICY_NAME.test(className)) {
|
|
throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className);
|
|
}
|
|
if (!SUPPORTED_RUNTIMES.has(runtime)) {
|
|
throw new AgentEnvBoundaryError('unsupported-runtime', 'MOSAIC_AGENT_RUNTIME', runtime);
|
|
}
|
|
if (!MODEL.test(model))
|
|
throw new AgentEnvBoundaryError('unsafe-model', 'MOSAIC_AGENT_MODEL', model);
|
|
if (!REASONING.has(reasoning)) {
|
|
throw new AgentEnvBoundaryError('unsupported-reasoning', 'MOSAIC_AGENT_REASONING', reasoning);
|
|
}
|
|
if (toolPolicy !== '' && !POLICY_NAME.test(toolPolicy)) {
|
|
throw new AgentEnvBoundaryError('unsafe-tool-policy', 'MOSAIC_AGENT_TOOL_POLICY', toolPolicy);
|
|
}
|
|
if (!isSafeAbsolutePath(workingDirectory)) {
|
|
throw new AgentEnvBoundaryError('unsafe-path', 'MOSAIC_AGENT_WORKDIR', workingDirectory);
|
|
}
|
|
if (!TMUX_SOCKET.test(socket)) {
|
|
throw new AgentEnvBoundaryError('unsafe-socket', 'MOSAIC_TMUX_SOCKET', socket);
|
|
}
|
|
}
|
|
|
|
function requiredGeneratedValue(values: Readonly<Record<string, string>>, key: string): string {
|
|
const value = values[key];
|
|
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
|
return value;
|
|
}
|
|
|
|
function assertLocalValues(values: Readonly<Record<string, string>>): void {
|
|
for (const [key, value] of Object.entries(values)) {
|
|
if (key === 'MOSAIC_HEARTBEAT_INTERVAL') {
|
|
if (!/^[1-9][0-9]*$/.test(value)) {
|
|
throw new AgentEnvBoundaryError('invalid-interval', key, value);
|
|
}
|
|
continue;
|
|
}
|
|
if (!isSafeAbsolutePath(value)) throw new AgentEnvBoundaryError('unsafe-path', key, value);
|
|
}
|
|
}
|
|
|
|
function isSafeAbsolutePath(value: string): boolean {
|
|
return (
|
|
value.startsWith('/') && !value.split('/').includes('..') && !/[\s"'`$\\;&|<>(){}]/.test(value)
|
|
);
|
|
}
|
|
|
|
function containsShellSyntax(value: string): boolean {
|
|
return /["'`$\\;&|<>(){}]/.test(value);
|
|
}
|
|
|
|
interface LegacyDisposition {
|
|
readonly localValues: Readonly<Record<string, string>>;
|
|
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
|
}
|
|
|
|
function emptyLegacyDisposition(): LegacyDisposition {
|
|
return { localValues: {}, diagnostics: [] };
|
|
}
|
|
|
|
function classifyLegacyEnvironment(source: string): LegacyDisposition {
|
|
const localValues: Record<string, string> = {};
|
|
const diagnostics: AgentEnvironmentDiagnostic[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
for (const line of source.split('\n')) {
|
|
if (line === '') continue;
|
|
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
|
if (!match || match[1] === undefined || match[2] === undefined) {
|
|
diagnostics.push(diagnostic('malformed-line', '(malformed)', line));
|
|
continue;
|
|
}
|
|
const [, key, value] = match;
|
|
if (seen.has(key)) {
|
|
diagnostics.push(diagnostic('duplicate-key', key, value));
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
if (GENERATED_KEY_SET.has(key)) continue;
|
|
try {
|
|
parseAgentEnvironment(`${key}=${value}\n`, 'local');
|
|
localValues[key] = value;
|
|
} catch (error: unknown) {
|
|
if (error instanceof AgentEnvBoundaryError) diagnostics.push(error.diagnostic);
|
|
else throw error;
|
|
}
|
|
}
|
|
|
|
return { localValues: Object.freeze(localValues), diagnostics: Object.freeze(diagnostics) };
|
|
}
|
|
|
|
function diagnostic(code: string, key: string, value: string): AgentEnvironmentDiagnostic {
|
|
return { code, key, sha256: hashValue(value) };
|
|
}
|
|
|
|
function hashValue(value: string): string {
|
|
return createHash('sha256').update(value).digest('hex');
|
|
}
|
|
|
|
async function readOptionalPrivateFile(path: string): Promise<string | undefined> {
|
|
try {
|
|
await assertPrivateRegularFile(path);
|
|
return await readFile(path, 'utf8');
|
|
} catch (error: unknown) {
|
|
if (isMissingFile(error)) return undefined;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function isMissingFile(error: unknown): boolean {
|
|
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
|
}
|
|
|
|
async function validatePrivateProjectionDirectory(
|
|
mosaicHome: string,
|
|
agentEnvDir: string,
|
|
): Promise<void> {
|
|
const fleetDir = join(mosaicHome, 'fleet');
|
|
const expectedAgentEnvDir = join(fleetDir, 'agents');
|
|
if (resolve(agentEnvDir) !== resolve(expectedAgentEnvDir)) {
|
|
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', agentEnvDir);
|
|
}
|
|
await assertManagedDirectoryIfPresent(mosaicHome, false);
|
|
await assertManagedDirectoryIfPresent(fleetDir, false);
|
|
await assertManagedDirectoryIfPresent(agentEnvDir, true);
|
|
}
|
|
|
|
async function ensurePrivateProjectionDirectory(
|
|
mosaicHome: string,
|
|
agentEnvDir: string,
|
|
): Promise<void> {
|
|
await validatePrivateProjectionDirectory(mosaicHome, agentEnvDir);
|
|
const fleetDir = join(mosaicHome, 'fleet');
|
|
await ensureManagedDirectory(mosaicHome, false);
|
|
await ensureManagedDirectory(fleetDir, false);
|
|
await ensureManagedDirectory(agentEnvDir, true);
|
|
}
|
|
|
|
async function ensureManagedDirectory(path: string, privateDirectory: boolean): Promise<void> {
|
|
let created = false;
|
|
try {
|
|
await lstat(path);
|
|
} catch (error: unknown) {
|
|
if (!isMissingFile(error)) throw error;
|
|
try {
|
|
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
created = true;
|
|
} catch (mkdirError: unknown) {
|
|
if (!isAlreadyExists(mkdirError)) throw mkdirError;
|
|
}
|
|
}
|
|
|
|
await assertManagedDirectory(path, privateDirectory);
|
|
if (created) {
|
|
await chmod(path, 0o700);
|
|
await assertManagedDirectory(path, privateDirectory);
|
|
}
|
|
}
|
|
|
|
function isAlreadyExists(error: unknown): boolean {
|
|
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST';
|
|
}
|
|
|
|
async function assertManagedDirectoryIfPresent(
|
|
path: string,
|
|
privateDirectory: boolean,
|
|
): Promise<void> {
|
|
try {
|
|
await assertManagedDirectory(path, privateDirectory);
|
|
} catch (error: unknown) {
|
|
if (isMissingFile(error)) return;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function assertManagedDirectory(path: string, privateDirectory: boolean): Promise<void> {
|
|
const metadata = await lstat(path);
|
|
if (!metadata.isDirectory()) {
|
|
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', path);
|
|
}
|
|
if ((metadata.mode & 0o022) !== 0 || (privateDirectory && (metadata.mode & 0o077) !== 0)) {
|
|
throw new AgentEnvBoundaryError('unsafe-permissions', '(directory)', path);
|
|
}
|
|
}
|
|
|
|
async function assertPrivateRegularFileIfPresent(path: string): Promise<void> {
|
|
try {
|
|
await assertPrivateRegularFile(path);
|
|
} catch (error: unknown) {
|
|
if (isMissingFile(error)) return;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function assertPrivateRegularFile(path: string): Promise<void> {
|
|
const metadata = await lstat(path);
|
|
if (!metadata.isFile()) throw new AgentEnvBoundaryError('unsafe-file', '(file)', path);
|
|
if ((metadata.mode & 0o077) !== 0) {
|
|
throw new AgentEnvBoundaryError('unsafe-permissions', '(file)', path);
|
|
}
|
|
}
|
|
|
|
async function writePrivateAtomically(path: string, content: string): Promise<void> {
|
|
try {
|
|
await assertPrivateRegularFile(path);
|
|
} catch (error: unknown) {
|
|
if (!isMissingFile(error)) throw error;
|
|
}
|
|
const temporaryPath = join(dirname(path), `.${randomUUID()}.tmp`);
|
|
await writeFile(temporaryPath, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
await rename(temporaryPath, path);
|
|
}
|