From f1b606f414ee1467c2d9efd895a83cdcab7e23a4 Mon Sep 17 00:00:00 2001 From: zocomputer Date: Wed, 23 Sep 2026 21:26:04 +0000 Subject: [PATCH] feat: read the bridge's bot token and bridge secret from the host secrets file A Zo process service starts from a bare environment: it inherits neither the host shell nor the deployment's Vercel variables, and the token and shared secret are stored there as sensitive values that cannot be read back. Load them from /root/.zo_secrets instead, so the service definition carries only the non-secret routing configuration. The environment still wins over the file. --- README.md | 11 ++++- bridge/discord-gateway/index.ts | 13 +++++ lib/host-secrets.test.ts | 86 +++++++++++++++++++++++++++++++++ lib/host-secrets.ts | 82 +++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 lib/host-secrets.test.ts create mode 100644 lib/host-secrets.ts diff --git a/README.md b/README.md index 56e70ec..c918fda 100644 --- a/README.md +++ b/README.md @@ -131,11 +131,20 @@ label: computer-discord-bridge mode: process workdir: /home/workspace/users/etok/workspaces/wazootech/repos/computer entrypoint: node --experimental-strip-types bridge/discord-gateway/index.ts -env: DISCORD_BOT_TOKEN, DISCORD_BRIDGE_SECRET, COMPUTER_BASE_URL, +env: COMPUTER_BASE_URL, DISCORD_INTERNAL_GUILD_IDS, DISCORD_INTERNAL_CHANNEL_IDS, DISCORD_INTERNAL_USER_IDS, DISCORD_INTERNAL_ROLE_IDS ``` +`DISCORD_BOT_TOKEN` and `DISCORD_BRIDGE_SECRET` stay out of that definition. A +managed service inherits neither the host shell nor this deployment's Vercel +variables, and Vercel marks both as sensitive, so their values can never be read +back out of it. The bridge therefore loads them from the host secrets file +(`/root/.zo_secrets`, the same file the other Zo-hosted bots read; override with +`ZO_SECRETS_PATH`) before anything reads the environment. An environment value +always wins over the file, so the service definition can still override anything +the file holds. + `scripts/zo-deploy.ts` deploys a new revision over Zo's MCP endpoint (`api.zo.computer/mcp`), which needs no open ports on the host: ```bash diff --git a/bridge/discord-gateway/index.ts b/bridge/discord-gateway/index.ts index 8b43c96..25a1051 100644 --- a/bridge/discord-gateway/index.ts +++ b/bridge/discord-gateway/index.ts @@ -50,6 +50,19 @@ import { } from "../../lib/discord-bridge.ts"; import { discordPolicyConfigFromEnv } from "../../lib/discord-policy.ts"; import { readDiscordMentionEvent, resolveDiscordMentionAdmission } from "../../lib/discord-mention-policy.ts"; +import { loadHostSecrets } from "../../lib/host-secrets.ts"; + +// A managed service starts from a bare environment: it inherits neither the +// host shell nor the deployment's Vercel variables, which are stored as secrets +// and cannot be read back. Fill the bot token and the shared bridge secret from +// the host's secrets file before anything reads the environment, so the service +// definition never has to carry them. +const hostSecrets = loadHostSecrets(); +if (hostSecrets.skipped) { + console.log(`no host secrets at ${hostSecrets.path}; using the environment as given`); +} else if (hostSecrets.loaded.length > 0) { + console.log(`loaded ${String(hostSecrets.loaded.length)} secret(s) from ${hostSecrets.path}`); +} const DISCORD_API_BASE = "https://discord.com/api/v10"; const GATEWAY_INTENTS = DISCORD_GATEWAY_INTENTS; diff --git a/lib/host-secrets.test.ts b/lib/host-secrets.test.ts new file mode 100644 index 0000000..c1327a1 --- /dev/null +++ b/lib/host-secrets.test.ts @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + BRIDGE_SECRET_NAMES, + loadHostSecrets, + parseHostSecrets, +} from "./host-secrets.ts"; + +const SECRETS = [ + "# Zo secrets", + "DISCORD_BOT_TOKEN=bot-token", + 'DISCORD_BRIDGE_SECRET="shared secret"', + "export DISCORD_INTERNAL_GUILD_IDS='1525763491712737310'", + "", + "DISCORD_INTERNAL_CHANNEL_IDS=1525763492431331362,", + "NOT_A_BRIDGE_NAME=ignored", + "MALFORMED", +].join("\n"); + +test("parses key=value lines, skipping comments and malformed entries", () => { + const values = parseHostSecrets(SECRETS); + assert.equal(values.get("DISCORD_BOT_TOKEN"), "bot-token"); + assert.equal(values.get("MALFORMED"), undefined); + assert.equal(values.get("NOT_A_BRIDGE_NAME"), "ignored"); +}); + +test("strips quotes and an export prefix", () => { + const values = parseHostSecrets(SECRETS); + assert.equal(values.get("DISCORD_BRIDGE_SECRET"), "shared secret"); + assert.equal(values.get("DISCORD_INTERNAL_GUILD_IDS"), "1525763491712737310"); +}); + +test("fills an unset variable from the host file", () => { + const env: Record = { COMPUTER_BASE_URL: "https://example.test" }; + const source = loadHostSecrets(env, ["/secrets"], () => true, () => SECRETS); + assert.equal(env.DISCORD_BOT_TOKEN, "bot-token"); + assert.equal(source.skipped, false); + assert.equal(source.path, "/secrets"); + assert.deepEqual(source.loaded, [ + "DISCORD_BOT_TOKEN", + "DISCORD_BRIDGE_SECRET", + "DISCORD_INTERNAL_GUILD_IDS", + "DISCORD_INTERNAL_CHANNEL_IDS", + ]); +}); + +test("never overwrites a variable the service already set", () => { + const env: Record = { DISCORD_BOT_TOKEN: "from-service" }; + const source = loadHostSecrets(env, ["/secrets"], () => true, () => SECRETS); + assert.equal(env.DISCORD_BOT_TOKEN, "from-service"); + assert.ok(!source.loaded.includes("DISCORD_BOT_TOKEN")); + assert.ok(source.loaded.includes("DISCORD_BRIDGE_SECRET")); +}); + +test("treats an empty variable as unset", () => { + const env: Record = { DISCORD_BOT_TOKEN: "" }; + loadHostSecrets(env, ["/secrets"], () => true, () => SECRETS); + assert.equal(env.DISCORD_BOT_TOKEN, "bot-token"); +}); + +test("skips quietly when no secrets file exists", () => { + const env: Record = {}; + const source = loadHostSecrets(env, ["/secrets"], () => false, () => SECRETS); + assert.equal(source.skipped, true); + assert.deepEqual(source.loaded, []); + assert.equal(env.DISCORD_BOT_TOKEN, undefined); +}); + +test("honors ZO_SECRETS_PATH ahead of the default location", () => { + const env: Record = { ZO_SECRETS_PATH: "/custom" }; + const source = loadHostSecrets( + env, + ["/root/.zo_secrets"], + (candidate) => candidate === "/custom", + () => "DISCORD_BOT_TOKEN=custom-token", + ); + assert.equal(source.path, "/custom"); + assert.equal(env.DISCORD_BOT_TOKEN, "custom-token"); +}); + +test("leaves unrelated names alone", () => { + const env: Record = {}; + loadHostSecrets(env, ["/secrets"], () => true, () => SECRETS); + assert.equal(env.NOT_A_BRIDGE_NAME, undefined); + assert.ok(BRIDGE_SECRET_NAMES.includes("DISCORD_BOT_TOKEN")); +}); diff --git a/lib/host-secrets.ts b/lib/host-secrets.ts new file mode 100644 index 0000000..ac7b40f --- /dev/null +++ b/lib/host-secrets.ts @@ -0,0 +1,82 @@ +import { existsSync, readFileSync } from "node:fs"; + +/** + * Names the bridge accepts from the host secrets file. + * + * An environment variable set on the service definition always wins, so the + * file is a fallback for credentials that are kept out of the service (and out + * of this repository) rather than an override of it. + */ +export const BRIDGE_SECRET_NAMES = [ + "DISCORD_BOT_TOKEN", + "DISCORD_BRIDGE_SECRET", + "DISCORD_APPLICATION_ID", + "DISCORD_INTERNAL_GUILD_IDS", + "DISCORD_INTERNAL_CHANNEL_IDS", + "DISCORD_INTERNAL_USER_IDS", + "DISCORD_INTERNAL_ROLE_IDS", +] as const; + +/** Where the host keeps Zo-managed secrets, checked in order. */ +export const DEFAULT_HOST_SECRET_PATHS = ["/root/.zo_secrets"] as const; + +export interface HostSecretSource { + /** Path of the file that was read. */ + path: string; + /** Names that were filled from the file because the environment left them unset. */ + loaded: string[]; + /** True when no file was present, so the environment is used as given. */ + skipped: boolean; +} + +/** Parses `KEY=value` lines, ignoring blanks, comments, and `export` prefixes. */ +export function parseHostSecrets(text: string): Map { + const values = new Map(); + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#")) continue; + const withoutExport = trimmed.startsWith("export ") ? trimmed.slice(7) : trimmed; + const separator = withoutExport.indexOf("="); + if (separator <= 0) continue; + const name = withoutExport.slice(0, separator).trim(); + let value = withoutExport.slice(separator + 1).trim(); + const quoted = + (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")); + if (quoted && value.length >= 2) value = value.slice(1, -1); + if (name.length > 0) values.set(name, value); + } + return values; +} + +/** + * Fills the bridge's credentials from the host's secrets file. + * + * Zo services inherit neither the host shell environment nor the deployment's + * variables, so a bot token and shared secret kept on the deployment would have + * to be duplicated into the service definition. Reading the host file instead + * keeps one copy of each credential. Nothing is overwritten: an unset variable + * is filled, a set one is left alone. + */ +export function loadHostSecrets( + env: Record = process.env, + paths: readonly string[] = DEFAULT_HOST_SECRET_PATHS, + exists: (path: string) => boolean = existsSync, + read: (path: string) => string = (path) => readFileSync(path, "utf8"), +): HostSecretSource { + const override = env.ZO_SECRETS_PATH; + const candidates = override !== undefined && override.length > 0 ? [override, ...paths] : paths; + const path = candidates.find((candidate) => exists(candidate)); + if (path === undefined) return { path: candidates[0] ?? "", loaded: [], skipped: true }; + + const values = parseHostSecrets(read(path)); + const loaded: string[] = []; + for (const name of BRIDGE_SECRET_NAMES) { + const current = env[name]; + if (current !== undefined && current.length > 0) continue; + const value = values.get(name); + if (value === undefined || value.length === 0) continue; + env[name] = value; + loaded.push(name); + } + return { path, loaded, skipped: false }; +}