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 packages/loopover-miner/lib/hosted-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@
// up, then dispatches to exactly ONE existing unattended-cycle command
// (docs/unattended-scheduling.md's `discover`/`manage poll`, plus `attempt`) reused in-process -- these
// functions already return the miner's own 0=success/2=failure exit-code contract unmodified; this file adds
// no new exit-code vocabulary, it only wraps the health server's lifecycle around one of them.
// no new exit-code vocabulary, it only wraps the health server's lifecycle around one of them. Also resolves
// this tenant's #8202/#8246 bootstrap credential once per wake (tenant-credential-resolution.ts) -- best-effort,
// purely to prove that mechanism is wired for AMS; no cycle command reads the result today.
import type { Server } from "node:http";
import { access } from "node:fs/promises";
import { runAttempt } from "./attempt-cli.js";
import { runDiscover } from "./discover-cli.js";
import { runManagePoll } from "./manage-poll.js";
import { resolveMinerStateDir } from "./status.js";
import { resolveTenantSecret } from "./tenant-credential-resolution.js";
import { startAmsHealthServer, type ReadinessProbe } from "./ams-health-server.js";

/** The one-shot cycle commands a hosted tenant can be woken to run -- deliberately NOT `loop` (the
Expand Down Expand Up @@ -64,6 +67,12 @@ export async function runHostedEntry(cliArgs: string[], options: RunHostedEntryO
return 2;
}

// #8246: best-effort, resolved once per wake -- proves the #8202 bootstrap-secret mechanism is wired for AMS
// too. No consumer exists for the resolved value yet (this package has no Postgres-backed store today), so
// this never blocks or fails the actual cycle dispatch below.
const tenantSecret = await resolveTenantSecret(env);
console.log(JSON.stringify({ event: "ams_hosted_entry_tenant_secret_resolved", resolved: tenantSecret !== null, secretType: tenantSecret?.secretType ?? null }));

let server: Server | undefined;
try {
server = await startAmsHealthServer({ port: options.port ?? 8080, probes: [stateDirProbe(env)] });
Expand Down
102 changes: 102 additions & 0 deletions packages/loopover-miner/lib/tenant-credential-resolution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Resolves a hosted AMS tenant's bootstrap secret (#8246, the AMS half of #8202). Exchanges
// LOOPOVER_TENANT_SECRET_TOKEN against the SAME broker exchange src/orb/broker-client.ts's
// fetchBrokeredStoredSecret already implements for ORB -- duplicated here, not imported: this package is a
// real npm workspace member whose tsconfig.json scopes `"rootDir": "."` to itself, so a relative import
// reaching into root src/ resolves outside rootDir and fails tsc with TS6059. This mirrors
// control-plane/src/secret-driver.ts's own identical "duplicate, don't import" call for the SAME package
// boundary (see also control-plane/src/http-app.ts's HOSTED_CYCLE_COMMANDS comment, which cross-references
// this file for the same reasoning).
//
// #8202's mechanism: control-plane delivers a one-time bootstrap credential into a hosted tenant container's
// cold-boot env as LOOPOVER_TENANT_SECRET_TOKEN (a product-agnostic name -- ORB's and AMS's containers both
// read the identical var). The container exchanges it via POST /v1/orb/token for whatever the broker has
// custodied under it -- today, always a tenant_db_credential (a JSON-encoded DatabaseConnectionDetails);
// #8202's own research confirmed there is no production issuance path for ams_github_token yet, so that isn't
// a real response shape to plan a consumer around.
//
// resolveTenantSecret (the function hosted-entry.ts actually calls) is deliberately best-effort: unlike ORB's
// fetchBrokeredStoredSecret, which throws because a self-hosted engine has real work that needs the value, no
// code in this package consumes a resolved tenant secret yet (the miner's own stores are unconditionally local
// SQLite -- see store-db-adapter.ts's own "later" note on swapping in a Postgres adapter), so a broker outage
// or an unconfigured token must not block a scheduled discover/manage-poll/attempt cycle from running.
// fetchTenantSecret (the throwing primitive) is exported for whatever real consumer eventually needs strict
// failure semantics.
//
// This FILE is named "credential", not "secret", purely to stay clear of scripts/check-miner-package.ts's
// filename-based FORBIDDEN_PATH filter (a coarse `.*secret.*` heuristic aimed at stray credential files like
// .env/.pem, not descriptively-named source code) -- the exported symbols below keep "Secret" in their names,
// matching src/orb/broker-client.ts's own naming for the function this duplicates.

const DEFAULT_BROKER_URL = "https://api.loopover.ai";
const BROKER_TIMEOUT_MS = 25_000;

function isLocalBrokerHost(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
}

/** Same URL-safety validation as broker-client.ts's own orbBrokerBaseUrl -- guards against an attacker- or
* misconfiguration-controlled ORB_BROKER_URL sending the bootstrap token to an unintended origin. */
function orbBrokerBaseUrl(env: { ORB_BROKER_URL?: string | undefined }): string {
const raw = env.ORB_BROKER_URL ?? DEFAULT_BROKER_URL;
let url: URL;
try {
url = new URL(raw);
} catch {
throw new Error("ORB_BROKER_URL must be a valid URL.");
}
if (url.username || url.password) {
throw new Error("ORB_BROKER_URL must not include userinfo.");
}
if (url.search || url.hash) {
throw new Error("ORB_BROKER_URL must not include a query string or fragment.");
}
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocalBrokerHost(url.hostname))) {
throw new Error("ORB_BROKER_URL must use https unless it targets localhost development.");
}
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, "");
return `${url.origin}${path}`;
}

export type TenantSecret = { secretValue: string; secretType: string };

/** Exchange LOOPOVER_TENANT_SECRET_TOKEN for whatever the broker has custodied under it. Throws on a non-OK
* response or a body missing secretValue -- the strict primitive; {@link resolveTenantSecret} below is the
* best-effort wrapper hosted-entry.ts actually calls. */
export async function fetchTenantSecret(
env: { LOOPOVER_TENANT_SECRET_TOKEN?: string | undefined; ORB_BROKER_URL?: string | undefined },
fetchImpl: typeof fetch = fetch,
): Promise<TenantSecret> {
const base = orbBrokerBaseUrl(env);
const response = await fetchImpl(`${base}/v1/orb/token`, {
method: "POST",
headers: { authorization: `Bearer ${env.LOOPOVER_TENANT_SECRET_TOKEN ?? ""}` },
signal: AbortSignal.timeout(BROKER_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`Orb broker stored-secret exchange failed (${response.status}).`);
}
const payload = (await response.json()) as { secretValue?: string; secretType?: string };
if (!payload.secretValue) {
throw new Error("Orb broker stored-secret response did not include a secretValue.");
}
return { secretValue: payload.secretValue, secretType: payload.secretType ?? "" };
}

/** Best-effort wrapper around {@link fetchTenantSecret} (#8246): `null` when `LOOPOVER_TENANT_SECRET_TOKEN`
* isn't set (a self-hosted or not-yet-provisioned tenant -- the overwhelmingly common case today) OR when the
* exchange itself fails, logged rather than thrown. `hosted-entry.ts` calls this once per wake so the
* mechanism is proven wired end-to-end for AMS (#8246's own deliverable) without making a scheduled cycle
* fragile against a value nothing consumes yet. */
export async function resolveTenantSecret(
env: Record<string, string | undefined>,
fetchImpl: typeof fetch = fetch,
): Promise<TenantSecret | null> {
const token = env.LOOPOVER_TENANT_SECRET_TOKEN?.trim();
if (!token) return null;
try {
return await fetchTenantSecret(env, fetchImpl);
} catch (error) {
console.warn(JSON.stringify({ event: "ams_tenant_secret_resolve_failed", message: error instanceof Error ? error.message : String(error) }));
return null;
}
}
61 changes: 61 additions & 0 deletions test/unit/miner-hosted-entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ const runDiscover = vi.fn(async (_args: string[]) => 0);
const runManagePoll = vi.fn(async (_args: string[]) => 0);
const runAttempt = vi.fn(async (_args: string[]) => 0);
const startAmsHealthServer = vi.fn(async (_options: HealthServerOptions) => ({ close: (cb: () => void) => cb() }));
const resolveTenantSecret = vi.fn(async (_env: Record<string, string | undefined>) => null as { secretValue: string; secretType: string } | null);

vi.mock("../../packages/loopover-miner/lib/discover-cli.js", () => ({ runDiscover }));
vi.mock("../../packages/loopover-miner/lib/manage-poll.js", () => ({ runManagePoll }));
vi.mock("../../packages/loopover-miner/lib/attempt-cli.js", () => ({ runAttempt }));
vi.mock("../../packages/loopover-miner/lib/ams-health-server.js", () => ({ startAmsHealthServer }));
vi.mock("../../packages/loopover-miner/lib/tenant-credential-resolution.js", () => ({ resolveTenantSecret }));

const { isHostedCycleCommand, runHostedEntry } = await import("../../packages/loopover-miner/lib/hosted-entry.js");

Expand Down Expand Up @@ -138,4 +140,63 @@ describe("runHostedEntry (#7182)", () => {
const exitCode = await runHostedEntry(["discover"]);
expect(typeof exitCode).toBe("number");
});

// #8246: resolveTenantSecret is called once per wake, with whatever env was resolved -- proves the #8202
// bootstrap-secret mechanism is wired for AMS. It's best-effort by design (see tenant-secret-resolution.ts),
// so neither a null nor a real result should ever change whether/how the cycle command itself dispatches.
describe("tenant secret resolution (#8246)", () => {
it("calls resolveTenantSecret with the resolved env before dispatching the cycle command", async () => {
const env = { LOOPOVER_MINER_CONFIG_DIR: stateDir, LOOPOVER_TENANT_SECRET_TOKEN: "orbsec_x" };

await runHostedEntry(["discover"], { env });

expect(resolveTenantSecret).toHaveBeenCalledExactlyOnceWith(env);
});

it("still dispatches normally when no tenant secret resolves (self-host/unprovisioned -- the common case)", async () => {
resolveTenantSecret.mockResolvedValueOnce(null);

const exitCode = await runHostedEntry(["discover"], { env: { LOOPOVER_MINER_CONFIG_DIR: stateDir } });

expect(exitCode).toBe(0);
expect(runDiscover).toHaveBeenCalledWith([]);
});

it("still dispatches normally when a real tenant secret resolves", async () => {
resolveTenantSecret.mockResolvedValueOnce({ secretValue: "postgres://tenant-acme@neon/acme", secretType: "tenant_db_credential" });

const exitCode = await runHostedEntry(["discover"], { env: { LOOPOVER_MINER_CONFIG_DIR: stateDir } });

expect(exitCode).toBe(0);
expect(runDiscover).toHaveBeenCalledWith([]);
});

it("logs the resolved secretType when a tenant secret resolves", async () => {
resolveTenantSecret.mockResolvedValueOnce({ secretValue: "postgres://tenant-acme@neon/acme", secretType: "tenant_db_credential" });
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);

await runHostedEntry(["discover"], { env: { LOOPOVER_MINER_CONFIG_DIR: stateDir } });

const entry = log.mock.calls.map((call) => JSON.parse(call[0] as string) as { event?: string }).find((parsed) => parsed.event === "ams_hosted_entry_tenant_secret_resolved");
expect(entry).toEqual({ event: "ams_hosted_entry_tenant_secret_resolved", resolved: true, secretType: "tenant_db_credential" });
log.mockRestore();
});

it("logs a null secretType when no tenant secret resolves", async () => {
resolveTenantSecret.mockResolvedValueOnce(null);
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);

await runHostedEntry(["discover"], { env: { LOOPOVER_MINER_CONFIG_DIR: stateDir } });

const entry = log.mock.calls.map((call) => JSON.parse(call[0] as string) as { event?: string }).find((parsed) => parsed.event === "ams_hosted_entry_tenant_secret_resolved");
expect(entry).toEqual({ event: "ams_hosted_entry_tenant_secret_resolved", resolved: false, secretType: null });
log.mockRestore();
});

it("never calls resolveTenantSecret for an unknown cycle name (the early-return path)", async () => {
await runHostedEntry(["loop"], { env: { LOOPOVER_MINER_CONFIG_DIR: stateDir } });

expect(resolveTenantSecret).not.toHaveBeenCalled();
});
});
});
Loading