Compare commits

..
Author SHA1 Message Date
jschoubben 8f459c7023 letta: placed state, a route, accepted keys, and a runtime that can log in
The module named /var/lib/letta, a layout no definition may carry (ADR
0112); state is now a placed directory holding the bindings and secrets.

letta had no route, while the server it replaces is reached by its public
name (a workflow calls it there). It now contributes one for its `web`
endpoint; reach is the assignment's.

The server needs an OpenAI key for agents on OpenAI models, and nothing
gave it one: `openai-api-key` is an own-secret, accepted from the
operator (a key someone chose, not one the mesh can mint). The
server-password is accepted the same way where a server already has
clients. Both, and the database password inside LETTA_PG_URI, stay in the
server's environment: letta 0.6.x reads settings from the environment
only, and its startup.sh starts an embedded PostgreSQL unless
LETTA_PG_URI is set - the declared reason now says so.

The runtime's tools never authenticated: the client sent only a Bearer
token, and 0.6.x's --secure mode checks X-BARE-PASSWORD ("password <it>")
and answers 401 otherwise. The client now sends both. Its password comes
from the runtime config file (the key client.ts reads first) instead of an
env-file, so the runtime container no longer carries a secret in its
environment.

Image: the same 0.6.8 image, now pinned by the index digest ace runs
rather than its amd64 manifest.

Verified: catalogue tests with MESH_CATALOGUE set; tsc -p tsconfig.json in
the mesh-tools build image. Throwaway containers: a fresh 0.6.8 with its
embedded PG and two blocks made through the API; stopped, copied, dumped
from the copy; restored (schema letta + vector pre-made by the superuser,
--no-owner --role, search_path set on the database as the original had
it) into a grant-shaped database on the postgres module's pgvector image
(PG17); started in this shape: alembic finds nothing to do, both blocks
are there, a wrong password is refused with 401, and the patched client
lists agents. Test containers and data removed.
2026-09-30 12:22:12 +02:00
5 changed files with 55 additions and 110 deletions
+16 -53
View File
@@ -2,8 +2,7 @@
// an indexer proxy: it normalises many torrent trackers behind one Torznab surface. This client
// talks its /api/v2.0 REST API, and only jackett's tools import it.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { readFileSync } from "node:fs";
export interface JackettIndexer {
id: string;
@@ -44,36 +43,18 @@ export class JackettClient {
/**
* Build from the module's resolved environment. Jackett's REST API is keyed, so both the URL and
* the key must be present. The key is read from the settings-merged config or MESH_JACKETT_API_KEY,
* or, failing those, discovered from Jackett's own ServerConfig.json under MESH_JACKETT_CONFIG_DIR
* — the file Jackett writes it to, as sonarr/radarr read theirs from config.xml — so a running
* server needs no key configured by hand and no secret has to be put in an assignment. Without a
* URL or key there is nothing to talk to, so this throws and the module contributes no tools
* rather than failing half-configured.
* the key must be present — without them there is nothing to talk to, so this throws and the
* module contributes no tools rather than failing half-configured.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient {
const cfg = meshConfig(env.MESH_JACKETT_CONFIG_FILE);
const url = cfg.url ?? env.MESH_JACKETT_URL;
const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY
?? JackettClient.detectApiKey(env.MESH_JACKETT_CONFIG_DIR ?? "/config");
const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY;
if (!url) throw new Error("no Jackett URL — set MESH_JACKETT_URL");
if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY or make the config dir readable");
if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY");
return new JackettClient(url, apiKey);
}
/** Discover the API key from Jackett's ServerConfig.json (the linuxserver image keeps it at
* <config>/Jackett/ServerConfig.json), falling back to null. */
static detectApiKey(configDir: string): string | null {
for (const file of [join(configDir, "Jackett", "ServerConfig.json"), join(configDir, "ServerConfig.json")]) {
if (!existsSync(file)) continue;
try {
const key = (JSON.parse(readFileSync(file, "utf8")) as { APIKey?: unknown }).APIKey;
if (typeof key === "string" && key) return key;
} catch { /* unreadable or mid-write: try the next, then give up */ }
}
return null;
}
private async get(path: string, params: Record<string, string> = {}): Promise<any> {
const url = new URL(`${this.baseUrl}${path}`);
url.searchParams.set("apikey", this.apiKey);
@@ -83,36 +64,18 @@ export class JackettClient {
return res.json();
}
/**
* The configured indexers Jackett proxies. `configured=false` also lists the ones not set up.
* Read from the Torznab `t=indexers` feed, not /api/v2.0/indexers: that one is the web UI's and
* wants a login cookie (it answers an API-key request with a redirect), while the Torznab feed is
* what the key is for. The feed carries no last error, so `lastError` stays unset.
*/
/** The configured indexers Jackett proxies. `configured=false` also lists the ones not set up. */
async getIndexers(configuredOnly = true): Promise<JackettIndexer[]> {
const url = new URL(`${this.baseUrl}/api/v2.0/indexers/all/results/torznab/api`);
url.searchParams.set("apikey", this.apiKey);
url.searchParams.set("t", "indexers");
url.searchParams.set("configured", configuredOnly ? "true" : "false");
const res = await fetch(url.toString(), { headers: { Accept: "application/xml" } });
if (!res.ok) throw new Error(`Jackett API torznab t=indexers: ${res.status} ${await res.text()}`);
const xml = await res.text();
// Torznab reports failures (a wrong key among them) as 200 with an <error> body.
const err = xml.match(/<error code="(\d+)" description="([^"]*)"/);
if (err) throw new Error(`Jackett API torznab t=indexers: error ${err[1]} ${err[2]}`);
const text = (block: string, tag: string) =>
block.match(new RegExp(`<${tag}>([^<]*)</${tag}>`))?.[1];
const out: JackettIndexer[] = [];
for (const m of xml.matchAll(/<indexer id="([^"]+)" configured="([^"]+)">([\s\S]*?)<\/indexer>/g)) {
out.push({
id: m[1],
name: text(m[3], "title") ?? m[1],
type: text(m[3], "type") ?? "unknown",
configured: m[2] === "true",
siteLink: text(m[3], "link"),
});
}
return out;
const raw = await this.get("/api/v2.0/indexers", { configured: configuredOnly ? "true" : "false" });
const list = Array.isArray(raw) ? raw : [];
return list.map((i: any) => ({
id: i.id,
name: i.name,
type: i.type,
configured: i.configured ?? false,
siteLink: i.site_link,
lastError: i.last_error || undefined,
}));
}
/**
+9 -27
View File
@@ -1,19 +1,6 @@
{
"module": "jackett",
"version": "1",
"provides": [
{
"name": "jackett-api",
"scope": "mesh"
}
],
"serves": {
"jackett-api": {
"scheme": "http",
"port": 9117,
"url-base": ""
}
},
"capabilities": [
"container-runtime"
],
@@ -23,7 +10,7 @@
"port": 9117,
"protocol": "tcp",
"from": "mesh",
"why": "the indexer proxy: its web UI, and the Torznab feeds the *arr apps search through, which other modules reach as jackett-api"
"why": "the indexer proxy"
}
],
"resources": [
@@ -33,15 +20,10 @@
"path": "/var/lib/mesh/jackett",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "config",
"type": "directory",
"path": "/services/jackett/config",
"mode": "0700",
"owner": "1000:1000"
},
@@ -49,7 +31,7 @@
"id": "server",
"type": "container",
"name": "jackett",
"image": "lscr.io/linuxserver/jackett@sha256:7b19f4f6ac33d855ca9226600ecbd096ee678f66da28b13a7c09980b035ff583",
"image": "lscr.io/linuxserver/jackett@sha256:fd72d42b731ebf750b5de9711127251cf3b3f609419c32083ea8b3b3ee840b77",
"env": {
"PUID": "1000",
"PGID": "1000",
@@ -59,13 +41,13 @@
"9117"
],
"volumes": [
"${dir:config}:/config"
"/services/jackett/config:/config"
]
},
{
"id": "runtime-config",
"type": "file",
"path": "${dir:state}/config.json",
"path": "/var/lib/mesh/jackett/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
@@ -77,12 +59,12 @@
"network": "host",
"volumes": [
"/var/lib/mesh/jackett/broker:/run/secrets/broker:ro",
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:config}:/var/lib/jackett/config:ro"
"/var/lib/mesh/jackett/config.json:/run/config/config.json:ro",
"/services/jackett/config:/var/lib/jackett/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_JACKETT_URL": "http://127.0.0.1:${port:9117}",
"MESH_JACKETT_URL": "http://127.0.0.1:9117",
"MESH_JACKETT_CONFIG_FILE": "/run/config/config.json",
"MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config"
},
@@ -105,7 +87,7 @@
}
},
"binds": {
"route": "${dir:state}/route.json"
"route": "/var/lib/mesh/jackett/route.json"
},
"build": {
"on": [
+1 -1
View File
@@ -9,7 +9,7 @@ export function getJackettTools(jackett: JackettClient): ToolDefinition[] {
return [
{
name: "jackett_indexers",
description: "List the indexers Jackett proxies, with their type and site.",
description: "List the indexers Jackett proxies, with their type and any last error.",
input: { all: { type: "boolean", description: "include indexers not yet configured (default false)" } },
run: async (args) => {
const indexers = await jackett.getIndexers(!args.all);
+8 -4
View File
@@ -1,10 +1,10 @@
// The Letta API client — letta's own code, living in the module (novox/hq ADR 0039). Its tools
// import it; nothing outside letta does.
//
// Letta authenticates with a single server password, presented as a Bearer token. That password is
// a mesh own-secret, minted once and handed to both the server (LETTA_SERVER_PASSWORD) and this
// client (MESH_LETTA_PASSWORD) — so the module's tools are live without anything configured by hand.
// The runtime config file may still override the URL or password.
// Letta authenticates with a single server password. That password is a mesh own-secret handed to
// both the server (LETTA_SERVER_PASSWORD) and this client, through the runtime config file the mesh
// mounts (its `password` key) — so the module's tools are live without anything configured by hand.
// Where a server already has clients, the password is accepted rather than minted.
import { readFileSync } from "node:fs";
@@ -58,6 +58,10 @@ export class LettaClient {
...options,
headers: {
"Content-Type": "application/json",
// The server's --secure mode checks X-BARE-PASSWORD ("password <it>") and answers a Bearer
// token alone with 401 (letta/server/rest_api/app.py, 0.6.x). Both are sent: Bearer is what
// later servers read.
"X-BARE-PASSWORD": `password ${this.password}`,
Authorization: `Bearer ${this.password}`,
...(options.headers as Record<string, string> | undefined),
},
+21 -25
View File
@@ -5,21 +5,28 @@
"container-runtime"
],
"requires": [
"postgres-database"
"postgres-database",
"route"
],
"contributes": {
"postgres-database": {
"name": "letta"
},
"route": {
"label": "letta",
"endpoint": "web"
}
},
"binds": {
"postgres-database": "/var/lib/letta/database.json"
"postgres-database": "${dir:state}/database.json",
"route": "${dir:state}/route.json"
},
"secrets": {
"postgres-database": "/var/lib/letta/database.secret"
"postgres-database": "${dir:state}/database.secret"
},
"own-secrets": {
"server-password": "/var/lib/letta/server-password.secret",
"server-password": "${dir:state}/server-password.secret",
"openai-api-key": "${dir:state}/openai-api-key.secret",
"broker": "/var/lib/mesh/letta/broker"
},
"listens": [
@@ -28,7 +35,7 @@
"port": 8283,
"protocol": "tcp",
"from": "mesh",
"why": "the Letta agent server REST API and web UI; a public name is a route grant later"
"why": "the Letta agent server REST API and web UI, password-protected (--secure); a public name is the route's"
}
],
"resources": [
@@ -41,15 +48,15 @@
{
"id": "state",
"type": "directory",
"path": "/var/lib/letta",
"mode": "0700"
"mode": "0700",
"place": "."
},
{
"id": "server-env",
"type": "file",
"path": "/var/lib/letta/server.env",
"path": "${dir:state}/server.env",
"mode": "0600",
"content": "LETTA_PG_URI=postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nLETTA_SERVER_PASSWORD=${secret:server-password}\nSECURE=true\nTZ=Europe/Brussels\n"
"content": "LETTA_PG_URI=postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nLETTA_SERVER_PASSWORD=${secret:server-password}\nOPENAI_API_KEY=${secret:openai-api-key}\nSECURE=true\nTZ=Europe/Brussels\n"
},
{
"id": "net",
@@ -60,31 +67,24 @@
"id": "server",
"type": "container",
"name": "letta",
"image": "letta/letta@sha256:1d2e0692514287c5ed1a483e14e16ed945f8632d315539f5e66373bb7d7c471b",
"image": "letta/letta@sha256:bfd1e49ce45b9a208c941e832c1d1d194017ff210a3784b0ca6c323aed767a29",
"network": "letta",
"env-file": [
"/var/lib/letta/server.env"
"${dir:state}/server.env"
],
"ports": [
"8283"
],
"secrets-in-environment": "the letta image is env-driven and its file-source support could not be verified; the mesh runtime can take its password from config.json (client.ts) \u2014 not yet converted"
"secrets-in-environment": "letta 0.6.x reads its settings from the environment only (pydantic settings, no secrets_dir or _FILE twin), and its startup.sh starts an embedded PostgreSQL unless LETTA_PG_URI is set - so the database password travels inside that URI (startup.sh also echoes it to the log); LETTA_SERVER_PASSWORD and OPENAI_API_KEY have no file source either"
},
{
"id": "runtime-config",
"type": "file",
"path": "/var/lib/mesh/letta/config.json",
"mode": "0600",
"content": "{}\n",
"content": "{\n \"password\": \"${secret:server-password}\"\n}\n",
"merge": "json"
},
{
"id": "runtime-env",
"type": "file",
"path": "/var/lib/letta/runtime.env",
"mode": "0600",
"content": "MESH_LETTA_PASSWORD=${secret:server-password}\n"
},
{
"id": "runtime",
"type": "container",
@@ -99,14 +99,10 @@
"MESH_LETTA_URL": "http://letta:8283",
"MESH_LETTA_CONFIG_FILE": "/run/config/config.json"
},
"env-file": [
"/var/lib/letta/runtime.env"
],
"restart-on": [
"runtime-config"
],
"artifact": "runtime",
"secrets-in-environment": "the letta image is env-driven and its file-source support could not be verified; the mesh runtime can take its password from config.json (client.ts) \u2014 not yet converted"
"artifact": "runtime"
}
],
"build": {