Skip to content
Merged
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
19 changes: 15 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Local services for running a bench outside its test suites.
#
# Usage:
# docker compose up -d
services:
gotenberg:
image: gotenberg/gotenberg:8
ports:
- "3010:3000"
176 changes: 176 additions & 0 deletions tools/gotenberg/LICENSE

Large diffs are not rendered by default.

58 changes: 58 additions & 0 deletions tools/gotenberg/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# @corbits/gotenberg

Renders a Markdown document to PDF through an operator-configured
[Gotenberg](https://gotenberg.dev) server: a thin HTTP client and one
`@intx/agent` tool bundle.

## Tools

- `gotenberg_render_pdf` — renders a Markdown document (`title`,
`markdown`) to PDF and returns it as base64-encoded bytes plus a
filename and MIME type.

## Credential

The bundle's env carries `credentials`, the harness's consumer-gated
`CredentialCapability`. When it is absent, or
`credentials.resolve("gotenberg")` throws — no bound handle, or a grant
that doesn't authorize this consumer — the tool never throws itself: it
returns a completed result with `isError: true` and content naming the
source "not connected".

This package declares one credential handle, `gotenberg`, in its
`package.json`'s `interchange.credentials` field. The bound credential's
origin is the operator's Gotenberg server address — connect it once in
Settings · Connections, and every workflow that pins `@corbits/gotenberg`
and binds this handle resolves the same bench-owned credential.

## Running Gotenberg locally

```sh
docker compose up -d gotenberg
```

or directly:

```sh
docker run --rm -p 3010:3000 gotenberg/gotenberg:8
```

Gotenberg is stateless: no migrations, no shared state, scales
horizontally.

## Usage

```ts
import { gotenbergTools } from "@corbits/gotenberg";

const agent = defineAgent({
// ...
tools: [gotenbergTools],
});
```

## Running tests

```
cd tools/gotenberg && bun test
```
43 changes: 43 additions & 0 deletions tools/gotenberg/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "@corbits/gotenberg",
"version": "0.0.1",
"description": "Renders Markdown to PDF through an operator-configured Gotenberg server: a minimal client and an @intx/agent tool bundle exposing gotenberg_render_pdf",
"license": "LGPL-2.1-or-later",
"repository": {
"type": "git",
"url": "https://github.com/corbitsdev/workbench.git",
"directory": "tools/gotenberg"
},
"files": [
"src",
"LICENSE",
"README.md"
],
"type": "module",
"exports": {
".": "./src/index.ts"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "bun test"
},
"devDependencies": {
"@types/bun": "catalog:",
"arktype": "catalog:",
"typescript": "catalog:"
},
"peerDependencies": {
"@intx/agent": "0.3.0",
"@intx/types": "0.3.0"
},
"interchange": {
"credentials": [
{
"handle": "gotenberg"
}
]
}
}
43 changes: 43 additions & 0 deletions tools/gotenberg/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test";
import { GotenbergRenderError, renderMarkdownToPdf } from "./client";

describe("renderMarkdownToPdf", () => {
test("posts the markdown and index template as a relative request", async () => {
const pdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
let capturedUrl: string | undefined;
let capturedForm: FormData | undefined;
const fetchStub = async (url: string | URL | Request, init?: RequestInit) => {
capturedUrl = String(url);
capturedForm = init?.body as FormData;
return new Response(pdfBytes, { status: 200 });
};

const result = await renderMarkdownToPdf(
{ title: "Acme Diligence Brief", markdown: "# Hello" },
fetchStub,
);

expect(result).toEqual(pdfBytes);
expect(capturedUrl).toBe("/forms/chromium/convert/markdown");
const files = capturedForm?.getAll("files") ?? [];
expect(files).toHaveLength(2);
});

test("wraps a network failure in GotenbergRenderError", async () => {
const fetchStub = async () => {
throw new Error("ECONNREFUSED");
};

await expect(
renderMarkdownToPdf({ title: "t", markdown: "m" }, fetchStub),
).rejects.toBeInstanceOf(GotenbergRenderError);
});

test("wraps a non-2xx response in GotenbergRenderError", async () => {
const fetchStub = async () => new Response("boom", { status: 500 });

await expect(
renderMarkdownToPdf({ title: "t", markdown: "m" }, fetchStub),
).rejects.toBeInstanceOf(GotenbergRenderError);
});
});
64 changes: 64 additions & 0 deletions tools/gotenberg/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Talks to one Gotenberg server (https://gotenberg.dev) over its Chromium
// module's Markdown route: POST an `index.html` that inlines a Markdown
// file via Gotenberg's `toHTML` template helper, get a rendered PDF back.
// Gotenberg is stateless and holds no workbench data of its own — every
// call is a one-shot conversion, nothing to provision or migrate. The
// caller's `fetchImpl` is expected to already be pinned to the operator's
// Gotenberg origin (the mediated credential's `fetch`), so this module
// only ever requests a relative path.
export type GotenbergFetch = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;

const MARKDOWN_ROUTE = "/forms/chromium/convert/markdown";

function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

function indexTemplateFor(title: string): string {
return (
`<!doctype html><html><head><meta charset="utf-8">` +
`<title>${escapeHtml(title)}</title></head>` +
`<body>{{ toHTML "body.md" }}</body></html>`
);
}

export class GotenbergRenderError extends Error {}

/**
* Converts one Markdown document to PDF bytes via a Gotenberg server.
* Throws `GotenbergRenderError` on any transport or non-2xx failure.
*/
export async function renderMarkdownToPdf(
input: { readonly title: string; readonly markdown: string },
fetchImpl: GotenbergFetch,
): Promise<Uint8Array> {
const form = new FormData();
form.append(
"files",
new Blob([indexTemplateFor(input.title)], { type: "text/html" }),
"index.html",
);
form.append("files", new Blob([input.markdown], { type: "text/markdown" }), "body.md");

let response: Response;
try {
response = await fetchImpl(MARKDOWN_ROUTE, { method: "POST", body: form });
} catch (err) {
throw new GotenbergRenderError(
`Could not reach Gotenberg: ${err instanceof Error ? err.message : String(err)}`,
);
}
if (!response.ok) {
throw new GotenbergRenderError(
`Gotenberg rejected the render: ${response.status} ${response.statusText}`,
);
}
return new Uint8Array(await response.arrayBuffer());
}
4 changes: 4 additions & 0 deletions tools/gotenberg/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { renderMarkdownToPdf, GotenbergRenderError } from "./client";
export type { GotenbergFetch } from "./client";
export { GOTENBERG_RENDER_PDF_TOOL, gotenbergTools } from "./tool";
export type { GotenbergEnv } from "./tool";
22 changes: 22 additions & 0 deletions tools/gotenberg/src/manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Proves this package's own `package.json` satisfies the vendored
// `PackageJSON` manifest schema (`@intx/types/src/package-json.ts`) with
// an `interchange.credentials` declaration the deploy-time
// `ToolPackageManifest` harvests into `topLevel[].credentials`.
import { expect, test } from "bun:test";
import { type } from "arktype";
import { PackageJSON } from "@intx/types/package-json";

import packageJson from "../package.json";

test("package.json parses against the vendored PackageJSON manifest schema", () => {
const parsed = PackageJSON(packageJson);
expect(parsed instanceof type.errors).toBe(false);
});

test("declares exactly one credential handle: gotenberg", () => {
const parsed = PackageJSON(packageJson);
if (parsed instanceof type.errors) {
throw new Error(parsed.summary);
}
expect(parsed.interchange?.credentials).toEqual([{ handle: "gotenberg" }]);
});
102 changes: 102 additions & 0 deletions tools/gotenberg/src/tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { expect, test } from "bun:test";
import type { ToolCall } from "@intx/types/runtime";
import type { CredentialCapability, MediatedCredential } from "@intx/types";

import { GOTENBERG_RENDER_PDF_TOOL, gotenbergTools } from "./tool";
import type { GotenbergEnv } from "./tool";

const CALL: ToolCall = {
id: "call_1",
name: GOTENBERG_RENDER_PDF_TOOL,
arguments: { title: "Acme Brief", markdown: "# Hello" },
};

/**
* A fake `credentials` capability mirroring the platform's own
* `createCredentialCapability`/`createHttpCredentialProvider` shape: a
* bound handle resolves to a mediated `fetch` pinned to the operator's
* Gotenberg origin; an unbound handle throws.
*/
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;

function fakeCredentials(fetchImpl: FetchLike | undefined): CredentialCapability {
return {
resolve(handle: string): Promise<MediatedCredential> {
if (fetchImpl === undefined) {
return Promise.reject(new Error(`no credential is bound to handle "${handle}"`));
}
return Promise.resolve({
kind: "http",
fetch: fetchImpl,
dispose: () => {},
});
},
};
}

function fakeEnv(credentials: CredentialCapability | undefined): GotenbergEnv {
return { credentials } as unknown as GotenbergEnv;
}

test("declares the gotenberg_render_pdf tool", () => {
const bundle = gotenbergTools(fakeEnv(fakeCredentials(async () => new Response())));
expect(bundle.definitions.map((d) => d.name)).toEqual([GOTENBERG_RENDER_PDF_TOOL]);
});

test("degrades to a non-throwing 'not connected' error when no credential is bound", async () => {
const bundle = gotenbergTools(fakeEnv(fakeCredentials(undefined)));
const result = await bundle.run(CALL, new AbortController().signal);
expect(result.isError).toBe(true);
expect(result.content).toMatch(/not connected/i);
});

test("degrades the same way when the step carries no credentials capability at all", async () => {
const bundle = gotenbergTools(fakeEnv(undefined));
const result = await bundle.run(CALL, new AbortController().signal);
expect(result.isError).toBe(true);
expect(result.content).toMatch(/not connected/i);
});

test("rejects a missing markdown argument without calling the network", async () => {
let called = false;
const bundle = gotenbergTools(
fakeEnv(
fakeCredentials(async () => {
called = true;
return new Response();
}),
),
);
const result = await bundle.run(
{ id: "call_2", name: GOTENBERG_RENDER_PDF_TOOL, arguments: { title: "t" } },
new AbortController().signal,
);
expect(called).toBe(false);
expect(result.isError).toBe(true);
expect(result.content).toContain("markdown");
});

test("returns the rendered PDF as base64 content on a successful call", async () => {
const pdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
const bundle = gotenbergTools(
fakeEnv(fakeCredentials(async () => new Response(pdfBytes, { status: 200 }))),
);
const result = await bundle.run(CALL, new AbortController().signal);
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content as string) as {
filename: string;
mimeType: string;
pdfBase64: string;
};
expect(parsed.filename).toBe("Acme Brief.pdf");
expect(parsed.mimeType).toBe("application/pdf");
expect(Buffer.from(parsed.pdfBase64, "base64")).toEqual(Buffer.from(pdfBytes));
});

test("degrades to an error result (never throws) when the underlying call fails", async () => {
const bundle = gotenbergTools(
fakeEnv(fakeCredentials(async () => new Response("nope", { status: 500 }))),
);
const result = await bundle.run(CALL, new AbortController().signal);
expect(result.isError).toBe(true);
});
Loading
Loading