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
8 changes: 7 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
Expand All @@ -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) });
Expand All @@ -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 });
Expand Down Expand Up @@ -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(),
Expand All @@ -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
Expand Down
50 changes: 50 additions & 0 deletions src/utils/client-os.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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": "<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<string, string> {
const os = hivemindOsValue();
if (!os) return {};
return { [HIVEMIND_OS_HEADER]: os };
}
71 changes: 71 additions & 0 deletions tests/claude-code/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand All @@ -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();
Expand All @@ -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.
Expand Down Expand Up @@ -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({});
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions tests/claude-code/client-os.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = { 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<string, string> = { 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",
});
});
});