From fa599d696115d574fa22fbe43ebe0595be15408d Mon Sep 17 00:00:00 2001 From: galuis116 Date: Mon, 13 Jul 2026 01:09:02 -0400 Subject: [PATCH] feat(miner): add GITHUB_TOKEN_FILE secret-mount indirection for fleet mode The miner is a separate deployable from ORB (its own process/container per DEPLOYMENT.md's fleet mode), so it never ran through ORB's own src/selfhost/load-file-secrets.ts server-startup resolver -- a plain GITHUB_TOKEN env var is visible in plaintext via `docker inspect` on any host running the miner, with no file-mount alternative. Add a generic _FILE resolver, ported from that same ORB pattern, called once at CLI startup (before any subcommand dispatches or reads process.env) so it covers GITHUB_TOKEN and any coding-agent env-var credential with zero changes to the individual call sites that already read those vars. Diverges from the ORB analogue on purpose: throws (rather than logging and continuing) on a missing/unreadable file, so a broken secret mount fails a container fast and loud. Fixes #5178 --- .gittensory-miner.env.example | 4 + packages/gittensory-miner/DEPLOYMENT.md | 22 +++ .../gittensory-miner/bin/gittensory-miner.js | 13 ++ .../lib/env-file-indirection.d.ts | 4 + .../lib/env-file-indirection.js | 45 +++++ test/unit/miner-env-file-indirection.test.ts | 167 ++++++++++++++++++ 6 files changed, 255 insertions(+) create mode 100644 packages/gittensory-miner/lib/env-file-indirection.d.ts create mode 100644 packages/gittensory-miner/lib/env-file-indirection.js create mode 100644 test/unit/miner-env-file-indirection.test.ts diff --git a/.gittensory-miner.env.example b/.gittensory-miner.env.example index b8760ea4c7..b427c93955 100644 --- a/.gittensory-miner.env.example +++ b/.gittensory-miner.env.example @@ -15,6 +15,10 @@ # GitHub token (a PAT or an App installation token) the miner uses for issue discovery and PR-outcome # polling. Discovery/attempt runs that reach GitHub fail without it. Keep it out of source control. GITHUB_TOKEN=ghp_your_token_here +# Docker Swarm/Kubernetes secret-mount alternative: set GITHUB_TOKEN_FILE to a mounted file path instead +# of a plaintext value above (keeps the token out of `docker inspect`); an explicit GITHUB_TOKEN always +# wins if both are set. See packages/gittensory-miner/DEPLOYMENT.md's "Secret-file alternative" section. +# GITHUB_TOKEN_FILE=/run/secrets/github_token # ============================================================================= # 2. Coding agent diff --git a/packages/gittensory-miner/DEPLOYMENT.md b/packages/gittensory-miner/DEPLOYMENT.md index a4eac6ace4..1e04d4fa48 100644 --- a/packages/gittensory-miner/DEPLOYMENT.md +++ b/packages/gittensory-miner/DEPLOYMENT.md @@ -92,6 +92,28 @@ The image entrypoint is `gittensory-miner`; pass subcommands after the image nam - **`GITHUB_TOKEN`** — supplied by the operator at run time; the image contains no credentials. - **Scale** — launch additional containers with the same volume (or partitioned config dirs) for parallel attempts. +**Secret-file alternative (`GITHUB_TOKEN_FILE`).** A plain `-e GITHUB_TOKEN` value is visible in plaintext +via `docker inspect`/`docker compose config` and any full-env dump of the running container. For Docker +Swarm/Kubernetes-managed secrets (mounted as a file, e.g. at `/run/secrets/github_token`), set +`GITHUB_TOKEN_FILE` to that mount path instead — the miner reads and trims the file's contents at startup and +uses it exactly as if `GITHUB_TOKEN` had been set directly: + +```sh +docker run --rm -it \ + -e GITTENSORY_MINER_CONFIG_DIR=/data/miner \ + -e GITHUB_TOKEN_FILE=/run/secrets/github_token \ + -v miner-data:/data/miner \ + -v /path/to/your/secret:/run/secrets/github_token:ro \ + gittensory-miner:latest \ + doctor +``` + +If both `GITHUB_TOKEN` and `GITHUB_TOKEN_FILE` are set, the plain `GITHUB_TOKEN` value always wins (same +precedence rule as ORB's own `src/selfhost/load-file-secrets.ts`). A missing or unreadable `GITHUB_TOKEN_FILE` +fails the container fast with a clear error naming the file path, rather than silently proceeding with no +credential. The same `_FILE` convention works for any credential the miner reads from a plain env var — +not only `GITHUB_TOKEN`. + The repo-root [`docker-compose.yml`](../../docker-compose.yml) documents the **self-hosted review stack** (the `gittensory` API/orb), not the miner CLI. Miners are clients of that stack (or of github.com directly) and do not require it to run locally. ### Docker Compose (fleet mode) diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index 5d834ef83f..15d721ee9c 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -19,6 +19,7 @@ import { runOrbExportCli } from "../lib/orb-export.js"; import { installCliSignalHandlers } from "../lib/process-lifecycle.js"; import { runStateCli } from "../lib/run-state-cli.js"; import { runInit } from "../lib/laptop-init.js"; +import { loadMinerFileSecrets } from "../lib/env-file-indirection.js"; import { runMigrate } from "../lib/migrate-cli.js"; import { runDoctor, runStatus } from "../lib/status.js"; import { @@ -28,6 +29,18 @@ import { } from "../lib/update-check.js"; import { resolveMinerVersion } from "../lib/version.js"; +// Resolve any `_FILE` secret-mount vars (GITHUB_TOKEN_FILE, etc.) into their plain counterparts FIRST, +// before anything else reads process.env -- every subcommand below (and the coding-agent driver construction +// deeper in the call graph) reads plain env vars, so this single early pass is all that's needed for the whole +// CLI (#5178). A broken secret mount fails the process fast and loud with a clear message, instead of an +// uncaught-exception stack trace or a silent empty credential surfacing as a confusing GitHub 401 later. +try { + loadMinerFileSecrets(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} + // Register signal + crash handlers once, before any command runs, so an interrupted run closes its open ledgers // cleanly instead of dying mid-write (#4826). Covers every subcommand below, including the local ones. installCliSignalHandlers(); diff --git a/packages/gittensory-miner/lib/env-file-indirection.d.ts b/packages/gittensory-miner/lib/env-file-indirection.d.ts new file mode 100644 index 0000000000..ad731369df --- /dev/null +++ b/packages/gittensory-miner/lib/env-file-indirection.d.ts @@ -0,0 +1,4 @@ +export function loadMinerFileSecrets( + env?: Record, + readFile?: (path: string) => string, +): void; diff --git a/packages/gittensory-miner/lib/env-file-indirection.js b/packages/gittensory-miner/lib/env-file-indirection.js new file mode 100644 index 0000000000..8374021b9b --- /dev/null +++ b/packages/gittensory-miner/lib/env-file-indirection.js @@ -0,0 +1,45 @@ +// Resolve `_FILE` env vars (Docker/Swarm/K8s secret mounts) into `` at miner startup (#5178). +// Ports src/selfhost/load-file-secrets.ts's pattern into the miner package -- the miner is a separate +// deployable (its own process/container per DEPLOYMENT.md's fleet mode), so it never runs through ORB's own +// server-startup resolver. Deliberately diverges from that analogue in one way: an unreadable/missing +// `_FILE` here THROWS rather than logging and continuing, so a broken secret mount fails a miner +// container fast and loud (never silently proceeds with an unset/empty credential the next real GitHub call +// would then fail on anyway, with a far less specific error). +import { readFileSync } from "node:fs"; + +// Docker Compose's OWN reserved `_FILE`-suffixed environment variables -- never gittensory's secret-file +// convention, so they must never be dereferenced below (mirrors src/selfhost/load-file-secrets.ts's own +// exclusion and rationale: `COMPOSE_FILE` is a colon-delimited list of compose file paths, never a single +// readable file itself, and `COMPOSE_ENV_FILE` points at an operator's own .env file, not a secret). +const COMPOSE_RESERVED_FILE_VARS = new Set(["COMPOSE_FILE", "COMPOSE_ENV_FILE"]); + +/** + * Scan `env` for `_FILE` vars and resolve each into `` in place, reading the referenced file's + * contents (trimmed). An explicit `` value always wins over `_FILE` (mirrors the ORB analogue's + * precedence rule exactly) -- a `_FILE` var is only consulted when its plain counterpart is unset. Throws a + * clear, actionable error identifying the offending `_FILE` var and its file path when the file is + * missing or unreadable -- this never silently leaves a credential empty/undefined. Never logs or returns any + * resolved secret value itself; only the (non-secret) var name and file path ever appear in a thrown message. + * + * `env` and `readFile` are injectable purely for testability -- every real caller uses the defaults + * (`process.env`, `node:fs`'s `readFileSync`), so this is byte-identical to a hardcoded version at runtime. + * + * @param {Record} [env] + * @param {(path: string) => string} [readFile] + */ +export function loadMinerFileSecrets(env = process.env, readFile = (path) => readFileSync(path, "utf8")) { + for (const key of Object.keys(env)) { + if (!key.endsWith("_FILE") || !env[key] || COMPOSE_RESERVED_FILE_VARS.has(key)) continue; + const target = key.slice(0, -"_FILE".length); + if (env[target]) continue; // an explicit value always wins over _FILE + try { + env[target] = readFile(env[key]).trim(); + } catch (error) { + throw new Error( + `Failed to read secret file for ${key} (${env[key]}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } +} diff --git a/test/unit/miner-env-file-indirection.test.ts b/test/unit/miner-env-file-indirection.test.ts new file mode 100644 index 0000000000..0eb2e0dddb --- /dev/null +++ b/test/unit/miner-env-file-indirection.test.ts @@ -0,0 +1,167 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loadMinerFileSecrets } from "../../packages/gittensory-miner/lib/env-file-indirection.js"; +import { bin } from "./support/miner-cli-harness"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("loadMinerFileSecrets (#5178)", () => { + it("REGRESSION: never dereferences COMPOSE_FILE, and never calls readFile for it", () => { + const readFile = vi.fn(() => "should never be called"); + const env: Record = { + COMPOSE_FILE: "docker-compose.yml:docker-compose.override.yml", + }; + loadMinerFileSecrets(env, readFile); + expect(env.COMPOSE).toBeUndefined(); + expect(readFile).not.toHaveBeenCalled(); + }); + + it("also excludes COMPOSE_ENV_FILE, Compose's other reserved _FILE var", () => { + const readFile = vi.fn(() => "should never be called"); + const env: Record = { COMPOSE_ENV_FILE: ".env.prod" }; + loadMinerFileSecrets(env, readFile); + expect(env.COMPOSE_ENV).toBeUndefined(); + expect(readFile).not.toHaveBeenCalled(); + }); + + it("dereferences GITHUB_TOKEN_FILE into GITHUB_TOKEN, trimmed", () => { + const readFile = vi.fn(() => "ghp_s3cr3t\n"); + const env: Record = { GITHUB_TOKEN_FILE: "/run/secrets/github_token" }; + loadMinerFileSecrets(env, readFile); + expect(readFile).toHaveBeenCalledWith("/run/secrets/github_token"); + expect(env.GITHUB_TOKEN).toBe("ghp_s3cr3t"); + }); + + it("an explicit GITHUB_TOKEN always wins over GITHUB_TOKEN_FILE (documented precedence)", () => { + const readFile = vi.fn(() => "from-file"); + const env: Record = { + GITHUB_TOKEN_FILE: "/run/secrets/github_token", + GITHUB_TOKEN: "already-set", + }; + loadMinerFileSecrets(env, readFile); + expect(readFile).not.toHaveBeenCalled(); + expect(env.GITHUB_TOKEN).toBe("already-set"); + }); + + it("ignores a key that doesn't end in _FILE, and a _FILE key with no value", () => { + const readFile = vi.fn(); + const env: Record = { NOT_A_SECRET: "x", EMPTY_FILE: "" }; + loadMinerFileSecrets(env, readFile); + expect(readFile).not.toHaveBeenCalled(); + }); + + it("resolves an empty (but readable) secret file to an empty string, not an error", () => { + const readFile = vi.fn(() => " \n"); + const env: Record = { GITHUB_TOKEN_FILE: "/run/secrets/github_token" }; + loadMinerFileSecrets(env, readFile); + expect(env.GITHUB_TOKEN).toBe(""); + }); + + it("REGRESSION (gate divergence from the ORB analogue): throws a clear, actionable error naming the var and path when the file is missing/unreadable, instead of logging and continuing", () => { + const readFile = vi.fn(() => { + throw new Error("ENOENT: no such file or directory"); + }); + const env: Record = { GITHUB_TOKEN_FILE: "/run/secrets/missing" }; + expect(() => loadMinerFileSecrets(env, readFile)).toThrow( + "Failed to read secret file for GITHUB_TOKEN_FILE (/run/secrets/missing): ENOENT: no such file or directory", + ); + expect(env.GITHUB_TOKEN).toBeUndefined(); + }); + + it("formats a non-Error thrown value into the error message (defensive fallback)", () => { + const readFile = vi.fn(() => { + throw "boom"; // deliberately non-Error, exercising the ternary's fallback branch + }); + const env: Record = { GITHUB_TOKEN_FILE: "/run/secrets/missing" }; + expect(() => loadMinerFileSecrets(env, readFile)).toThrow( + "Failed to read secret file for GITHUB_TOKEN_FILE (/run/secrets/missing): boom", + ); + }); + + it("invariant: never includes the resolved secret's own value in a thrown error message", () => { + const readFile = vi.fn(() => { + throw new Error("permission denied"); + }); + const env: Record = { GITHUB_TOKEN_FILE: "/run/secrets/github_token" }; + try { + loadMinerFileSecrets(env, readFile); + expect.unreachable("expected loadMinerFileSecrets to throw"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + expect(message).toContain("GITHUB_TOKEN_FILE"); + expect(message).toContain("/run/secrets/github_token"); + expect(message).not.toContain("ghp_"); + } + }); + + it("resolves multiple independent _FILE vars in one pass", () => { + const contents: Record = { + "/run/secrets/github_token": "ghp_multi", + "/run/secrets/anthropic_key": "sk-ant-multi", + }; + const readFile = vi.fn((path: string) => contents[path] ?? ""); + const env: Record = { + GITHUB_TOKEN_FILE: "/run/secrets/github_token", + ANTHROPIC_API_KEY_FILE: "/run/secrets/anthropic_key", + }; + loadMinerFileSecrets(env, readFile); + expect(env.GITHUB_TOKEN).toBe("ghp_multi"); + expect(env.ANTHROPIC_API_KEY).toBe("sk-ant-multi"); + }); + + it("defaults to process.env and the real node:fs reader when called with no arguments", () => { + const original = process.env.NOT_A_REAL_MINER_SECRET_FILE; + process.env.NOT_A_REAL_MINER_SECRET_FILE = "/definitely/does/not/exist"; + try { + expect(() => loadMinerFileSecrets()).toThrow(/NOT_A_REAL_MINER_SECRET_FILE/); + } finally { + if (original === undefined) delete process.env.NOT_A_REAL_MINER_SECRET_FILE; + else process.env.NOT_A_REAL_MINER_SECRET_FILE = original; + } + }); + + describe("wired into the real CLI entry point (bin/gittensory-miner.js)", () => { + it("resolves GITHUB_TOKEN_FILE end-to-end: status --json reports it without ever printing the value", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-file-secret-")); + roots.push(root); + const secretPath = join(root, "github_token"); + writeFileSync(secretPath, "ghp_end_to_end_value\n"); + + const result = spawnSync("node", [bin, "status", "--json"], { + encoding: "utf8", + env: { + ...process.env, + GITTENSORY_MINER_CONFIG_DIR: join(root, "state"), + GITHUB_TOKEN: "", + GITHUB_TOKEN_FILE: secretPath, + }, + }); + + expect(result.status).toBe(0); + expect(result.stdout).not.toContain("ghp_end_to_end_value"); + expect(result.stderr).not.toContain("ghp_end_to_end_value"); + }); + + it("fails the process fast with a clear error when GITHUB_TOKEN_FILE points at a missing file", () => { + const result = spawnSync("node", [bin, "status"], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_TOKEN: "", + GITHUB_TOKEN_FILE: "/definitely/does/not/exist/github_token", + }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("GITHUB_TOKEN_FILE"); + expect(result.stderr).toContain("/definitely/does/not/exist/github_token"); + }); + }); +});