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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions bridge/discord-gateway/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
86 changes: 86 additions & 0 deletions lib/host-secrets.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = { 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<string, string | undefined> = { 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<string, string | undefined> = { 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<string, string | undefined> = {};
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<string, string | undefined> = { 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<string, string | undefined> = {};
loadHostSecrets(env, ["/secrets"], () => true, () => SECRETS);
assert.equal(env.NOT_A_BRIDGE_NAME, undefined);
assert.ok(BRIDGE_SECRET_NAMES.includes("DISCORD_BOT_TOKEN"));
});
82 changes: 82 additions & 0 deletions lib/host-secrets.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const values = new Map<string, string>();
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<string, string | undefined> = 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 };
}
Loading