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
4 changes: 4 additions & 0 deletions .gittensory-miner.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions packages/gittensory-miner/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<NAME>_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)
Expand Down
13 changes: 13 additions & 0 deletions packages/gittensory-miner/bin/gittensory-miner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -28,6 +29,18 @@ import {
} from "../lib/update-check.js";
import { resolveMinerVersion } from "../lib/version.js";

// Resolve any `<NAME>_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();
Expand Down
4 changes: 4 additions & 0 deletions packages/gittensory-miner/lib/env-file-indirection.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export function loadMinerFileSecrets(
env?: Record<string, string | undefined>,
readFile?: (path: string) => string,
): void;
45 changes: 45 additions & 0 deletions packages/gittensory-miner/lib/env-file-indirection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Resolve `<NAME>_FILE` env vars (Docker/Swarm/K8s secret mounts) into `<NAME>` 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
// `<NAME>_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 `<NAME>_FILE` vars and resolve each into `<NAME>` in place, reading the referenced file's
* contents (trimmed). An explicit `<NAME>` value always wins over `<NAME>_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 `<NAME>_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<string, string | undefined>} [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 <NAME> value always wins over <NAME>_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)
}`,
);
}
}
}
167 changes: 167 additions & 0 deletions test/unit/miner-env-file-indirection.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {
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<string, string | undefined> = { 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<string, string | undefined> = { 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<string, string | undefined> = {
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<string, string | undefined> = { 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<string, string | undefined> = { 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<string, string | undefined> = { 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<string, string | undefined> = { 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<string, string | undefined> = { 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<string, string> = {
"/run/secrets/github_token": "ghp_multi",
"/run/secrets/anthropic_key": "sk-ant-multi",
};
const readFile = vi.fn((path: string) => contents[path] ?? "");
const env: Record<string, string | undefined> = {
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");
});
});
});