fix(setup): keep Hermes config numbers as written (#2368)

Setup and uninstall re-printed every number in an existing Hermes
config.yaml from a JavaScript number. Hermes reads the file as YAML 1.1
with exact integers, so a Discord channel ID lost its low digits, an
octal mode such as 0600 became 600, and 0.0000001 became 1e-7, which
YAML 1.1 does not define as a float. Two IDs that round to the same
double were also rejected as duplicate keys, so setup could not
register at all.

Hermes YAML parsing and editing move to HermesConfigurationYaml. A parsed
number is written with its source spelling while that spelling still
reads as the node's value, the rule yaml's own boolean tag follows, and
two keys are duplicates only when both their value and their spelling
match. Parsed values stay JavaScript numbers.
This commit is contained in:
Chaoqi 2026-10-13 08:44:17 -04:00 committed by GitHub
parent 36132d3e17
commit d3a55720fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 141 additions and 12 deletions

View File

@ -1,9 +1,5 @@
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import {
parse as parseYaml,
parseDocument as parseYamlDocument,
stringify as stringifyYaml,
} from "yaml";
import { stringify as stringifyYaml } from "yaml";
import { isDeepStrictEqual } from "node:util";
import {
@ -12,6 +8,10 @@ import {
editDshDocument,
dshClientRegistrationEntry,
} from "./DshCordisPatch.js";
import {
editHermesConfigurationYaml,
parseHermesConfigurationYaml,
} from "./HermesConfigurationYaml.js";
import {
applyEdits,
findNodeAtLocation,
@ -118,7 +118,10 @@ const parseDocument = (
if (isEmptyClientConfigurationText(text)) return {};
// Hermes stores MCP servers in `mcp_servers` inside a YAML document.
if (format === "hermes")
return requireObject(parseYaml(text), "The configuration root");
return requireObject(
parseHermesConfigurationYaml(text),
"The configuration root",
);
// ZCode loads nonempty native files with strict JSON.parse.
if (format === "zcode")
return requireObject(JSON.parse(text), "The configuration root");
@ -1419,12 +1422,11 @@ const serializeHermesConfiguration = (
typeof servers === "object" && servers !== null
? (servers as Record<string, unknown>)[PRODUCT_IDENTITY.mcpServerKey]
: undefined;
const yamlDocument = parseYamlDocument(originalText);
if (entry === undefined)
yamlDocument.deleteIn([serversKey, PRODUCT_IDENTITY.mcpServerKey]);
else yamlDocument.setIn([serversKey, PRODUCT_IDENTITY.mcpServerKey], entry);
// Editing REA must not reflow unrelated user prompts or other YAML scalars.
return yamlDocument.toString({ lineWidth: 0 });
return editHermesConfigurationYaml(
originalText,
[serversKey, PRODUCT_IDENTITY.mcpServerKey],
entry,
);
};
/** Build the stdio entry shape expected by one client's configuration dialect. */

View File

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import {
editHermesConfigurationYaml,
parseHermesConfigurationYaml,
} from "./HermesConfigurationYaml.js";
describe("Hermes configuration YAML", () => {
it("writes an edited number from its new value", () => {
expect(
editHermesConfigurationYaml("mode: 0600\nmask: 0x1F\n", ["mode"], 384),
).toBe("mode: 384\nmask: 0x1F\n");
});
it("accepts keys Hermes reads as distinct and rejects a repeated key", () => {
expect(() =>
parseHermesConfigurationYaml("0600: octal\n600: decimal\n"),
).not.toThrow();
expect(() => parseHermesConfigurationYaml('a: 1\n"a": 2\n')).toThrow();
});
});

View File

@ -0,0 +1,74 @@
import {
isScalar,
parse,
parseDocument,
type ParsedNode,
type ScalarTag,
type Tags,
} from "yaml";
const NUMBER_TAGS = new Set([
"tag:yaml.org,2002:int",
"tag:yaml.org,2002:float",
]);
/**
* Write a parsed number with its source spelling while that spelling still
* reads as the node's value. Hermes reads config.yaml as YAML 1.1 with exact
* integers, so re-printing a JavaScript number changes what it reads: a
* Discord channel ID loses its low digits, `0600` (octal) becomes `600`, and
* `0.0000001` becomes `1e-7`, which YAML 1.1 does not define as a float.
*/
const keepNumberSource = (tag: Tags[number]): Tags[number] => {
if (typeof tag === "string" || tag.collection !== undefined) return tag;
const { stringify } = tag;
if (!NUMBER_TAGS.has(tag.tag) || stringify === undefined) return tag;
return {
...tag,
stringify: (item, context, ...rest) => {
const { source } = item;
if (source === undefined) return stringify(item, context, ...rest);
const reader = context.doc.schema.tags.find(
(candidate): candidate is ScalarTag =>
candidate.default === true && candidate.test?.test(source) === true,
);
const read = reader?.resolve(
source,
() => undefined,
context.doc.options,
);
return Object.is(isScalar(read) ? read.value : read, item.value)
? source
: stringify(item, context, ...rest);
},
};
};
const HERMES_YAML_OPTIONS = {
customTags: (tags: Tags) => tags.map(keepNumberSource),
// Hermes keeps integers exact, so IDs that round to one double, or `0600`
// and `600`, are distinct keys.
uniqueKeys: (a: ParsedNode, b: ParsedNode) =>
a === b ||
(isScalar(a) &&
isScalar(b) &&
a.value === b.value &&
a.source === b.source),
};
/** Parse a Hermes `config.yaml`. */
export const parseHermesConfigurationYaml = (text: string): unknown =>
parse(text, HERMES_YAML_OPTIONS);
/** Set or delete one value in a Hermes `config.yaml`, keeping the rest as written. */
export const editHermesConfigurationYaml = (
text: string,
path: readonly string[],
value: unknown,
): string => {
const document = parseDocument(text, HERMES_YAML_OPTIONS);
if (value === undefined) document.deleteIn(path);
else document.setIn(path, value);
// Editing REA must not reflow unrelated user prompts or other YAML scalars.
return document.toString({ lineWidth: 0 });
};

View File

@ -237,6 +237,38 @@ mcp_servers:
expect(await readFile(client.configPath, "utf8")).toBe(original);
});
it("keeps unrelated numbers as written, including Discord IDs", async () => {
const { home, client } = await hermesClient();
// Hermes reads these exactly: two IDs one apart, an octal mode, a float.
const original = `discord:
free_response_channels:
- 1187654321098765432
channel_prompts:
1187654321098765433: Build questions.
1187654321098765434: Release questions.
mode: 0600
min_score: 0.0000001
mcp_servers:
other:
command: other-server
`;
await writeFile(client.configPath, original);
expect(
(await configureClientConfiguration(client, {}, command)).status,
).toBe("configured");
const text = await readFile(client.configPath, "utf8");
expect(text.startsWith(original)).toBe(true);
expect(parseYaml(text, { intAsBigInt: true })).toMatchObject({
mcp_servers: {
rea: { command: command[0], args: command.slice(1), enabled: true },
},
});
expect((await systemUninstallHost(home).removeClient(client)).status).toBe(
"removed",
);
expect(await readFile(client.configPath, "utf8")).toBe(original);
});
it("creates config.yaml when Hermes has no configuration yet", async () => {
const { client } = await hermesClient();
expect(