📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
"use strict";
|
||||
|
||||
const { hostConfigError } = require("./errors");
|
||||
|
||||
const KNOWN_KEYS = ["command", "args", "env"];
|
||||
|
||||
function decodeUtf8(bytes) {
|
||||
if (bytes.length > 1024 * 1024) throw hostConfigError("AAS_ADAPTER_CONFIG_TOO_LARGE", "invalidInput");
|
||||
try {
|
||||
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
if (text.charCodeAt(0) === 0xfeff) throw new Error("bom");
|
||||
return text;
|
||||
} catch {
|
||||
throw hostConfigError("AAS_ADAPTER_CONFIG_ENCODING_INVALID", "invalidInput");
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonAst(text) {
|
||||
let cursor = 0;
|
||||
function fail() { throw hostConfigError("AAS_ADAPTER_CLAUDE_JSON_INVALID", "invalidInput"); }
|
||||
function whitespace() { while (/[\u0009\u000a\u000d\u0020]/.test(text[cursor] || "")) cursor += 1; }
|
||||
function string() {
|
||||
const start = cursor;
|
||||
if (text[cursor] !== '"') fail();
|
||||
cursor += 1;
|
||||
while (cursor < text.length) {
|
||||
if (text[cursor] === '"') {
|
||||
cursor += 1;
|
||||
try { return { value: JSON.parse(text.slice(start, cursor)), start, end: cursor }; } catch { fail(); }
|
||||
}
|
||||
if (text[cursor] === "\\") cursor += 2;
|
||||
else cursor += 1;
|
||||
}
|
||||
fail();
|
||||
}
|
||||
function value(depth = 0) {
|
||||
if (depth > 32) fail();
|
||||
whitespace();
|
||||
const start = cursor;
|
||||
if (text[cursor] === "{") return object(depth + 1);
|
||||
if (text[cursor] === "[") return array(depth + 1);
|
||||
if (text[cursor] === '"') {
|
||||
const node = string();
|
||||
return { type: "string", ...node };
|
||||
}
|
||||
while (cursor < text.length && !/[\s,}\]]/.test(text[cursor])) cursor += 1;
|
||||
if (cursor === start) fail();
|
||||
try { return { type: "primitive", value: JSON.parse(text.slice(start, cursor)), start, end: cursor }; } catch { fail(); }
|
||||
}
|
||||
function array(depth) {
|
||||
const start = cursor;
|
||||
const items = [];
|
||||
cursor += 1;
|
||||
whitespace();
|
||||
if (text[cursor] === "]") { cursor += 1; return { type: "array", value: [], items, start, end: cursor }; }
|
||||
while (cursor < text.length) {
|
||||
const item = value(depth);
|
||||
items.push(item);
|
||||
whitespace();
|
||||
if (text[cursor] === "]") { cursor += 1; return { type: "array", value: items.map((entry) => entry.value), items, start, end: cursor }; }
|
||||
if (text[cursor] !== ",") fail();
|
||||
cursor += 1;
|
||||
}
|
||||
fail();
|
||||
}
|
||||
function object(depth) {
|
||||
const start = cursor;
|
||||
const properties = [];
|
||||
const keys = new Set();
|
||||
const result = {};
|
||||
cursor += 1;
|
||||
whitespace();
|
||||
if (text[cursor] === "}") { cursor += 1; return { type: "object", value: result, properties, start, end: cursor }; }
|
||||
while (cursor < text.length) {
|
||||
whitespace();
|
||||
const key = string();
|
||||
if (keys.has(key.value)) throw hostConfigError("AAS_ADAPTER_CLAUDE_JSON_DUPLICATE_KEY", "invalidInput", { key: key.value });
|
||||
keys.add(key.value);
|
||||
whitespace();
|
||||
if (text[cursor] !== ":") fail();
|
||||
cursor += 1;
|
||||
const child = value(depth);
|
||||
properties.push({ key: key.value, keyNode: key, valueNode: child });
|
||||
result[key.value] = child.value;
|
||||
whitespace();
|
||||
if (text[cursor] === "}") { cursor += 1; return { type: "object", value: result, properties, start, end: cursor }; }
|
||||
if (text[cursor] !== ",") fail();
|
||||
cursor += 1;
|
||||
}
|
||||
fail();
|
||||
}
|
||||
const root = value(0);
|
||||
whitespace();
|
||||
if (cursor !== text.length || root.type !== "object") fail();
|
||||
return root;
|
||||
}
|
||||
|
||||
function property(node, key) {
|
||||
return node.properties.find((entry) => entry.key === key);
|
||||
}
|
||||
|
||||
function indentation(text) {
|
||||
const match = text.match(/\n([ \t]+)"/);
|
||||
return match ? match[1] : " ";
|
||||
}
|
||||
|
||||
function lineIndent(text, offset) {
|
||||
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
|
||||
return (text.slice(lineStart, offset).match(/^[ \t]*/) || [""])[0];
|
||||
}
|
||||
|
||||
function formatValue(value, continuationIndent, indentUnit) {
|
||||
return JSON.stringify(value, null, indentUnit).split("\n").map((line, index) => index === 0 ? line : `${continuationIndent}${line}`).join("\n");
|
||||
}
|
||||
|
||||
function replaceRange(text, start, end, replacement) {
|
||||
return `${text.slice(0, start)}${replacement}${text.slice(end)}`;
|
||||
}
|
||||
|
||||
function insertProperty(text, node, key, value, indentUnit) {
|
||||
const baseIndent = lineIndent(text, node.start);
|
||||
const childIndent = `${baseIndent}${indentUnit}`;
|
||||
const rendered = `${JSON.stringify(key)}: ${formatValue(value, childIndent, indentUnit)}`;
|
||||
if (node.properties.length === 0) {
|
||||
return replaceRange(text, node.start, node.end, `{\n${childIndent}${rendered}\n${baseIndent}}`);
|
||||
}
|
||||
const last = node.properties.at(-1).valueNode;
|
||||
return replaceRange(text, last.end, last.end, `,\n${childIndent}${rendered}`);
|
||||
}
|
||||
|
||||
function sameValue(left, right) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function inspectTree(root) {
|
||||
const mcp = property(root, "mcpServers");
|
||||
if (mcp && mcp.valueNode.type !== "object") throw hostConfigError("AAS_ADAPTER_CLAUDE_SECTION_AMBIGUOUS", "invalidInput", { key: "mcpServers" });
|
||||
const aas = mcp ? property(mcp.valueNode, "aas") : null;
|
||||
if (aas && aas.valueNode.type !== "object") throw hostConfigError("AAS_ADAPTER_CLAUDE_SECTION_AMBIGUOUS", "invalidInput", { key: "aas" });
|
||||
return {
|
||||
mcp: mcp ? mcp.valueNode : null,
|
||||
aas: aas ? aas.valueNode : null,
|
||||
inspection: {
|
||||
sectionPresent: Boolean(aas),
|
||||
configured: Boolean(aas) && KNOWN_KEYS.every((key) => Object.hasOwn(aas.valueNode.value, key)),
|
||||
unknownKeys: aas ? Object.keys(aas.valueNode.value).filter((key) => !KNOWN_KEYS.includes(key)).sort() : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildClaudeText(bytes, server) {
|
||||
const source = decodeUtf8(bytes);
|
||||
const text = source.trim() === "" ? "{}\n" : source;
|
||||
const root = parseJsonAst(text);
|
||||
const { mcp, aas, inspection } = inspectTree(root);
|
||||
const changedPaths = KNOWN_KEYS.filter((key) => !aas || !Object.hasOwn(aas.value, key) || !sameValue(aas.value[key], server[key])).map((key) => `mcpServers.aas.${key}`);
|
||||
if (changedPaths.length === 0) return { text, inspection, changedPaths };
|
||||
const indentUnit = indentation(text);
|
||||
let nextText;
|
||||
if (aas) {
|
||||
const nextAas = { ...aas.value, command: server.command, args: server.args, env: server.env };
|
||||
nextText = replaceRange(text, aas.start, aas.end, formatValue(nextAas, lineIndent(text, aas.start), indentUnit));
|
||||
} else if (mcp) {
|
||||
nextText = insertProperty(text, mcp, "aas", server, indentUnit);
|
||||
} else {
|
||||
nextText = insertProperty(text, root, "mcpServers", { aas: server }, indentUnit);
|
||||
}
|
||||
return { text: nextText, inspection, changedPaths };
|
||||
}
|
||||
|
||||
function inspectClaudeBytes(bytes) {
|
||||
const source = decodeUtf8(bytes);
|
||||
const root = parseJsonAst(source.trim() === "" ? "{}" : source);
|
||||
return inspectTree(root).inspection;
|
||||
}
|
||||
|
||||
module.exports = { buildClaudeText, inspectClaudeBytes, parseJsonAst };
|
||||
@@ -0,0 +1,183 @@
|
||||
"use strict";
|
||||
|
||||
const { hostConfigError } = require("./errors");
|
||||
|
||||
const SECTION = "mcp_servers.aas";
|
||||
const KNOWN_KEYS = ["command", "args", "enabled"];
|
||||
|
||||
function decodeUtf8(bytes) {
|
||||
if (bytes.length > 1024 * 1024) throw hostConfigError("AAS_ADAPTER_CONFIG_TOO_LARGE", "invalidInput");
|
||||
try {
|
||||
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
if (text.charCodeAt(0) === 0xfeff) throw new Error("bom");
|
||||
return text;
|
||||
} catch {
|
||||
throw hostConfigError("AAS_ADAPTER_CONFIG_ENCODING_INVALID", "invalidInput");
|
||||
}
|
||||
}
|
||||
|
||||
function stripInlineComment(value) {
|
||||
let quoted = false;
|
||||
let escaped = false;
|
||||
let depth = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const character = value[index];
|
||||
if (quoted) {
|
||||
if (escaped) escaped = false;
|
||||
else if (character === "\\") escaped = true;
|
||||
else if (character === '"') quoted = false;
|
||||
continue;
|
||||
}
|
||||
if (character === '"') quoted = true;
|
||||
else if (character === "[") depth += 1;
|
||||
else if (character === "]") depth -= 1;
|
||||
else if (character === "#" && depth === 0) return { value: value.slice(0, index).trim(), comment: value.slice(index) };
|
||||
}
|
||||
return { value: value.trim(), comment: "" };
|
||||
}
|
||||
|
||||
function findAssignment(line) {
|
||||
let quoted = false;
|
||||
let escaped = false;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const character = line[index];
|
||||
if (quoted) {
|
||||
if (escaped) escaped = false;
|
||||
else if (character === "\\") escaped = true;
|
||||
else if (character === '"') quoted = false;
|
||||
} else if (character === '"') quoted = true;
|
||||
else if (character === "=") return { key: line.slice(0, index).trim(), raw: line.slice(index + 1), indent: line.match(/^\s*/)[0] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseKnownValue(key, raw) {
|
||||
const source = stripInlineComment(raw).value;
|
||||
try {
|
||||
if (key === "command") {
|
||||
const value = JSON.parse(source);
|
||||
if (typeof value !== "string") throw new Error("not string");
|
||||
return value;
|
||||
}
|
||||
if (key === "args") {
|
||||
const value = JSON.parse(source);
|
||||
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) throw new Error("not string array");
|
||||
return value;
|
||||
}
|
||||
if (key === "enabled" && /^(true|false)$/.test(source)) return source === "true";
|
||||
} catch {
|
||||
// Convert all unsupported TOML forms into a stable adapter error below.
|
||||
}
|
||||
throw hostConfigError("AAS_ADAPTER_CODEX_SECTION_AMBIGUOUS", "invalidInput", { key });
|
||||
}
|
||||
|
||||
function parseCodexText(text) {
|
||||
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
||||
const terminalNewline = text.endsWith("\n");
|
||||
const lines = text.length === 0 ? [] : text.split(/\r?\n/);
|
||||
if (terminalNewline) lines.pop();
|
||||
let currentSection = "";
|
||||
let exactStart = -1;
|
||||
let exactEnd = lines.length;
|
||||
let exactCount = 0;
|
||||
const values = {};
|
||||
const lineIndexes = {};
|
||||
const unknownKeys = [];
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const trimmed = lines[index].trim();
|
||||
const header = trimmed.match(/^\[([^\]]+)\]\s*(?:#.*)?$/);
|
||||
const arrayHeader = trimmed.match(/^\[\[([^\]]+)\]\]\s*(?:#.*)?$/);
|
||||
if (arrayHeader) {
|
||||
const name = arrayHeader[1].trim();
|
||||
if (name.includes("mcp_servers") && name.includes("aas")) throw hostConfigError("AAS_ADAPTER_CODEX_SECTION_AMBIGUOUS", "invalidInput");
|
||||
currentSection = name;
|
||||
continue;
|
||||
}
|
||||
if (header) {
|
||||
const name = header[1].trim();
|
||||
if (currentSection === SECTION && exactEnd === lines.length) exactEnd = index;
|
||||
if (name === SECTION) {
|
||||
exactCount += 1;
|
||||
exactStart = index;
|
||||
} else if (name.includes("mcp_servers") && name.includes("aas")) {
|
||||
throw hostConfigError("AAS_ADAPTER_CODEX_SECTION_AMBIGUOUS", "invalidInput");
|
||||
}
|
||||
currentSection = name;
|
||||
continue;
|
||||
}
|
||||
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
||||
const assignment = findAssignment(lines[index]);
|
||||
if (!assignment) continue;
|
||||
const key = assignment.key;
|
||||
if (currentSection !== SECTION) {
|
||||
if (key === "mcp_servers.aas" || key.startsWith("mcp_servers.aas.") || (currentSection === "mcp_servers" && (key === "aas" || key.startsWith("aas.")))) {
|
||||
throw hostConfigError("AAS_ADAPTER_CODEX_SECTION_AMBIGUOUS", "invalidInput");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (KNOWN_KEYS.includes(key)) {
|
||||
if (Object.hasOwn(lineIndexes, key)) throw hostConfigError("AAS_ADAPTER_CODEX_SECTION_AMBIGUOUS", "invalidInput", { key });
|
||||
values[key] = parseKnownValue(key, assignment.raw);
|
||||
lineIndexes[key] = index;
|
||||
} else {
|
||||
unknownKeys.push(key);
|
||||
}
|
||||
}
|
||||
if (currentSection === SECTION && exactEnd === lines.length) exactEnd = lines.length;
|
||||
if (exactCount > 1) throw hostConfigError("AAS_ADAPTER_CODEX_SECTION_AMBIGUOUS", "invalidInput");
|
||||
return { newline, terminalNewline, lines, present: exactCount === 1, start: exactStart, end: exactEnd, values, lineIndexes, unknownKeys };
|
||||
}
|
||||
|
||||
function encodeValue(key, value) {
|
||||
if (key === "command") return JSON.stringify(value);
|
||||
if (key === "args") return `[${value.map((entry) => JSON.stringify(entry)).join(", ")}]`;
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
|
||||
function sameValue(left, right) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function buildCodexText(bytes, server) {
|
||||
const text = decodeUtf8(bytes);
|
||||
const parsed = parseCodexText(text);
|
||||
const lines = [...parsed.lines];
|
||||
if (!parsed.present) {
|
||||
if (lines.length > 0 && lines.at(-1).trim() !== "") lines.push("");
|
||||
lines.push(`[${SECTION}]`);
|
||||
for (const key of KNOWN_KEYS) lines.push(`${key} = ${encodeValue(key, server[key])}`);
|
||||
} else {
|
||||
for (const key of KNOWN_KEYS) {
|
||||
if (!Object.hasOwn(parsed.lineIndexes, key)) continue;
|
||||
if (sameValue(parsed.values[key], server[key])) continue;
|
||||
const index = parsed.lineIndexes[key];
|
||||
const assignment = findAssignment(lines[index]);
|
||||
const { comment } = stripInlineComment(assignment.raw);
|
||||
lines[index] = `${assignment.indent}${key} = ${encodeValue(key, server[key])}${comment ? ` ${comment}` : ""}`;
|
||||
}
|
||||
const missing = KNOWN_KEYS.filter((key) => !Object.hasOwn(parsed.lineIndexes, key));
|
||||
if (missing.length > 0) lines.splice(parsed.end, 0, ...missing.map((key) => `${key} = ${encodeValue(key, server[key])}`));
|
||||
}
|
||||
const nextText = `${lines.join(parsed.newline)}${lines.length > 0 && (parsed.terminalNewline || text.length === 0) ? parsed.newline : ""}`;
|
||||
return {
|
||||
text: nextText,
|
||||
inspection: {
|
||||
sectionPresent: parsed.present,
|
||||
configured: parsed.present && KNOWN_KEYS.every((key) => Object.hasOwn(parsed.values, key)),
|
||||
unknownKeys: [...parsed.unknownKeys].sort(),
|
||||
},
|
||||
changedPaths: KNOWN_KEYS.filter((key) => !Object.hasOwn(parsed.values, key) || !sameValue(parsed.values[key], server[key])).map((key) => `mcp_servers.aas.${key}`),
|
||||
};
|
||||
}
|
||||
|
||||
function inspectCodexBytes(bytes) {
|
||||
const parsed = parseCodexText(decodeUtf8(bytes));
|
||||
return {
|
||||
sectionPresent: parsed.present,
|
||||
configured: parsed.present && KNOWN_KEYS.every((key) => Object.hasOwn(parsed.values, key)),
|
||||
unknownKeys: [...parsed.unknownKeys].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { buildCodexText, inspectCodexBytes, parseCodexText };
|
||||
@@ -0,0 +1,17 @@
|
||||
"use strict";
|
||||
|
||||
class HostConfigError extends Error {
|
||||
constructor(code, category = "invalidInput", details = {}) {
|
||||
super(code);
|
||||
this.name = "HostConfigError";
|
||||
this.code = code;
|
||||
this.category = category;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function hostConfigError(code, category, details) {
|
||||
return new HostConfigError(code, category, details);
|
||||
}
|
||||
|
||||
module.exports = { HostConfigError, hostConfigError };
|
||||
@@ -0,0 +1,343 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fsp = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { canonicalJson } = require("../canonical-json");
|
||||
const { buildClaudeText, inspectClaudeBytes } = require("./claude");
|
||||
const { buildCodexText, inspectCodexBytes } = require("./codex");
|
||||
const { HostConfigError, hostConfigError } = require("./errors");
|
||||
const {
|
||||
assertExplicitAbsolutePath,
|
||||
assertOwned,
|
||||
assertSafeDirectory,
|
||||
assertWindowsPrivatePath,
|
||||
copyWindowsAcl,
|
||||
currentUid,
|
||||
digest,
|
||||
fsyncDirectory,
|
||||
hardenWindowsPrivatePath,
|
||||
inspectRegularFile,
|
||||
sameIdentity,
|
||||
writeExclusiveSynced,
|
||||
} = require("./safety");
|
||||
const { normalizeServer } = require("./values");
|
||||
|
||||
const PRIVATE = new WeakMap();
|
||||
const HOSTS = new Set(["codex", "claude"]);
|
||||
const SCOPES = new Set(["project", "user"]);
|
||||
|
||||
function validateHostScope(host, scope) {
|
||||
if (!HOSTS.has(host)) throw hostConfigError("AAS_ADAPTER_HOST_UNSUPPORTED", "invalidInput", { host });
|
||||
if (!SCOPES.has(scope)) throw hostConfigError("AAS_ADAPTER_SCOPE_INVALID", "invalidInput", { scope });
|
||||
}
|
||||
|
||||
function parentIdentity(parent) {
|
||||
const stat = parent.stat;
|
||||
return { dev: stat.dev, ino: stat.ino, uid: stat.uid, gid: stat.gid, mode: stat.mode & 0o7777 };
|
||||
}
|
||||
|
||||
function publicInspection(host, scope, snapshot, inspection) {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
host,
|
||||
scope,
|
||||
configPath: snapshot.path,
|
||||
exists: snapshot.exists,
|
||||
digest: snapshot.digest,
|
||||
mode: snapshot.identity?.mode ?? null,
|
||||
sectionPresent: inspection.sectionPresent,
|
||||
configured: inspection.configured,
|
||||
unknownKeys: Object.freeze([...inspection.unknownKeys]),
|
||||
});
|
||||
}
|
||||
|
||||
async function inspectHostConfig({ host, scope, configPath }) {
|
||||
validateHostScope(host, scope);
|
||||
const snapshot = await inspectRegularFile(configPath);
|
||||
const inspection = host === "codex" ? inspectCodexBytes(snapshot.bytes) : inspectClaudeBytes(snapshot.bytes);
|
||||
return publicInspection(host, scope, snapshot, inspection);
|
||||
}
|
||||
|
||||
async function buildPatch({ host, scope, configPath, server }) {
|
||||
validateHostScope(host, scope);
|
||||
const desired = normalizeServer(host, server);
|
||||
const snapshot = await inspectRegularFile(configPath);
|
||||
const built = host === "codex" ? buildCodexText(snapshot.bytes, desired) : buildClaudeText(snapshot.bytes, desired);
|
||||
const nextBytes = Buffer.from(built.text, "utf8");
|
||||
const nextDigest = digest(nextBytes);
|
||||
const changed = snapshot.digest !== nextDigest;
|
||||
const patch = {
|
||||
schemaVersion: 1,
|
||||
host,
|
||||
scope,
|
||||
configPath: snapshot.path,
|
||||
status: changed ? "changesProposed" : "alreadyConfigured",
|
||||
changed,
|
||||
exists: snapshot.exists,
|
||||
currentDigest: snapshot.digest,
|
||||
nextDigest,
|
||||
redactedDiff: Object.freeze({
|
||||
schemaVersion: 1,
|
||||
changedPaths: Object.freeze([...built.changedPaths].sort()),
|
||||
valuesRedacted: true,
|
||||
envValuesRedacted: true,
|
||||
}),
|
||||
inspection: publicInspection(host, scope, snapshot, built.inspection),
|
||||
};
|
||||
Object.freeze(patch);
|
||||
PRIVATE.set(patch, {
|
||||
currentBytes: snapshot.bytes,
|
||||
nextBytes,
|
||||
identity: snapshot.identity,
|
||||
parentIdentity: parentIdentity(snapshot.parent),
|
||||
});
|
||||
return patch;
|
||||
}
|
||||
|
||||
function previewPatch(patch) {
|
||||
if (!PRIVATE.has(patch)) throw hostConfigError("AAS_ADAPTER_PATCH_INVALID", "invalidInput");
|
||||
return patch;
|
||||
}
|
||||
|
||||
async function acquireLock(lockPath, payload) {
|
||||
try {
|
||||
const handle = await fsp.open(lockPath, "wx", 0o600);
|
||||
try {
|
||||
hardenWindowsPrivatePath(lockPath, false);
|
||||
await handle.writeFile(`${canonicalJson(payload)}\n`);
|
||||
await handle.chmod(0o600);
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === "EEXIST") throw hostConfigError("AAS_ADAPTER_LOCKED", "conflict");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function releaseLock(lockPath) {
|
||||
try { await fsp.unlink(lockPath); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
await fsyncDirectory(path.dirname(lockPath));
|
||||
}
|
||||
|
||||
async function ensureBackupDirectory(backupDirectory) {
|
||||
const absolute = assertExplicitAbsolutePath(backupDirectory, "AAS_ADAPTER_BACKUP_PATH_INVALID");
|
||||
const existing = assertSafeDirectory(absolute, { allowMissing: true });
|
||||
if (existing.exists) {
|
||||
if (process.platform !== "win32" && (existing.stat.mode & 0o077) !== 0) throw hostConfigError("AAS_ADAPTER_BACKUP_DIRECTORY_PERMISSIONS", "filesystem");
|
||||
if (process.platform === "win32") assertWindowsPrivatePath(existing.path);
|
||||
return absolute;
|
||||
}
|
||||
assertSafeDirectory(path.dirname(absolute));
|
||||
try {
|
||||
await fsp.mkdir(absolute, { mode: 0o700 });
|
||||
} catch (error) {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
}
|
||||
const created = assertSafeDirectory(absolute);
|
||||
await fsp.chmod(created.path, 0o700);
|
||||
if (process.platform === "win32") hardenWindowsPrivatePath(created.path, true);
|
||||
await fsyncDirectory(path.dirname(created.path));
|
||||
return created.path;
|
||||
}
|
||||
|
||||
function safeTimestamp(value) {
|
||||
const date = value instanceof Date ? value : new Date(value ?? Date.now());
|
||||
if (!Number.isFinite(date.getTime())) throw hostConfigError("AAS_ADAPTER_TIMESTAMP_INVALID", "invalidInput");
|
||||
return date.toISOString().replace(/[:.]/g, "-");
|
||||
}
|
||||
|
||||
function configKey(configPath) {
|
||||
return digest(Buffer.from(configPath)).slice("sha256-".length, "sha256-".length + 24);
|
||||
}
|
||||
|
||||
async function createBackup({ patch, snapshot, backupDirectory, retention, now }) {
|
||||
if (!Number.isInteger(retention) || retention < 1 || retention > 100) throw hostConfigError("AAS_ADAPTER_RETENTION_INVALID", "invalidInput");
|
||||
const directory = await ensureBackupDirectory(backupDirectory);
|
||||
const key = configKey(patch.configPath);
|
||||
const stem = `${key}-${safeTimestamp(now)}-${snapshot.digest.slice(-12)}`;
|
||||
const backupFile = `${stem}.bak`;
|
||||
const metadataFile = `${stem}.json`;
|
||||
const backupPath = path.join(directory, backupFile);
|
||||
const metadataPath = path.join(directory, metadataFile);
|
||||
try {
|
||||
await writeExclusiveSynced(backupPath, snapshot.bytes, 0o600);
|
||||
const metadata = {
|
||||
schemaVersion: 1,
|
||||
host: patch.host,
|
||||
scope: patch.scope,
|
||||
configKey: key,
|
||||
configBasename: path.basename(patch.configPath),
|
||||
backupFile,
|
||||
sourceDigest: snapshot.digest,
|
||||
sourceMode: snapshot.identity.mode,
|
||||
sourceUid: snapshot.identity.uid,
|
||||
sourceGid: snapshot.identity.gid,
|
||||
createdAt: (now instanceof Date ? now : new Date(now ?? Date.now())).toISOString(),
|
||||
retention: { maxEntries: retention, enforcement: "explicit-cleanup" },
|
||||
};
|
||||
await writeExclusiveSynced(metadataPath, Buffer.from(`${canonicalJson(metadata)}\n`), 0o600);
|
||||
await fsyncDirectory(directory);
|
||||
return Object.freeze({ backupPath, metadataPath, sourceDigest: snapshot.digest });
|
||||
} catch (error) {
|
||||
await Promise.allSettled([fsp.unlink(backupPath), fsp.unlink(metadataPath)]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertSnapshotUnchanged(patch, internal) {
|
||||
const current = await inspectRegularFile(patch.configPath);
|
||||
if (current.exists !== patch.exists || current.digest !== patch.currentDigest || !sameIdentity(current.identity, internal.identity)) {
|
||||
throw hostConfigError("AAS_ADAPTER_CONFIG_CHANGED", "conflict");
|
||||
}
|
||||
if (!sameIdentity(parentIdentity(current.parent), internal.parentIdentity)) {
|
||||
throw hostConfigError("AAS_ADAPTER_DIRECTORY_CHANGED", "conflict");
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
async function applyHostConfigPatch({ patch, approved = false, backupDirectory, retention = 5, now } = {}) {
|
||||
const internal = PRIVATE.get(patch);
|
||||
if (!internal) throw hostConfigError("AAS_ADAPTER_PATCH_INVALID", "invalidInput");
|
||||
if (!approved) throw hostConfigError("AAS_ADAPTER_APPROVAL_REQUIRED", "policy");
|
||||
if (!patch.changed) return Object.freeze({ status: "alreadyConfigured", configDigest: patch.currentDigest, backup: null });
|
||||
if (patch.exists && typeof backupDirectory !== "string") throw hostConfigError("AAS_ADAPTER_BACKUP_REQUIRED", "policy");
|
||||
|
||||
const directory = path.dirname(patch.configPath);
|
||||
const lockPath = path.join(directory, `.${path.basename(patch.configPath)}.aas.lock`);
|
||||
const lockPayload = { schemaVersion: 1, pid: process.pid, createdAt: new Date().toISOString(), configKey: configKey(patch.configPath) };
|
||||
await acquireLock(lockPath, lockPayload);
|
||||
let stagePath;
|
||||
try {
|
||||
let current = await assertSnapshotUnchanged(patch, internal);
|
||||
const backup = current.exists ? await createBackup({ patch, snapshot: current, backupDirectory, retention, now }) : null;
|
||||
stagePath = path.join(directory, `.${path.basename(patch.configPath)}.aas-stage-${process.pid}-${crypto.randomBytes(12).toString("hex")}`);
|
||||
const targetMode = current.exists ? current.identity.mode : 0o600;
|
||||
const handle = await fsp.open(stagePath, "wx", targetMode);
|
||||
try {
|
||||
// A newly created Windows file inherits its parent DACL regardless of
|
||||
// the POSIX mode. Install the approved target ACL while the stage is
|
||||
// still empty, before writing configuration bytes.
|
||||
if (process.platform === "win32") {
|
||||
if (current.exists) copyWindowsAcl(patch.configPath, stagePath);
|
||||
else hardenWindowsPrivatePath(stagePath, false);
|
||||
}
|
||||
await handle.writeFile(internal.nextBytes);
|
||||
await handle.chmod(targetMode);
|
||||
if (current.exists && currentUid() !== null) await handle.chown(current.identity.uid, current.identity.gid);
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
current = await assertSnapshotUnchanged(patch, internal);
|
||||
await fsp.rename(stagePath, patch.configPath);
|
||||
stagePath = null;
|
||||
await fsyncDirectory(directory);
|
||||
const written = await inspectRegularFile(patch.configPath, { allowMissing: false });
|
||||
if (written.digest !== patch.nextDigest || (process.platform !== "win32" && written.identity.mode !== targetMode)) {
|
||||
throw hostConfigError("AAS_ADAPTER_WRITE_VERIFICATION_FAILED", "execution");
|
||||
}
|
||||
return Object.freeze({ status: "applied", configDigest: written.digest, backup });
|
||||
} finally {
|
||||
if (stagePath) await fsp.unlink(stagePath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
||||
await releaseLock(lockPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function readBackupRecords({ backupDirectory, configPath, keep }) {
|
||||
if (!Number.isInteger(keep) || keep < 0 || keep > 100) throw hostConfigError("AAS_ADAPTER_RETENTION_INVALID", "invalidInput");
|
||||
const backup = assertSafeDirectory(backupDirectory);
|
||||
if (process.platform !== "win32" && (backup.stat.mode & 0o077) !== 0) throw hostConfigError("AAS_ADAPTER_BACKUP_DIRECTORY_PERMISSIONS", "filesystem");
|
||||
const directory = backup.path;
|
||||
const absoluteConfig = assertExplicitAbsolutePath(configPath);
|
||||
const key = configKey(absoluteConfig);
|
||||
const names = (await fsp.readdir(directory)).filter((name) => name.startsWith(`${key}-`) && name.endsWith(".json")).sort().reverse();
|
||||
const records = [];
|
||||
for (const name of names) {
|
||||
const metadataPath = path.join(directory, name);
|
||||
const stat = await fsp.lstat(metadataPath);
|
||||
if (stat.isSymbolicLink() || !stat.isFile() || (process.platform !== "win32" && (stat.mode & 0o777) !== 0o600)) throw hostConfigError("AAS_ADAPTER_BACKUP_UNSAFE", "filesystem");
|
||||
assertOwned(stat, undefined, metadataPath);
|
||||
if (process.platform === "win32") assertWindowsPrivatePath(metadataPath);
|
||||
const bytes = await fsp.readFile(metadataPath);
|
||||
if (bytes.length > 32 * 1024) throw hostConfigError("AAS_ADAPTER_BACKUP_UNSAFE", "filesystem");
|
||||
let metadata;
|
||||
try { metadata = JSON.parse(bytes.toString("utf8")); } catch { throw hostConfigError("AAS_ADAPTER_BACKUP_UNSAFE", "filesystem"); }
|
||||
if (metadata.configKey !== key || typeof metadata.backupFile !== "string" || path.basename(metadata.backupFile) !== metadata.backupFile) {
|
||||
throw hostConfigError("AAS_ADAPTER_BACKUP_UNSAFE", "filesystem");
|
||||
}
|
||||
const backupPath = path.join(directory, metadata.backupFile);
|
||||
const backupStat = await fsp.lstat(backupPath);
|
||||
if (backupStat.isSymbolicLink() || !backupStat.isFile() || (process.platform !== "win32" && (backupStat.mode & 0o777) !== 0o600)) throw hostConfigError("AAS_ADAPTER_BACKUP_UNSAFE", "filesystem");
|
||||
assertOwned(backupStat, undefined, backupPath);
|
||||
if (process.platform === "win32") assertWindowsPrivatePath(backupPath);
|
||||
const backupBytes = await fsp.readFile(backupPath);
|
||||
if (digest(backupBytes) !== metadata.sourceDigest) throw hostConfigError("AAS_ADAPTER_BACKUP_UNSAFE", "filesystem");
|
||||
records.push({
|
||||
metadata,
|
||||
metadataPath,
|
||||
backupPath,
|
||||
approvalRecord: {
|
||||
metadataNameDigest: digest(Buffer.from(name)),
|
||||
backupNameDigest: digest(Buffer.from(metadata.backupFile)),
|
||||
metadataDigest: digest(bytes),
|
||||
backupDigest: digest(backupBytes),
|
||||
},
|
||||
});
|
||||
}
|
||||
const approvalPayload = {
|
||||
schemaVersion: 1,
|
||||
action: "mcp.backups.cleanup",
|
||||
backupDirectoryDigest: digest(Buffer.from(directory)),
|
||||
configPathDigest: digest(Buffer.from(absoluteConfig)),
|
||||
keep,
|
||||
records: records.map((record) => record.approvalRecord),
|
||||
};
|
||||
return { directory, key, records, approvalDigest: digest(Buffer.from(canonicalJson(approvalPayload))) };
|
||||
}
|
||||
|
||||
async function previewBackupCleanup({ backupDirectory, configPath, keep } = {}) {
|
||||
const inspected = await readBackupRecords({ backupDirectory, configPath, keep });
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
status: inspected.records.length > keep ? "changesProposed" : "nothingToClean",
|
||||
approvalDigest: inspected.approvalDigest,
|
||||
retained: Math.min(keep, inspected.records.length),
|
||||
removeCount: Math.max(0, inspected.records.length - keep),
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupBackups({ backupDirectory, configPath, keep, approved = false, approvalDigest } = {}) {
|
||||
if (!approved && !approvalDigest) throw hostConfigError("AAS_ADAPTER_APPROVAL_REQUIRED", "policy");
|
||||
const preview = await readBackupRecords({ backupDirectory, configPath, keep });
|
||||
if (approvalDigest && approvalDigest !== preview.approvalDigest) throw hostConfigError("AAS_ADAPTER_APPROVAL_MISMATCH", "policy");
|
||||
const { directory, key } = preview;
|
||||
const lockPath = path.join(directory, `.cleanup-${key}.lock`);
|
||||
await acquireLock(lockPath, { schemaVersion: 1, pid: process.pid, configKey: key, createdAt: new Date().toISOString() });
|
||||
try {
|
||||
const locked = await readBackupRecords({ backupDirectory, configPath, keep });
|
||||
if (locked.approvalDigest !== preview.approvalDigest) throw hostConfigError("AAS_ADAPTER_BACKUP_CHANGED", "conflict");
|
||||
const removed = [];
|
||||
for (const record of locked.records.slice(keep)) {
|
||||
await fsp.unlink(record.backupPath);
|
||||
await fsp.unlink(record.metadataPath);
|
||||
removed.push(path.basename(record.backupPath));
|
||||
}
|
||||
await fsyncDirectory(directory);
|
||||
return Object.freeze({ status: "cleaned", retained: Math.min(keep, locked.records.length), removed: Object.freeze(removed) });
|
||||
} finally {
|
||||
await releaseLock(lockPath);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HostConfigError,
|
||||
applyHostConfigPatch,
|
||||
buildPatch,
|
||||
cleanupBackups,
|
||||
inspectHostConfig,
|
||||
previewBackupCleanup,
|
||||
previewPatch,
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { fsyncDirectoryAsync } = require("../durability");
|
||||
const { hostConfigError } = require("./errors");
|
||||
|
||||
function digest(bytes) {
|
||||
return `sha256-${crypto.createHash("sha256").update(bytes).digest("hex")}`;
|
||||
}
|
||||
|
||||
function currentUid() {
|
||||
return typeof process.getuid === "function" ? process.getuid() : null;
|
||||
}
|
||||
|
||||
function runWindowsAcl(script, filePath) {
|
||||
const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script, filePath], {
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
timeout: 15000,
|
||||
maxBuffer: 64 * 1024,
|
||||
});
|
||||
if (result.status !== 0 || result.error) {
|
||||
throw hostConfigError("AAS_ADAPTER_WINDOWS_ACL_FAILED", "filesystem", { status: result.status ?? null });
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function windowsAclSnapshot(filePath) {
|
||||
const script = [
|
||||
"$ErrorActionPreference='Stop'",
|
||||
"$p=$args[0]",
|
||||
"$me=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value",
|
||||
"$a=Get-Acl -LiteralPath $p",
|
||||
"$owner=(New-Object Security.Principal.NTAccount($a.Owner)).Translate([Security.Principal.SecurityIdentifier]).Value",
|
||||
"$rules=@($a.Access | ForEach-Object { $_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value + '|' + $_.AccessControlType + '|' + $_.IsInherited })",
|
||||
"@{current=$me;owner=$owner;protected=$a.AreAccessRulesProtected;rules=$rules}|ConvertTo-Json -Compress",
|
||||
].join(";");
|
||||
let snapshot;
|
||||
try { snapshot = JSON.parse(runWindowsAcl(script, filePath)); } catch (cause) {
|
||||
if (cause && cause.code) throw cause;
|
||||
throw hostConfigError("AAS_ADAPTER_WINDOWS_ACL_FAILED", "filesystem");
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function assertWindowsOwned(filePath) {
|
||||
if (process.platform !== "win32") return;
|
||||
const snapshot = windowsAclSnapshot(filePath);
|
||||
if (snapshot.owner !== snapshot.current) throw hostConfigError("AAS_ADAPTER_OWNERSHIP_MISMATCH", "filesystem");
|
||||
}
|
||||
|
||||
function assertWindowsPrivatePath(filePath) {
|
||||
if (process.platform !== "win32") return;
|
||||
const snapshot = windowsAclSnapshot(filePath);
|
||||
const rules = Array.isArray(snapshot.rules) ? snapshot.rules : (snapshot.rules ? [snapshot.rules] : []);
|
||||
if (snapshot.owner !== snapshot.current || snapshot.protected !== true || rules.length !== 1
|
||||
|| rules[0] !== `${snapshot.current}|Allow|False`) {
|
||||
throw hostConfigError("AAS_ADAPTER_WINDOWS_ACL_UNSAFE", "filesystem");
|
||||
}
|
||||
}
|
||||
|
||||
function hardenWindowsPrivatePath(filePath, directory = false) {
|
||||
if (process.platform !== "win32") return;
|
||||
const script = [
|
||||
"$ErrorActionPreference='Stop'",
|
||||
"$p=$args[0]",
|
||||
"$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User",
|
||||
"$acl=Get-Acl -LiteralPath $p",
|
||||
"$acl.SetAccessRuleProtection($true,$false)",
|
||||
"@($acl.Access)|ForEach-Object{$acl.RemoveAccessRuleAll($_)}",
|
||||
`$inherit=${directory ? "[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit'" : "[Security.AccessControl.InheritanceFlags]::None"}`,
|
||||
"$rule=New-Object Security.AccessControl.FileSystemAccessRule($sid,[Security.AccessControl.FileSystemRights]::FullControl,$inherit,[Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow)",
|
||||
"$acl.SetOwner($sid)",
|
||||
"$acl.SetAccessRule($rule)",
|
||||
"Set-Acl -LiteralPath $p -AclObject $acl",
|
||||
].join(";");
|
||||
runWindowsAcl(script, filePath);
|
||||
assertWindowsPrivatePath(filePath);
|
||||
}
|
||||
|
||||
function copyWindowsAcl(sourcePath, destinationPath) {
|
||||
if (process.platform !== "win32") return;
|
||||
const script = "$ErrorActionPreference='Stop';$source=$args[0];$destination=$args[1];$acl=Get-Acl -LiteralPath $source;Set-Acl -LiteralPath $destination -AclObject $acl";
|
||||
const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script, sourcePath, destinationPath], {
|
||||
encoding: "utf8", windowsHide: true, timeout: 15000, maxBuffer: 64 * 1024,
|
||||
});
|
||||
if (result.status !== 0 || result.error) throw hostConfigError("AAS_ADAPTER_WINDOWS_ACL_FAILED", "filesystem", { status: result.status ?? null });
|
||||
assertWindowsOwned(destinationPath);
|
||||
}
|
||||
|
||||
function assertExplicitAbsolutePath(filePath, code = "AAS_ADAPTER_PATH_INVALID") {
|
||||
if (typeof filePath !== "string" || filePath.length === 0 || filePath.includes("\0") || !path.isAbsolute(filePath)) {
|
||||
throw hostConfigError(code, "invalidInput");
|
||||
}
|
||||
return path.normalize(filePath);
|
||||
}
|
||||
|
||||
function assertOwned(stat, expectedUid = currentUid(), filePath = null) {
|
||||
if (expectedUid !== null && stat.uid !== expectedUid) {
|
||||
throw hostConfigError("AAS_ADAPTER_OWNERSHIP_MISMATCH", "filesystem", { expectedUid, actualUid: stat.uid });
|
||||
}
|
||||
if (process.platform === "win32" && filePath) assertWindowsOwned(filePath);
|
||||
}
|
||||
|
||||
function assertSafeDirectory(directoryPath, options = {}) {
|
||||
const absolute = assertExplicitAbsolutePath(directoryPath, "AAS_ADAPTER_DIRECTORY_PATH_INVALID");
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(absolute);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT" && options.allowMissing) return { path: absolute, exists: false };
|
||||
throw error;
|
||||
}
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
||||
throw hostConfigError("AAS_ADAPTER_DIRECTORY_UNSAFE", "filesystem");
|
||||
}
|
||||
assertOwned(stat, options.expectedUid, absolute);
|
||||
return { path: absolute, exists: true, stat };
|
||||
}
|
||||
|
||||
async function inspectRegularFile(filePath, options = {}) {
|
||||
const absolute = assertExplicitAbsolutePath(filePath);
|
||||
const parent = assertSafeDirectory(path.dirname(absolute), { expectedUid: options.expectedUid });
|
||||
let stat;
|
||||
try {
|
||||
stat = await fsp.lstat(absolute);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT" && options.allowMissing !== false) {
|
||||
return { path: absolute, parent, exists: false, bytes: Buffer.alloc(0), digest: digest(Buffer.alloc(0)) };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (stat.isSymbolicLink() || !stat.isFile()) {
|
||||
throw hostConfigError("AAS_ADAPTER_CONFIG_UNSAFE", "filesystem");
|
||||
}
|
||||
assertOwned(stat, options.expectedUid, absolute);
|
||||
const handle = await fsp.open(absolute, "r");
|
||||
let bytes;
|
||||
let openedStat;
|
||||
try {
|
||||
openedStat = await handle.stat();
|
||||
if (!openedStat.isFile() || openedStat.dev !== stat.dev || openedStat.ino !== stat.ino) {
|
||||
throw hostConfigError("AAS_ADAPTER_CONFIG_CHANGED", "conflict");
|
||||
}
|
||||
bytes = await handle.readFile();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return {
|
||||
path: absolute,
|
||||
parent,
|
||||
exists: true,
|
||||
bytes,
|
||||
digest: digest(bytes),
|
||||
identity: {
|
||||
dev: stat.dev,
|
||||
ino: stat.ino,
|
||||
uid: stat.uid,
|
||||
gid: stat.gid,
|
||||
mode: stat.mode & 0o7777,
|
||||
size: stat.size,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sameIdentity(left, right) {
|
||||
if (!left || !right) return left === right;
|
||||
return ["dev", "ino", "uid", "gid", "mode", "size"].every((key) => left[key] === right[key]);
|
||||
}
|
||||
|
||||
async function fsyncDirectory(directoryPath) {
|
||||
await fsyncDirectoryAsync(directoryPath);
|
||||
}
|
||||
|
||||
async function writeExclusiveSynced(filePath, bytes, mode = 0o600) {
|
||||
const handle = await fsp.open(filePath, "wx", mode);
|
||||
try {
|
||||
// On Windows the create mode does not constrain the inherited DACL. Make
|
||||
// the still-empty file owner-only before any potentially sensitive bytes
|
||||
// are written.
|
||||
hardenWindowsPrivatePath(filePath, false);
|
||||
await handle.writeFile(bytes);
|
||||
await handle.chmod(mode);
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertExplicitAbsolutePath,
|
||||
assertOwned,
|
||||
assertSafeDirectory,
|
||||
assertWindowsPrivatePath,
|
||||
copyWindowsAcl,
|
||||
currentUid,
|
||||
digest,
|
||||
fsyncDirectory,
|
||||
hardenWindowsPrivatePath,
|
||||
inspectRegularFile,
|
||||
sameIdentity,
|
||||
writeExclusiveSynced,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
"use strict";
|
||||
|
||||
const { hostConfigError } = require("./errors");
|
||||
|
||||
function assertString(value, field, maximum = 4096) {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > maximum || value.includes("\0")) {
|
||||
throw hostConfigError("AAS_ADAPTER_SERVER_INVALID", "invalidInput", { field });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeArgs(args) {
|
||||
if (!Array.isArray(args) || args.length > 64) {
|
||||
throw hostConfigError("AAS_ADAPTER_SERVER_INVALID", "invalidInput", { field: "args" });
|
||||
}
|
||||
return args.map((entry, index) => assertString(entry, `args[${index}]`));
|
||||
}
|
||||
|
||||
function normalizeEnv(env) {
|
||||
if (!env || typeof env !== "object" || Array.isArray(env) || Object.getPrototypeOf(env) !== Object.prototype) {
|
||||
throw hostConfigError("AAS_ADAPTER_SERVER_INVALID", "invalidInput", { field: "env" });
|
||||
}
|
||||
const entries = Object.entries(env);
|
||||
if (entries.length > 64) throw hostConfigError("AAS_ADAPTER_SERVER_INVALID", "invalidInput", { field: "env" });
|
||||
return Object.fromEntries(entries.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, value]) => [
|
||||
assertString(key, "env.key", 256),
|
||||
typeof value === "string" && value.length <= 8192 && !value.includes("\0")
|
||||
? value
|
||||
: (() => { throw hostConfigError("AAS_ADAPTER_SERVER_INVALID", "invalidInput", { field: `env.${key}` }); })(),
|
||||
]));
|
||||
}
|
||||
|
||||
function normalizeServer(host, server) {
|
||||
if (!server || typeof server !== "object" || Array.isArray(server)) {
|
||||
throw hostConfigError("AAS_ADAPTER_SERVER_INVALID", "invalidInput");
|
||||
}
|
||||
if (host === "codex") {
|
||||
if (server.enabled !== undefined && typeof server.enabled !== "boolean") {
|
||||
throw hostConfigError("AAS_ADAPTER_SERVER_INVALID", "invalidInput", { field: "enabled" });
|
||||
}
|
||||
return { command: assertString(server.command, "command"), args: normalizeArgs(server.args), enabled: server.enabled ?? true };
|
||||
}
|
||||
if (host === "claude") {
|
||||
return { command: assertString(server.command, "command"), args: normalizeArgs(server.args), env: normalizeEnv(server.env ?? {}) };
|
||||
}
|
||||
throw hostConfigError("AAS_ADAPTER_HOST_UNSUPPORTED", "invalidInput", { host });
|
||||
}
|
||||
|
||||
module.exports = { normalizeServer };
|
||||
Reference in New Issue
Block a user