diff --git a/deploy/systemd/credential-compartments/README.md b/deploy/systemd/credential-compartments/README.md index 9778990..32d88c5 100644 --- a/deploy/systemd/credential-compartments/README.md +++ b/deploy/systemd/credential-compartments/README.md @@ -15,6 +15,8 @@ Use `--consumer-providers tinker,openai` for the production Pi/Tinker Telegram a The splitter writes an owner-only directory and four mode-0600 files. It refuses Git worktrees and configured public-content roots. Its receipt contains paths and variable names only. +Use `letta` for Cloud-backed declarations that need `LETTA_API_KEY`. Use `letta-local` for API-backed local Agent SDK declarations that use existing agent identity and ChatGPT OAuth without placing a Cloud API key in the consumer compartment. Combine either with `tinker` or `openai-compatible` when the same consumer process owns those declarations. + Install each template as `credentials.conf` beneath the corresponding user-unit drop-in directory, then run `systemctl --user daemon-reload`. Restart only the affected units and verify their effective `EnvironmentFiles` and loaded code paths. Do not inspect `Environment` or print file contents as verification. The Jetstream compartment intentionally carries no model or Telegram credential. It is activatable only with a producer-only Jetstream command. This slice is based on `d7abc76`, whose Jetstream command also starts consumers; producer-only support must exist in the target branch before installing this drop-in or enabling Jetstream. diff --git a/scripts/split-service-credentials.ts b/scripts/split-service-credentials.ts index 897029f..c175096 100644 --- a/scripts/split-service-credentials.ts +++ b/scripts/split-service-credentials.ts @@ -8,14 +8,14 @@ export async function main(arguments_: string[] = process.argv.slice(2)): Promis const source = valueAfter(arguments_, "--source"); const outputDirectory = valueAfter(arguments_, "--output-dir"); if (!source || !outputDirectory) { - throw new Error("Usage: split-service-credentials --source --output-dir [--consumer-providers letta,tinker,openai,openai-compatible]"); + throw new Error("Usage: split-service-credentials --source --output-dir [--consumer-providers letta,letta-local,tinker,openai,openai-compatible]"); } const providers = (valueAfter(arguments_, "--consumer-providers") ?? "letta") .split(",") .map((value) => value.trim()) .filter(Boolean) as ConsumerCredentialProvider[]; for (const provider of providers) { - if (!(["letta", "tinker", "openai", "openai-compatible"] as string[]).includes(provider)) { + if (!(["letta", "letta-local", "tinker", "openai", "openai-compatible"] as string[]).includes(provider)) { throw new Error(`Unsupported consumer credential provider: ${provider}`); } } diff --git a/src/runtime/credential-compartments.ts b/src/runtime/credential-compartments.ts index 92a28ba..a71eff6 100644 --- a/src/runtime/credential-compartments.ts +++ b/src/runtime/credential-compartments.ts @@ -6,7 +6,7 @@ import { type PrivateDestinationOptions, } from "../security/private-files.js"; -export type ConsumerCredentialProvider = "letta" | "tinker" | "openai" | "openai-compatible"; +export type ConsumerCredentialProvider = "letta" | "letta-local" | "tinker" | "openai" | "openai-compatible"; export type CredentialCompartment = "telegram-webhook" | "consumer" | "telegram-dispatcher" | "jetstream"; export interface SplitCredentialOptions extends PrivateDestinationOptions { @@ -145,9 +145,10 @@ function validateRequiredAssignments( } if (providers.includes("letta")) { if (!assignments.has("LETTA_API_KEY")) throw new Error("Required Letta consumer credential assignment is missing: LETTA_API_KEY"); - if (![...assignments.keys()].some((name) => /^THOUGHTSTREAM_LETTA_[A-Z0-9_]+_AGENT_ID$/.test(name))) { - throw new Error("A Letta consumer compartment requires at least one THOUGHTSTREAM_LETTA_*_AGENT_ID assignment"); - } + } + if ((providers.includes("letta") || providers.includes("letta-local")) + && ![...assignments.keys()].some((name) => /^THOUGHTSTREAM_LETTA_[A-Z0-9_]+_AGENT_ID$/.test(name))) { + throw new Error("A Letta consumer compartment requires at least one THOUGHTSTREAM_LETTA_*_AGENT_ID assignment"); } if (providers.includes("tinker") && !assignments.has("TINKER_API_KEY")) { throw new Error("Required Tinker consumer credential assignment is missing: TINKER_API_KEY"); @@ -171,7 +172,8 @@ function isTelegramBotToken(name: string): boolean { function isConsumerVariable(name: string, providers: ConsumerCredentialProvider[]): boolean { return consumerRuntimeVariables.has(name) - || (providers.includes("letta") && (name === "LETTA_API_KEY" || /^THOUGHTSTREAM_LETTA_[A-Z0-9_]+$/.test(name))) + || (providers.includes("letta") && name === "LETTA_API_KEY") + || ((providers.includes("letta") || providers.includes("letta-local")) && /^THOUGHTSTREAM_LETTA_[A-Z0-9_]+$/.test(name)) || (providers.includes("tinker") && (name === "TINKER_API_KEY" || /^THOUGHTSTREAM_TINKER_[A-Z0-9_]+$/.test(name))) || (providers.includes("openai") && name === "OPENAI_API_KEY") || (providers.includes("openai-compatible") && (name === "THOUGHTSTREAM_MODEL_API_KEY" || /^THOUGHTSTREAM_MODEL_[A-Z0-9_]+$/.test(name))); diff --git a/test/credential-compartments.test.ts b/test/credential-compartments.test.ts index b684b96..4bb1bda 100644 --- a/test/credential-compartments.test.ts +++ b/test/credential-compartments.test.ts @@ -175,6 +175,34 @@ describe("service credential compartments", () => { expect(JSON.stringify(receipt)).not.toContain(values.openai); }); + test("routes local Agent SDK identity without requiring or copying a Cloud API key", async () => { + const root = await temporaryProject("thoughtstream-credential-local-letta-"); + roots.push(root); + const source = path.join(root, "source.env"); + await fs.writeFile(source, [ + `THOUGHTSTREAM_LETTA_CO_AGENT_ID=${generatedValue("co-agent")}`, + "THOUGHTSTREAM_LETTA_CO_MEMORY_DIR=/private/co-memory", + "THOUGHTSTREAM_ENABLE_COIL_PUBLIC_KNOWLEDGE=1", + "THOUGHTSTREAM_PUBLIC_KNOWLEDGE_POLICY_PATH=/private/policy.json", + "THOUGHTSTREAM_PUBLIC_KNOWLEDGE_CATALOG_ROOT=/private/catalog", + `THOUGHTSTREAM_TELEGRAM_BOT_TOKEN=${generatedValue("bot")}`, + `THOUGHTSTREAM_TELEGRAM_WEBHOOK_SECRET=${generatedValue("webhook")}`, + "", + ].join("\n"), { mode: 0o600 }); + const receipt = await splitServiceCredentialFile(source, path.join(root, "credentials"), { + consumerProviders: ["letta-local"], + publicContentRoots: [], + }); + expect(receipt.files.consumer.variableNames).toEqual([ + "THOUGHTSTREAM_ENABLE_COIL_PUBLIC_KNOWLEDGE", + "THOUGHTSTREAM_LETTA_CO_AGENT_ID", + "THOUGHTSTREAM_LETTA_CO_MEMORY_DIR", + "THOUGHTSTREAM_PUBLIC_KNOWLEDGE_CATALOG_ROOT", + "THOUGHTSTREAM_PUBLIC_KNOWLEDGE_POLICY_PATH", + ]); + expect(await fs.readFile(receipt.files.consumer.path, "utf8")).not.toContain("LETTA_API_KEY"); + }); + test("refuses Git and configured public-content destinations before creating credential files", async () => { const root = await temporaryProject("thoughtstream-credential-path-"); roots.push(root);