diff --git a/packages/cap-discord/src/config.ts b/packages/cap-discord/src/config.ts index 817bb87..2b97d76 100644 --- a/packages/cap-discord/src/config.ts +++ b/packages/cap-discord/src/config.ts @@ -17,6 +17,7 @@ // a JSON blob) — config, not the secret. The secret stays on disk. import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; import { type Static, Type } from "typebox"; import { Value } from "typebox/value"; @@ -98,9 +99,17 @@ export function readToken( tokenFile: string, read: (path: string) => string = (p) => readFileSync(p, "utf8"), ): string { + // Expand a leading ~/ so configs can use the ~-relative paths the schema + // documents ("Absolute (or ~-relative) path"). Node's fs does NOT expand ~/ + // (only the shell does), so without this an unexpanded path throws ENOENT — + // and because pi's loader swallows extension-load errors, the discord + // capability would silently never load. Mirrors cap-flair / cap-observatory. + const resolvedPath = tokenFile.startsWith("~/") + ? `${homedir()}/${tokenFile.slice(2)}` + : tokenFile; let contents: string; try { - contents = read(tokenFile); + contents = read(resolvedPath); } catch (err) { // err.message from fs already contains only the path, not contents. const reason = err instanceof Error ? err.message : "read failed"; @@ -108,7 +117,7 @@ export function readToken( } const token = contents.trim(); if (token.length === 0) { - throw new Error(`discord capability: token file ${tokenFile} is empty.`); + throw new Error(`discord capability: token file ${resolvedPath} is empty.`); } return token; } diff --git a/packages/cap-discord/test/config.test.ts b/packages/cap-discord/test/config.test.ts index 45eb420..64c00e4 100644 --- a/packages/cap-discord/test/config.test.ts +++ b/packages/cap-discord/test/config.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { Value } from "typebox/value"; import { CONFIG_ENV_VAR, CONFIG_SCHEMA, loadConfigFromEnv, readToken } from "../src/config.js"; @@ -99,6 +99,30 @@ describe("readToken", () => { expect(msg).not.toContain("the-secret-token"); }); + it("expands a leading ~/ in the token file path to the home dir", () => { + // Node's fs does NOT expand ~/ (only the shell does); readToken must, so a + // bob.yaml `tokenFile: ~/...` does not throw ENOENT and silently kill the + // cap. Use the read seam to capture the path actually opened — never a real + // token file. + let opened = ""; + const token = readToken("~/agents/rivet/.pi-agent/discord.token", (p) => { + opened = p; + return "tok-from-home\n"; + }); + expect(opened).toBe(`${homedir()}/agents/rivet/.pi-agent/discord.token`); + expect(opened.startsWith("~/")).toBe(false); + expect(token).toBe("tok-from-home"); + }); + + it("leaves an absolute token file path unchanged", () => { + let opened = ""; + readToken("/secrets/bot.token", (p) => { + opened = p; + return "abs-token\n"; + }); + expect(opened).toBe("/secrets/bot.token"); + }); + it("never echoes the token in any error path", () => { // Read succeeds but token is whitespace → empty error. The thrown message // must not contain the raw read contents.