From 6b0b80ab2ffeba7e8e1bcc0a9c126fba8ae326ab Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:23:37 -0700 Subject: [PATCH] feat(miner): wire AMS's hosted entry point to the #8202 tenant-secret bootstrap (#8246) Adds tenant-credential-resolution.ts, a duplicated (not imported -- cross- package import from packages/loopover-miner into root src/ fails tsc with TS6059, confirmed) exchange of LOOPOVER_TENANT_SECRET_TOKEN against the broker, mirroring src/orb/broker-client.ts's fetchBrokeredStoredSecret for ORB. hosted-entry.ts resolves it once per wake, best-effort -- no code in this package consumes the resolved value yet (the miner's stores are unconditionally local SQLite), so this proves the mechanism is wired for AMS without making a scheduled cycle fragile against an unused value, mirroring #8202/#8253's own "prove it works, defer consumption" precedent for ORB. --- packages/loopover-miner/lib/hosted-entry.ts | 11 +- .../lib/tenant-credential-resolution.ts | 102 ++++++++++++++ test/unit/miner-hosted-entry.test.ts | 61 ++++++++ ...miner-tenant-credential-resolution.test.ts | 130 ++++++++++++++++++ 4 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 packages/loopover-miner/lib/tenant-credential-resolution.ts create mode 100644 test/unit/miner-tenant-credential-resolution.test.ts diff --git a/packages/loopover-miner/lib/hosted-entry.ts b/packages/loopover-miner/lib/hosted-entry.ts index c7339ea04a..ab5cb9acd7 100644 --- a/packages/loopover-miner/lib/hosted-entry.ts +++ b/packages/loopover-miner/lib/hosted-entry.ts @@ -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 @@ -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)] }); diff --git a/packages/loopover-miner/lib/tenant-credential-resolution.ts b/packages/loopover-miner/lib/tenant-credential-resolution.ts new file mode 100644 index 0000000000..602ee5b347 --- /dev/null +++ b/packages/loopover-miner/lib/tenant-credential-resolution.ts @@ -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 { + 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, + fetchImpl: typeof fetch = fetch, +): Promise { + 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; + } +} diff --git a/test/unit/miner-hosted-entry.test.ts b/test/unit/miner-hosted-entry.test.ts index ce54e7d4b3..ae631b2159 100644 --- a/test/unit/miner-hosted-entry.test.ts +++ b/test/unit/miner-hosted-entry.test.ts @@ -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) => 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"); @@ -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(); + }); + }); }); diff --git a/test/unit/miner-tenant-credential-resolution.test.ts b/test/unit/miner-tenant-credential-resolution.test.ts new file mode 100644 index 0000000000..de920106dc --- /dev/null +++ b/test/unit/miner-tenant-credential-resolution.test.ts @@ -0,0 +1,130 @@ +// Tests for the AMS half of #8202's secret-bootstrap mechanism (#8246). Mirrors +// test/unit/orb-broker-client.test.ts's own fetchBrokeredStoredSecret coverage closely -- this file is a +// deliberate duplicate of that exchange logic (see tenant-credential-resolution.ts's header for why), so its +// test coverage should read the same way. Named "credential" rather than "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 +// still say "Secret", matching src/orb/broker-client.ts's own naming for the function this duplicates. +import { describe, expect, it, vi } from "vitest"; +import { fetchTenantSecret, resolveTenantSecret } from "../../packages/loopover-miner/lib/tenant-credential-resolution"; + +/** A fetch stub that records the URL + init and returns a fixed response. */ +function captureFetch(resp: Response): { fetchImpl: typeof fetch; calls: { url: string; init?: RequestInit | undefined }[] } { + const calls: { url: string; init?: RequestInit | undefined }[] = []; + const fetchImpl = (async (url: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return resp; + }) as typeof fetch; + return { fetchImpl, calls }; +} + +describe("fetchTenantSecret (#8246)", () => { + it("exchanges the tenant secret token for a stored secret (default broker URL + Bearer token)", async () => { + const { fetchImpl, calls } = captureFetch(Response.json({ secretValue: "postgres://tenant-acme:hunter2@neon/acme", secretType: "tenant_db_credential" })); + const out = await fetchTenantSecret({ LOOPOVER_TENANT_SECRET_TOKEN: "orbsec_x" }, fetchImpl); + expect(out).toEqual({ secretValue: "postgres://tenant-acme:hunter2@neon/acme", secretType: "tenant_db_credential" }); + expect(calls[0]?.url).toBe("https://api.loopover.ai/v1/orb/token"); + expect((calls[0]?.init?.headers as Record).authorization).toBe("Bearer orbsec_x"); + expect(calls[0]?.init?.method).toBe("POST"); + }); + + it("respects a custom ORB_BROKER_URL", async () => { + const { fetchImpl, calls } = captureFetch(Response.json({ secretValue: "v", secretType: "tenant_db_credential" })); + await fetchTenantSecret({ LOOPOVER_TENANT_SECRET_TOKEN: "s", ORB_BROKER_URL: "https://broker.example/" }, fetchImpl); + expect(calls[0]?.url).toBe("https://broker.example/v1/orb/token"); + }); + + it("rejects unsafe broker URLs (same validation as broker-client.ts's own orbBrokerBaseUrl)", async () => { + const fetchImpl = (async () => { + throw new Error("fetch should not be called for an unsafe broker URL"); + }) as typeof fetch; + await expect(fetchTenantSecret({ LOOPOVER_TENANT_SECRET_TOKEN: "s", ORB_BROKER_URL: "http://broker.example" }, fetchImpl)).rejects.toThrow(/must use https/); + await expect(fetchTenantSecret({ LOOPOVER_TENANT_SECRET_TOKEN: "s", ORB_BROKER_URL: "https://user:pass@broker.example" }, fetchImpl)).rejects.toThrow(/userinfo/); + await expect(fetchTenantSecret({ LOOPOVER_TENANT_SECRET_TOKEN: "s", ORB_BROKER_URL: "https://broker.example?redirect=evil" }, fetchImpl)).rejects.toThrow(/query string or fragment/); + await expect(fetchTenantSecret({ LOOPOVER_TENANT_SECRET_TOKEN: "s", ORB_BROKER_URL: "not a url" }, fetchImpl)).rejects.toThrow(/valid URL/); + }); + + it("allows an explicit localhost HTTP broker URL for development only", async () => { + const { fetchImpl, calls } = captureFetch(Response.json({ secretValue: "v", secretType: "t" })); + await fetchTenantSecret({ LOOPOVER_TENANT_SECRET_TOKEN: "s", ORB_BROKER_URL: "http://localhost:8787" }, fetchImpl); + expect(calls[0]?.url).toBe("http://localhost:8787/v1/orb/token"); + }); + + it("preserves and trails a non-root base path instead of collapsing it (only an exact '/' collapses to empty)", async () => { + const { fetchImpl, calls } = captureFetch(Response.json({ secretValue: "v", secretType: "t" })); + await fetchTenantSecret({ LOOPOVER_TENANT_SECRET_TOKEN: "s", ORB_BROKER_URL: "https://broker.example/orb/" }, fetchImpl); + expect(calls[0]?.url).toBe("https://broker.example/orb/v1/orb/token"); + }); + + it("sends an empty Bearer when no token is set (defensive ?? branch)", async () => { + const { fetchImpl, calls } = captureFetch(Response.json({ secretValue: "v", secretType: "t" })); + await fetchTenantSecret({}, fetchImpl); + expect((calls[0]?.init?.headers as Record).authorization).toBe("Bearer "); + }); + + it("defaults secretType to an empty string when the broker response omits it (defensive ?? branch)", async () => { + const { fetchImpl } = captureFetch(Response.json({ secretValue: "v" })); + const out = await fetchTenantSecret({ LOOPOVER_TENANT_SECRET_TOKEN: "s" }, fetchImpl); + expect(out).toEqual({ secretValue: "v", secretType: "" }); + }); + + it("throws on a non-OK broker response (e.g. 401 invalid_enrollment)", async () => { + const fetchImpl = (async () => new Response("nope", { status: 401 })) as typeof fetch; + await expect(fetchTenantSecret({ LOOPOVER_TENANT_SECRET_TOKEN: "s" }, fetchImpl)).rejects.toThrow(/401/); + }); + + it("throws when the broker response has no secretValue", async () => { + const fetchImpl = (async () => Response.json({ secretType: "tenant_db_credential" })) as typeof fetch; + await expect(fetchTenantSecret({ LOOPOVER_TENANT_SECRET_TOKEN: "s" }, fetchImpl)).rejects.toThrow(/did not include a secretValue/); + }); +}); + +describe("resolveTenantSecret (#8246)", () => { + it("returns null without ever calling fetch when LOOPOVER_TENANT_SECRET_TOKEN is unset (self-host/unprovisioned, the common case)", async () => { + const fetchImpl = vi.fn(); + const out = await resolveTenantSecret({}, fetchImpl as unknown as typeof fetch); + expect(out).toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("returns null without calling fetch when the token is present but blank", async () => { + const fetchImpl = vi.fn(); + const out = await resolveTenantSecret({ LOOPOVER_TENANT_SECRET_TOKEN: " " }, fetchImpl as unknown as typeof fetch); + expect(out).toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("returns the real exchanged secret when a token is set and the exchange succeeds", async () => { + const { fetchImpl } = captureFetch(Response.json({ secretValue: "postgres://tenant-acme@neon/acme", secretType: "tenant_db_credential" })); + const out = await resolveTenantSecret({ LOOPOVER_TENANT_SECRET_TOKEN: "orbsec_x" }, fetchImpl); + expect(out).toEqual({ secretValue: "postgres://tenant-acme@neon/acme", secretType: "tenant_db_credential" }); + }); + + it("swallows an exchange failure, warn-logs it, and returns null instead of throwing -- a broker outage must never block a scheduled cycle", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const fetchImpl = (async () => new Response("nope", { status: 503 })) as typeof fetch; + + const out = await resolveTenantSecret({ LOOPOVER_TENANT_SECRET_TOKEN: "orbsec_x" }, fetchImpl); + + expect(out).toBeNull(); + expect(warn).toHaveBeenCalledTimes(1); + const logged = JSON.parse((warn.mock.calls[0] as [string])[0]) as { event: string; message: string }; + expect(logged.event).toBe("ams_tenant_secret_resolve_failed"); + expect(logged.message).toMatch(/503/); + warn.mockRestore(); + }); + + it("swallows a non-Error rejection from the exchange too", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const fetchImpl = (async () => { + throw "a plain string rejection"; + }) as unknown as typeof fetch; + + const out = await resolveTenantSecret({ LOOPOVER_TENANT_SECRET_TOKEN: "orbsec_x" }, fetchImpl); + + expect(out).toBeNull(); + const logged = JSON.parse((warn.mock.calls[0] as [string])[0]) as { message: string }; + expect(logged.message).toBe("a plain string rejection"); + warn.mockRestore(); + }); +});