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
13 changes: 11 additions & 2 deletions packages/cap-discord/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -98,17 +99,25 @@ 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";
throw new Error(`discord capability: cannot read token file: ${reason}`);
}
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;
}
26 changes: 25 additions & 1 deletion packages/cap-discord/test/config.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Expand Down
Loading