fix(api): store LF line breaks for multipart image notes; make ACP host tests portable (#568)

* fix(api): store LF line breaks for multipart image notes

multipart/form-data encodes line breaks in text fields as CRLF. Browsers
always do so, and Bun 1.4 now does as well, so notes created through
POST /api/v1/memos/with-image (used by the Web Clipper) were stored with
CRLF line breaks, while every other note uses LF. The route now normalizes
the Markdown field to LF before creating the note.

This also fixes the existing route contract test under Bun 1.4, and a new
test sends a CRLF multipart body like a browser does.

* test(desktop): make ACP host tests independent of the machine and Bun version

- The ACP command allow-list test checked that a missing command is not
  installed, but on macOS the resolver also looks in /opt/homebrew/bin and
  /usr/local/bin, so it failed on any machine with that command installed.
  The test now only sees its temporary directory.
- The fake WorkBuddy agent started its account API without a port during
  probes and chats. Bun 1.3 then listened on a random port, while Bun 1.4,
  like Node, rejects port NaN, so six WorkBuddy session tests hung until
  their timeout. Like WorkBuddy itself, the fake agent now serves the
  account API only when started with a port.
This commit is contained in:
Dariusz Koryto authored and GitHub committed 2026-10-15 09:22:42 +08:00
1 parent 9bb3218c03
commit bbe80d18db
3 files changed
+44 -3

No files matched your search

+28
View File
@@ -158,6 +158,34 @@ describe("memo route contracts", () => {
expect(await response.json()).toMatchObject({ memo: { id: "memo_created" } });
});
test("stores the line breaks of a multipart image note as LF", async () => {
let contentMarkdown;
const dependencies = createDependencies({
createMemo: async (_db, _workspaceId, input) => {
contentMarkdown = input.contentMarkdown;
return { id: "memo_created" };
},
});
// Browsers encode line breaks in multipart text fields as CRLF.
const boundary = "edgeever-boundary";
const part = (name, value) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
const body = [
part("notebookId", "nb_1"),
part("contentMarkdown", "![clip](/api/v1/resources/EDGEVERRESOURCEID/blob)\r\n\r\nSource: https://example.com\r\nLine"),
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="clip.png"\r\nContent-Type: image/png\r\n\r\n\x89PNG\r\n`,
`--${boundary}--\r\n`,
].join("");
const response = await createApp(dependencies).request("/api/v1/memos/with-image", {
method: "POST",
headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
body,
}, environment);
expect(response.status).toBe(201);
const { resourceId } = await response.json();
expect(contentMarkdown).toBe(`![clip](/api/v1/resources/${resourceId}/blob)\n\nSource: https://example.com\nLine`);
});
test("enforces read scope before loading a memo", async () => {
let called = false;
const dependencies = createDependencies({
+5 -1
View File
@@ -172,7 +172,11 @@ export const registerMemoRoutes = (
const file = form.get("file");
const notebookId = typeof form.get("notebookId") === "string" ? String(form.get("notebookId")).trim() : "";
const title = typeof form.get("title") === "string" ? String(form.get("title")) : "";
const contentMarkdown = typeof form.get("contentMarkdown") === "string" ? String(form.get("contentMarkdown")) : "";
// multipart/form-data encodes line breaks in text fields as CRLF (browsers
// always, and newer Bun versions too), while notes store LF line breaks.
const contentMarkdown = typeof form.get("contentMarkdown") === "string"
? String(form.get("contentMarkdown")).replace(/\r\n?/g, "\n")
: "";
let tags: string[] = [];
const tagsValue = form.get("tags");
if (typeof tagsValue === "string" && tagsValue.trim()) {
+11 -2
View File
@@ -104,7 +104,10 @@ if (${workBuddyAccountApi ? "true" : "false"}) {
if (${workBuddyUnreadable ? "true" : "false"}) authenticated = true;
const restoredAccount = authenticated;
const save = () => writeFileSync(reportPath, JSON.stringify(report));
if (${workBuddyAccountApi ? "true" : "false"}) {
// Like WorkBuddy, serve the account API only when started with --serve, which
// sets SERVER__PORT. Probes and chats start without it, and listening on port
// NaN is an error in Node and newer Bun versions.
if (${workBuddyAccountApi ? "true" : "false"} && process.env.SERVER__PORT) {
const { createServer } = await import("node:http");
const server = createServer(async (req, res) => {
if (req.headers.authorization !== "Bearer " + process.env.CODEBUDDY_GATEWAY_PASSWORD) {
@@ -348,7 +351,13 @@ describe("ACP command allow-list", () => {
for (const name of ["claude-agent-acp", "openclaw", "hermes"]) {
await writeFile(path.join(directory, name), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
}
const deps = { platform: "darwin", pathEnv: directory, home: directory };
// Only the temporary directory exists, so commands installed on the
// machine running the tests (for example in /opt/homebrew/bin) are ignored.
const onlyTemporaryDirectory = (candidate) => {
if (!candidate.startsWith(directory)) throw Object.assign(new Error(`ENOENT: ${candidate}`), { code: "ENOENT" });
return realpathSync(candidate);
};
const deps = { platform: "darwin", pathEnv: directory, home: directory, realpathSync: onlyTemporaryDirectory };
expect(resolveAcpCommand({ id: "claudeCode" }, deps).command).toMatchObject({ command: realpathSync(path.join(directory, "claude-agent-acp")), args: [] });
expect(resolveAcpCommand({ id: "openClaw" }, deps).command).toMatchObject({ command: realpathSync(path.join(directory, "openclaw")), args: ["acp"] });
expect(resolveAcpCommand({ id: "hermesAgent" }, deps).command).toMatchObject({ command: realpathSync(path.join(directory, "hermes")), args: ["acp"] });