diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2b659d8cf..2c8dabc78 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -68,6 +68,12 @@ jobs: # fail on a Windows runner for reasons unrelated to these fixes. # The wiki-worker suites mock execFileSync entirely (no real spawning, # no POSIX-only paths), so they run cleanly on windows-latest. + # + # client-os.test.ts is here for a different reason: it is the only suite + # whose result DEPENDS on running on real Windows. It asserts the unstubbed + # process.platform maps to the OS name the backend records, so this leg is + # what proves win32 -> "windows" on a genuine Windows machine instead of a + # redefined property (PLA-498). name: Windows smoke (spawn + hook dedup + wiki-worker) runs-on: windows-latest steps: @@ -88,7 +94,7 @@ jobs: run: npm install - name: Run Windows-relevant suites - run: npx vitest run tests/shared/spawn-detached.test.ts tests/cli/install-helpers.test.ts tests/codex/codex-wiki-worker.test.ts tests/cursor/cursor-wiki-worker.test.ts tests/pi/pi-wiki-worker.test.ts tests/hermes/hermes-wiki-worker.test.ts + run: npx vitest run tests/shared/spawn-detached.test.ts tests/cli/install-helpers.test.ts tests/codex/codex-wiki-worker.test.ts tests/cursor/cursor-wiki-worker.test.ts tests/pi/pi-wiki-worker.test.ts tests/hermes/hermes-wiki-worker.test.ts tests/claude-code/client-os.test.ts test: name: Typecheck and Test diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 5990c6524..9fc8d6446 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -5,6 +5,7 @@ import { deeplakeClientHeader } from "../utils/client-header.js"; import { hivemindInstallIDHeader } from "./install-id.js"; +import { hivemindOsHeader } from "../utils/client-os.js"; import { openInBrowser } from "../dashboard/open.js"; import { type Credentials, @@ -59,6 +60,7 @@ async function apiGet(path: string, token: string, apiUrl: string, orgId?: strin Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...deeplakeClientHeader(), + ...hivemindOsHeader(), }; if (orgId) headers["X-Activeloop-Org-Id"] = orgId; const resp = await fetch(`${apiUrl}${path}`, { headers }); @@ -71,6 +73,7 @@ async function apiPost(path: string, body: unknown, token: string, apiUrl: strin Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...deeplakeClientHeader(), + ...hivemindOsHeader(), }; if (orgId) headers["X-Activeloop-Org-Id"] = orgId; const resp = await fetch(`${apiUrl}${path}`, { method: "POST", headers, body: JSON.stringify(body) }); @@ -83,6 +86,7 @@ async function apiDelete(path: string, token: string, apiUrl: string, orgId?: st Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...deeplakeClientHeader(), + ...hivemindOsHeader(), }; if (orgId) headers["X-Activeloop-Org-Id"] = orgId; const resp = await fetch(`${apiUrl}${path}`, { method: "DELETE", headers }); @@ -115,6 +119,7 @@ export async function requestDeviceCode(apiUrl = DEFAULT_API_URL, ref?: string): headers: { "Content-Type": "application/json", ...deeplakeClientHeader(), + ...hivemindOsHeader(), ...hivemindInstallIDHeader(), ...hivemindReferrerHeader(ref), ...signupFlowHeader(), @@ -130,6 +135,7 @@ export async function pollForToken(deviceCode: string, apiUrl = DEFAULT_API_URL) headers: { "Content-Type": "application/json", ...deeplakeClientHeader(), + ...hivemindOsHeader(), ...hivemindInstallIDHeader(), // The backend resolves/creates the user on this poll (trackDeviceFlowAuth), // so the flow header must ride along here too — the /auth/device/code diff --git a/src/utils/client-os.ts b/src/utils/client-os.ts new file mode 100644 index 000000000..6d087a557 --- /dev/null +++ b/src/utils/client-os.ts @@ -0,0 +1,50 @@ +/** + * X-Hivemind-OS header helper. + * + * The deeplake-api backend records the client OS on the signup analytics + * events and forwards it to the marketing CRM, so "did this lead install on + * Windows?" has an answer. `process.platform` is the only truthful source: + * the server would see its own GOOS, and the CLI's HTTP client sends no OS + * token in its User-Agent. + * + * Normalized here so the wire value is already the vocabulary the CRM reads. + * The backend normalizes again on ingest (normalizeOS in auth_analytics.go) + * because two other producers exist and disagree — hivemind.ps1 sends + * "Windows" and hivemind.sh sends raw `uname -s` — so the canonical mapping + * has to live at the backend boundary regardless. Sending the canonical name + * from here keeps this CLI from being a third dialect. + * + * Platforms we do not ship an installer for omit the header entirely — an + * absent property is more honest than a bucket named "other", and it matches + * how the install-id header degrades. + * + * Header, not a dimension on X-Deeplake-Client: that header's value is a + * parsed contract (product, optionally product/version) and overloading it + * would break ParseClientHeader on the backend. + */ + +export const HIVEMIND_OS_HEADER = "X-Hivemind-OS"; + +// Node platform -> the vocabulary the backend allowlists. Deliberately only +// the three we publish install routes for (hivemind.sh, hivemind.ps1). +const OS_NAMES: Record = { + darwin: "macos", + win32: "windows", + linux: "linux", +}; + +/** Returns "macos" | "windows" | "linux", or "" on any other platform. */ +export function hivemindOsValue(): string { + return OS_NAMES[process.platform] ?? ""; +} + +/** + * Returns `{ "X-Hivemind-OS": "" }` for spreading into a headers object, + * or `{}` on a platform we do not ship for. Same shape and same + * never-throws contract as deeplakeClientHeader() / hivemindInstallIDHeader(). + */ +export function hivemindOsHeader(): Record { + const os = hivemindOsValue(); + if (!os) return {}; + return { [HIVEMIND_OS_HEADER]: os }; +} diff --git a/tests/claude-code/auth.test.ts b/tests/claude-code/auth.test.ts index f7148fce4..2426ac882 100644 --- a/tests/claude-code/auth.test.ts +++ b/tests/claude-code/auth.test.ts @@ -15,6 +15,7 @@ const fetchMock = vi.fn(); const saveCredentialsMock = vi.fn(); const loadCredentialsMock = vi.fn(); const installIDHeaderMock = vi.fn(); +const osHeaderMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); vi.mock("../../src/commands/auth-creds.js", () => ({ @@ -32,6 +33,12 @@ vi.mock("../../src/utils/client-header.js", () => ({ vi.mock("../../src/commands/install-id.js", () => ({ hivemindInstallIDHeader: () => installIDHeaderMock(), })); +// Mock the OS header for the same reason as install-id: the real helper reads +// process.platform, so without this every assertion below would depend on which +// machine CI runs on. client-os.test.ts covers the real mapping. +vi.mock("../../src/utils/client-os.js", () => ({ + hivemindOsHeader: () => osHeaderMock(), +})); async function importAuth() { vi.resetModules(); @@ -47,6 +54,8 @@ beforeEach(() => { saveCredentialsMock.mockReset(); loadCredentialsMock.mockReset(); installIDHeaderMock.mockReset(); + osHeaderMock.mockReset(); + osHeaderMock.mockReturnValue({}); // Default: install-id helper returns the empty object, so the header // is omitted (matches the graceful-degradation path). Individual tests // override this to assert the happy path. @@ -110,6 +119,31 @@ describe("requestDeviceCode", () => { expect(init.headers["X-Hivemind-Install-Id"]).toBe("uuid-from-helper"); }); + it("includes X-Hivemind-OS header on the device-code request", async () => { + osHeaderMock.mockReturnValueOnce({ "X-Hivemind-OS": "windows" }); + fetchMock.mockResolvedValueOnce( + ok({ device_code: "d", user_code: "u", verification_uri: "v", verification_uri_complete: "vc", expires_in: 1, interval: 1 }), + ); + const { requestDeviceCode } = await importAuth(); + await requestDeviceCode("https://api.example"); + const init = fetchMock.mock.calls[0][1]; + expect(init.headers["X-Hivemind-OS"]).toBe("windows"); + }); + + it("omits X-Hivemind-OS on an unsupported platform without dropping the other headers", async () => { + osHeaderMock.mockReturnValueOnce({}); + installIDHeaderMock.mockReturnValueOnce({ "X-Hivemind-Install-Id": "uuid-x" }); + fetchMock.mockResolvedValueOnce( + ok({ device_code: "d", user_code: "u", verification_uri: "v", verification_uri_complete: "vc", expires_in: 1, interval: 1 }), + ); + const { requestDeviceCode } = await importAuth(); + await requestDeviceCode("https://api.example"); + const init = fetchMock.mock.calls[0][1]; + expect(init.headers["X-Hivemind-OS"]).toBeUndefined(); + expect(init.headers["X-Hivemind-Install-Id"]).toBe("uuid-x"); + expect(init.headers["X-Deeplake-Client"]).toBe("hivemind/test"); + }); + it("omits X-Hivemind-Install-Id header when install-id helper returns empty (graceful-degrade path)", async () => { // beforeEach default already sets installIDHeaderMock to return {} — make it explicit here for clarity. installIDHeaderMock.mockReturnValueOnce({}); @@ -230,6 +264,24 @@ describe("listOrgs / listWorkspaces", () => { expect(init.method).toBeUndefined(); }); + // Regression guard for why the OS header rides the authenticated helpers at + // all. signup_completed fires from whichever request provisions the user: if + // that is not /auth/device/token, it is the CLI's first authenticated call, + // which is GET /me inside saveCredentialsFromToken (listOrgs follows it). + // Both go through apiGet, so asserting on listOrgs covers the shared helper — + // but if /me ever moves to its own fetch, it must carry the header too, or it + // consumes the one-time signup capture without an OS and nothing later can + // repair it. + it("apiGet forwards X-Hivemind-OS so a middleware-path signup still sees it", async () => { + osHeaderMock.mockReturnValueOnce({ "X-Hivemind-OS": "macos" }); + fetchMock.mockResolvedValueOnce(ok([{ id: "o1", name: "acme" }])); + const { listOrgs } = await importAuth(); + await listOrgs("tok", "https://api.example"); + const init = fetchMock.mock.calls[0][1]; + expect(init.headers["X-Hivemind-OS"]).toBe("macos"); + expect(init.headers.Authorization).toBe("Bearer tok"); + }); + it("listOrgs returns [] when API gives a non-array body", async () => { fetchMock.mockResolvedValueOnce(ok({ unexpected: true })); const { listOrgs } = await importAuth(); @@ -745,6 +797,25 @@ describe("saveCredentialsFromToken — org-pinning", () => { delete process.env.HIVEMIND_ORG_ID; }); + // Pins the request that actually matters. GET /me is the CLI's FIRST + // authenticated call, so it is the one that can trigger the middleware's + // one-time signup capture. If /me ever moves off apiGet to its own fetch + // without the OS header, it consumes that capture with no OS and no later + // request can repair it — this asserts on /me by URL, not on the helper. + it("sends X-Hivemind-OS on the first authenticated call, GET /me", async () => { + osHeaderMock.mockReturnValue({ "X-Hivemind-OS": "windows" }); + const token = makeToken({ org_id: "o1", user_id: "u1" }); + fetchMock + .mockResolvedValueOnce(ok({ id: "u1", name: "Alice" })) + .mockResolvedValueOnce(ok([{ id: "o1", name: "acme" }])); + const { saveCredentialsFromToken } = await importAuth(); + await saveCredentialsFromToken(token, "https://api.example", { skipTokenMint: true }); + + const [meUrl, meInit] = fetchMock.mock.calls[0]; + expect(meUrl).toBe("https://api.example/me"); + expect(meInit.headers["X-Hivemind-OS"]).toBe("windows"); + }); + it("skipTokenMint=true honors the org_id claim from the token JWT (multi-org user)", async () => { const token = makeToken({ org_id: "o2", user_id: "u1" }); fetchMock diff --git a/tests/claude-code/client-os.test.ts b/tests/claude-code/client-os.test.ts new file mode 100644 index 000000000..65c53990f --- /dev/null +++ b/tests/claude-code/client-os.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { hivemindOsValue, hivemindOsHeader, HIVEMIND_OS_HEADER } from "../../src/utils/client-os.js"; + +/** + * Source-level tests for src/utils/client-os.ts. + * + * process.platform is a read-only accessor, so each case redefines it and the + * afterEach restores the real descriptor. The module reads process.platform + * per call (no module-level capture), which is what makes this work against a + * single static import — same reason install-id.ts resolves its paths lazily. + */ + +const REAL_PLATFORM = Object.getOwnPropertyDescriptor(process, "platform")!; +// Captured before any stubbing, so the real-platform test below cannot be +// fooled by a leaked redefinition from an earlier case. +const REAL_PLATFORM_NAME = process.platform; + +function setPlatform(value: string): void { + Object.defineProperty(process, "platform", { value, configurable: true }); +} + +afterEach(() => { + Object.defineProperty(process, "platform", REAL_PLATFORM); +}); + +describe("hivemindOsValue", () => { + it("maps the three platforms we publish an installer for", () => { + setPlatform("darwin"); + expect(hivemindOsValue()).toBe("macos"); + setPlatform("win32"); + expect(hivemindOsValue()).toBe("windows"); + setPlatform("linux"); + expect(hivemindOsValue()).toBe("linux"); + }); + + it("returns empty on a platform we do not ship for, rather than inventing a bucket", () => { + setPlatform("freebsd"); + expect(hivemindOsValue()).toBe(""); + setPlatform("aix"); + expect(hivemindOsValue()).toBe(""); + }); + + // Asserted value by value, not "is it in the allowlist": the loose form passes + // even when a platform maps to the WRONG allowed name — sunos returning + // "linux" would look fine — and the backend would then record a confident lie. + it("maps every platform to its exact value, supported or not", () => { + for (const [platform, want] of Object.entries({ + darwin: "macos", + win32: "windows", + linux: "linux", + freebsd: "", + sunos: "", + android: "", + })) { + setPlatform(platform); + expect(hivemindOsValue()).toBe(want); + } + }); +}); + +// The only assertion here that runs against a REAL machine rather than a +// redefined property. It is why this file is in ci.yaml's windows-smoke suite +// list: on windows-latest it proves win32 -> "windows" on genuine Windows, +// which is the CLI half of what PLA-498's acceptance asks for and what we +// otherwise had no machine to check. +describe("the platform this process actually runs on", () => { + it("maps the real platform, unstubbed", () => { + const expected: Record = { darwin: "macos", win32: "windows", linux: "linux" }; + expect(hivemindOsValue()).toBe(expected[REAL_PLATFORM_NAME] ?? ""); + }); + + // toBeTruthy() would accept any non-empty string, so on the Windows runner the + // header could read "macos" and this would still pass — defeating the only + // reason this file runs on windows-latest at all. + it("emits the canonical value for the real platform", () => { + const expected: Record = { darwin: "macos", win32: "windows", linux: "linux" }; + const want = expected[REAL_PLATFORM_NAME]; + if (!want) { + expect(hivemindOsHeader()).toEqual({}); + return; + } + expect(hivemindOsHeader()[HIVEMIND_OS_HEADER]).toBe(want); + }); +}); + +describe("hivemindOsHeader", () => { + it("returns a spreadable single-entry object on a supported platform", () => { + setPlatform("win32"); + expect(hivemindOsHeader()).toEqual({ [HIVEMIND_OS_HEADER]: "windows" }); + }); + + it("returns {} on an unsupported platform so spreading omits the header entirely", () => { + setPlatform("freebsd"); + expect(hivemindOsHeader()).toEqual({}); + // Spreading {} must leave a headers object untouched — the graceful-degrade + // contract the install-id header follows. + expect({ "Content-Type": "application/json", ...hivemindOsHeader() }).toEqual({ + "Content-Type": "application/json", + }); + }); +});