From 222ad366a48a9eb6021ee90a150f6f949d5af419 Mon Sep 17 00:00:00 2001 From: khustup2 Date: Thu, 16 Jul 2026 17:13:01 +0000 Subject: [PATCH 1/2] fix(dir-config): route memory reads through .hivemind, not just capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.hivemind` resolved correctly, but only the capture path consumed it — every read path (memory search, proactive recall, the VFS shell) called loadConfig() raw. A routed directory captured to its workspace while reading from the global one, and the SessionStart banner reported the routed identity regardless, so the misrouting presented as success. Route the three read paths through resolveDirConfig, and separate the two concerns the file expresses: - orgId / workspaceId are IDENTITY — reads and writes alike - collect is a CAPTURE switch — writes only collect:false previously short-circuited the whole overlay, silently dropping a workspaceId set alongside it. Splitting them makes "read this workspace, never write to it" expressible, and removes a config field that could be set and quietly ignored. whoami reported raw stored creds, so it misreported inside a routed directory — and, independently of this feature, under HIVEMIND_ORG_ID / HIVEMIND_WORKSPACE_ID as well. It now reports the effective identity for the cwd and discloses whether a .hivemind or the environment overrode the stored values, so it agrees with the banner instead of contradicting it. The resolver was fully unit-tested and correct; nothing asserted that any consumer used it, which is why this shipped green. The added tests drive a real read through the hook and assert the workspace it resolves to — they fail against the unfixed code. The MCP server (src/mcp/server.ts) is still unrouted: it is long-lived per-host and its process.cwd() does not track a session across directories, so it needs a different mechanism and is left for a follow-up. --- README.md | 19 ++- src/commands/auth-login.ts | 10 +- src/commands/whoami.ts | 63 +++++++ src/dir-config.ts | 19 ++- src/hooks/pre-tool-use.ts | 11 +- src/hooks/recall.ts | 8 +- src/hooks/session-start.ts | 12 +- src/shell/deeplake-shell.ts | 9 +- tests/claude-code/auth-login-dispatch.test.ts | 23 +++ tests/shared/dir-config-read-routing.test.ts | 158 ++++++++++++++++++ tests/shared/dir-config.test.ts | 11 +- 11 files changed, 317 insertions(+), 26 deletions(-) create mode 100644 src/commands/whoami.ts create mode 100644 tests/shared/dir-config-read-routing.test.ts diff --git a/README.md b/README.md index edef5218c..5370dabf3 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,9 @@ This plugin captures session activity and stores it in your Deeplake workspace: ## Per-directory config (`.hivemind`) -The variables above set **one global identity** for the whole machine. A `.hivemind` file lets a specific directory tree override that: either **route** its traces to a different org/workspace, or **opt out** of capture entirely. +The variables above set **one global identity** for the whole machine. A `.hivemind` file lets a specific directory tree override that: either **route** it to a different org/workspace, or **opt out** of capture entirely. + +Routing is symmetric — a routed directory both writes its traces to that workspace **and reads memory from it**. Sessions started under it search, recall, and browse `~/.deeplake/memory` in the routed workspace, and `hivemind whoami` reports it. Drop a `.hivemind` JSON file at the root of the tree you want to configure: @@ -341,22 +343,29 @@ Drop a `.hivemind` JSON file at the root of the tree you want to configure: | Field | Effect | |---------------|-------------------------------------------------------------------------------| -| `orgId` | Route captured traces from this tree to this org. | +| `orgId` | Route this tree to this org — captured traces **and** memory reads. | | `workspaceId` | Route to this workspace. | -| `collect` | `false` → **never** capture traces from this tree. | +| `collect` | `false` → **never** capture traces from this tree. Reads still route. | Any field may be omitted; omitted fields fall back to your global identity. -**Two common recipes:** +`orgId` / `workspaceId` are **identity** (they apply to reads and writes alike); `collect` is a **capture switch** (writes only). The two are independent, which is what makes the read-only recipe below work. + +**Three common recipes:** ```jsonc -// route this repo's traces to a client org/workspace +// route this repo to a client org/workspace — reads and writes both land there { "orgId": "acme-corp", "workspaceId": "client-work" } // never collect traces from this folder (e.g. a personal or sensitive repo) { "collect": false } + +// read a shared workspace's memory, but never write to it +{ "workspaceId": "client-work", "collect": false } ``` +Routing never carries a token — auth stays in `~/.deeplake/credentials.json`, so a `.hivemind` only ever takes effect against orgs your existing login already authorizes. An `HIVEMIND_ORG_ID` / `HIVEMIND_WORKSPACE_ID` set in your environment **wins over** a `.hivemind` for that field; `hivemind whoami` discloses which one is in effect. + ### Committed vs local Two filenames are recognized, mirroring the `.env` / `.env.local` convention every dev already knows: diff --git a/src/commands/auth-login.ts b/src/commands/auth-login.ts index d65ca5fab..33f67f03f 100644 --- a/src/commands/auth-login.ts +++ b/src/commands/auth-login.ts @@ -25,6 +25,8 @@ import { inviteMember, listMembers, removeMember, } from "./auth.js"; import { sessionPrune } from "./session-prune.js"; +import { loadConfig } from "../config.js"; +import { renderWhoami } from "./whoami.js"; /** * Dispatch one auth subcommand. @@ -48,9 +50,11 @@ export async function runAuthCommand(args: string[]): Promise { case "whoami": { if (!creds) { console.log("Not logged in. Run: hivemind login"); break; } - console.log(`User org: ${creds.orgName ?? creds.orgId}`); - console.log(`Workspace: ${creds.workspaceId ?? "default"}`); - console.log(`API: ${creds.apiUrl ?? "https://api.deeplake.ai"}`); + // Report the EFFECTIVE identity for the cwd, not the raw stored creds: + // env vars and a per-directory `.hivemind` both override them, and this + // is the surface users (and agents) ask "what am I connected to?". + // Reading creds directly here made `whoami` misreport under either. + console.log(renderWhoami(loadConfig(), creds, process.cwd())); break; } diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts new file mode 100644 index 000000000..08fa9cc60 --- /dev/null +++ b/src/commands/whoami.ts @@ -0,0 +1,63 @@ +/** + * `hivemind whoami` rendering. + * + * Reports the EFFECTIVE identity for a given cwd — the one capture and memory + * search actually use — rather than the raw contents of + * `~/.deeplake/credentials.json`. Two things override the stored creds: + * + * - `HIVEMIND_ORG_ID` / `HIVEMIND_WORKSPACE_ID` in the environment + * - the nearest `.hivemind` for this directory (see src/dir-config.ts) + * + * Whenever the effective identity differs from what's stored, the reason and + * the stored values are both disclosed — a user asking "what am I connected + * to?" must never be told a value that isn't the one in use. + */ + +import type { Config } from "../config.js"; +import type { Credentials } from "./auth.js"; +import { resolveDirConfig } from "../dir-config.js"; + +const DEFAULT_API = "https://api.deeplake.ai"; + +export function renderWhoami(config: Config | null, creds: Credentials, cwd: string): string { + const storedOrg = creds.orgName ?? creds.orgId; + const storedWs = creds.workspaceId ?? "default"; + + // No usable Config (no token/orgId resolvable) — report what's stored. + if (!config) { + return [ + `User org: ${storedOrg}`, + `Workspace: ${storedWs}`, + `API: ${creds.apiUrl ?? DEFAULT_API}`, + ].join("\n"); + } + + const res = resolveDirConfig(config, cwd); + const eff = res.config; + + // Attribute each override precisely: `config` has already folded the env in, + // so a diff against `creds` is the env's doing, and a diff between `eff` and + // `config` is the .hivemind's. + const envMoved = config.orgId !== creds.orgId || config.workspaceId !== storedWs; + const dirMoved = !!res.found && + (eff.orgId !== config.orgId || eff.workspaceId !== config.workspaceId); + + const lines = [ + `User org: ${eff.orgName ?? eff.orgId}`, + `Workspace: ${eff.workspaceId}`, + `API: ${eff.apiUrl}`, + ]; + + const notes: string[] = []; + if (dirMoved) notes.push(`Routed by ${res.found?.path}`); + if (envMoved) notes.push("Overridden by HIVEMIND_* environment variables"); + if (notes.length) { + notes.push(`Stored identity: ${storedOrg} / ${storedWs}`); + } + if (res.found && !res.collect) { + notes.push(`Capture: disabled for this directory by ${res.found.path}`); + } + if (notes.length) lines.push("", ...notes); + + return lines.join("\n"); +} diff --git a/src/dir-config.ts b/src/dir-config.ts index 784a6831c..e582cda4a 100644 --- a/src/dir-config.ts +++ b/src/dir-config.ts @@ -103,8 +103,18 @@ export interface ResolvedDirConfig { /** * Overlay the nearest `.hivemind` onto `base` for a session in `cwd`. - * `collect: false` suppresses capture; org/workspace fields (when present) - * redirect it. Omitted fields fall back to the global identity in `base`. + * + * The two concerns are INDEPENDENT: + * - `orgId` / `workspaceId` are IDENTITY — they apply to reads (memory + * search, recall, the VFS) as well as capture. Omitted fields fall back to + * the global identity in `base`. + * - `collect` is the CAPTURE switch — writes only. It never suppresses the + * identity overlay, so `{ "collect": false, "workspaceId": "x" }` reads + * from `x` while writing nothing. (Reads are still authorized by the + * caller's existing token; the API rejects anything it doesn't grant.) + * + * Callers on the capture path must therefore gate on `collect`; callers on a + * read path use `config` unconditionally. * * Precedence follows the conventional `env > config-file > stored-creds` order: * an explicitly-set `HIVEMIND_ORG_ID` / `HIVEMIND_WORKSPACE_ID` LOCKS that field @@ -125,9 +135,6 @@ export function resolveDirConfig( const found = findDirConfig(cwd); if (!found) return { config: base, collect: true, found: null }; - if (found.raw.collect === false) { - return { config: base, collect: false, found }; - } const orgLocked = !!(envOverride ? envOverride.HIVEMIND_ORG_ID : process.env.HIVEMIND_ORG_ID); const wsLocked = !!(envOverride ? envOverride.HIVEMIND_WORKSPACE_ID : process.env.HIVEMIND_WORKSPACE_ID); const config: Config = { @@ -136,5 +143,5 @@ export function resolveDirConfig( orgName: orgLocked ? base.orgName : (found.raw.orgName ?? found.raw.orgId ?? base.orgName), workspaceId: wsLocked ? base.workspaceId : (found.raw.workspaceId ?? base.workspaceId), }; - return { config, collect: true, found }; + return { config, collect: found.raw.collect !== false, found }; } diff --git a/src/hooks/pre-tool-use.ts b/src/hooks/pre-tool-use.ts index e2944503e..363affed9 100644 --- a/src/hooks/pre-tool-use.ts +++ b/src/hooks/pre-tool-use.ts @@ -7,6 +7,7 @@ import { join, dirname, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { readStdin } from "../utils/stdin.js"; import { loadConfig } from "../config.js"; +import { resolveDirConfig } from "../dir-config.js"; import { armSkillOptOnSkillUse } from "./shared/skillopt-hook.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { sqlLike } from "../utils/sql.js"; @@ -266,7 +267,7 @@ interface ClaudePreToolDeps { export async function processPreToolUse(input: PreToolUseInput, deps: ClaudePreToolDeps = {}): Promise { const { - config = loadConfig(), + config: baseConfig = loadConfig(), createApi = (table, activeConfig) => new DeeplakeApi( activeConfig.token, activeConfig.apiUrl, @@ -322,7 +323,13 @@ export async function processPreToolUse(input: PreToolUseInput, deps: ClaudePreT // unreachable. Do NOT return null here — that hands the original command to // the host shell. Return the retry guidance instead so the command never // touches the real filesystem. - if (!config) return buildRetryGuidanceDecision(input.tool_name); + if (!baseConfig) return buildRetryGuidanceDecision(input.tool_name); + + // Reads are routed by the nearest `.hivemind` exactly like capture is: a + // directory pinned to another org/workspace must SEE that workspace's memory, + // not the global one. `collect` is a capture switch and deliberately does not + // gate reads (see src/dir-config.ts). + const config = resolveDirConfig(baseConfig, input.cwd ?? process.cwd()).config; const table = process.env["HIVEMIND_TABLE"] ?? "memory"; const sessionsTable = process.env["HIVEMIND_SESSIONS_TABLE"] ?? "sessions"; diff --git a/src/hooks/recall.ts b/src/hooks/recall.ts index 469c18d07..e8339ad02 100644 --- a/src/hooks/recall.ts +++ b/src/hooks/recall.ts @@ -29,6 +29,7 @@ import { readStdin } from "../utils/stdin.js"; import { loadConfig } from "../config.js"; +import { resolveDirConfig } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { EmbedClient } from "../embeddings/client.js"; import { embedSummaryWithWarmup } from "../embeddings/embed-summary.js"; @@ -198,12 +199,15 @@ async function main(): Promise { if (!recall) { log(`skip gate=${reason}`); return; } const session = input.session_id; - const config = loadConfig(); - if (!config?.token) { + const baseConfig = loadConfig(); + if (!baseConfig?.token) { log("skip no-config"); recordRecallEvent({ event: "no-config", gate: reason, session }); return; } + // Route recall by the nearest `.hivemind`, like capture and memory search: + // a routed directory must recall from ITS workspace, not the global one. + const config = resolveDirConfig(baseConfig, input.cwd ?? process.cwd()).config; // Bound the whole search path so the turn never stalls beyond the budget. // On timeout we ABORT the controller so the in-flight query is cancelled diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index cc71ec927..f0d91c330 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -333,13 +333,19 @@ async function main(): Promise { // Disclose the EFFECTIVE identity (after any `.hivemind` overlay), so a // directory that routes elsewhere (or opts out) is never silent. const effConfig = dirRes?.config ?? baseConfig; - const routed = !!(dirRes?.found && dirRes.collect && baseConfig && + // NOT gated on `dirRes.collect`: the identity overlay now applies to reads + // whether or not capture is on, so a `collect:false` directory can still be + // routed — and must say so. + const routed = !!(dirRes?.found && baseConfig && (dirRes.config.orgId !== baseConfig.orgId || dirRes.config.workspaceId !== baseConfig.workspaceId)); const effOrg = effConfig ? (effConfig.orgName ?? effConfig.orgId) : (creds?.orgName ?? creds?.orgId); const effWs = effConfig ? effConfig.workspaceId : (creds?.workspaceId ?? "default"); + // `routed` covers reads AND capture — both resolve through the same overlay — + // so the disclosure must never imply one moved without the other. + const routedNote = routed ? ` · routed by ${dirRes?.found?.path}` : ""; const identityLine = dirRes && !dirRes.collect - ? `Deeplake capture is disabled for this directory (${dirRes.found?.path}); memory search still uses org: ${effOrg}` - : `Logged in to Deeplake as org: ${effOrg} (workspace: ${effWs})${routed ? ` · routed by ${dirRes?.found?.path}` : ""}`; + ? `Deeplake capture is disabled for this directory (${dirRes.found?.path}); memory search uses org: ${effOrg} (workspace: ${effWs})${routedNote}` + : `Logged in to Deeplake as org: ${effOrg} (workspace: ${effWs})${routedNote}`; const baseContext = creds?.token ? `${resolvedContext}\n\n${identityLine}${updateNotice}` : `${resolvedContext}\n\nNot logged in to Deeplake; memory search is unavailable this session.${localMinedNote}${updateNotice}`; diff --git a/src/shell/deeplake-shell.ts b/src/shell/deeplake-shell.ts index 9b5ea0e14..43710635f 100644 --- a/src/shell/deeplake-shell.ts +++ b/src/shell/deeplake-shell.ts @@ -25,6 +25,7 @@ import { createInterface } from "node:readline"; import { deriveProjectKey } from "../utils/repo-identity.js"; import { Bash } from "just-bash"; import { loadConfig } from "../config.js"; +import { resolveDirConfig } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { DeeplakeFs } from "./deeplake-fs.js"; import { createGrepCommand } from "./grep-interceptor.js"; @@ -42,8 +43,8 @@ async function main(): Promise { delete process.env.HIVEMIND_DEBUG; } - const config = loadConfig(); - if (!config) { + const baseConfig = loadConfig(); + if (!baseConfig) { process.stderr.write( "Deeplake credentials not found.\n" + "Set HIVEMIND_TOKEN + HIVEMIND_ORG_ID in environment, or create ~/.deeplake/credentials.json\n" @@ -51,6 +52,10 @@ async function main(): Promise { process.exit(1); } + // The VFS resolves against the nearest `.hivemind` for the invoking cwd, so a + // routed directory browses ITS workspace's files rather than the global one. + const config = resolveDirConfig(baseConfig, process.cwd()).config; + const table = process.env["HIVEMIND_TABLE"] ?? "memory"; const sessionsTable = process.env["HIVEMIND_SESSIONS_TABLE"] ?? "sessions"; const goalsTable = process.env["HIVEMIND_GOALS_TABLE"] ?? config.goalsTableName; diff --git a/tests/claude-code/auth-login-dispatch.test.ts b/tests/claude-code/auth-login-dispatch.test.ts index 302e0e726..629f1f41b 100644 --- a/tests/claude-code/auth-login-dispatch.test.ts +++ b/tests/claude-code/auth-login-dispatch.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; */ const loadCredentialsMock = vi.fn(); +const loadConfigMock = vi.fn(); const loginMock = vi.fn(); const saveCredentialsMock = vi.fn(); const deleteCredentialsMock = vi.fn(); @@ -40,6 +41,13 @@ vi.mock("../../src/commands/auth.js", () => ({ vi.mock("../../src/commands/session-prune.js", () => ({ sessionPrune: (...a: unknown[]) => sessionPruneMock(...a), })); +// `whoami` reports the EFFECTIVE identity, so it resolves through loadConfig() +// (which folds in HIVEMIND_* env vars) rather than reading creds directly. +// Mock it at the boundary too — unmocked it would read the developer's real +// ~/.deeplake/credentials.json and make these assertions machine-dependent. +vi.mock("../../src/config.js", () => ({ + loadConfig: (...a: unknown[]) => loadConfigMock(...a), +})); const validCreds = { token: "tok", @@ -51,8 +59,20 @@ const validCreds = { savedAt: "2024-01-01", }; +/** What loadConfig() builds from validCreds when no env var overrides apply. */ +const validConfig = { + token: "tok", + orgId: "org-1", + orgName: "acme", + userName: "u", + workspaceId: "default", + apiUrl: "https://api.example", + tableName: "memory", +}; + beforeEach(() => { loadCredentialsMock.mockReset().mockReturnValue(validCreds); + loadConfigMock.mockReset().mockReturnValue(validConfig); loginMock.mockReset().mockResolvedValue(undefined); saveCredentialsMock.mockReset(); deleteCredentialsMock.mockReset().mockReturnValue(true); @@ -114,7 +134,10 @@ describe("runAuthCommand — whoami", () => { }); it("falls back to orgId when orgName is missing", async () => { + // loadConfig() owns this fallback (orgName: creds?.orgName ?? orgId), so a + // creds row without orgName reaches whoami as a config carrying orgId. loadCredentialsMock.mockReturnValue({ ...validCreds, orgName: undefined }); + loadConfigMock.mockReturnValue({ ...validConfig, orgName: "org-1" }); await run(["whoami"]); expect(consoleText()).toContain("User org: org-1"); }); diff --git a/tests/shared/dir-config-read-routing.test.ts b/tests/shared/dir-config-read-routing.test.ts new file mode 100644 index 000000000..9cd6e9347 --- /dev/null +++ b/tests/shared/dir-config-read-routing.test.ts @@ -0,0 +1,158 @@ +/** + * Reads must route through `.hivemind` exactly like capture does. + * + * The gap this covers: `resolveDirConfig` was fully unit-tested in isolation and + * correct, but no read path consumed it — every one called `loadConfig()` raw. + * A routed directory therefore captured to its workspace while reading from the + * global one, and the SessionStart banner reported the routed identity anyway. + * Unit-testing the resolver could never catch that; only asserting the CONSUMER + * can. These tests pin the wiring, not the resolver. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { processPreToolUse } from "../../src/hooks/pre-tool-use.js"; +import { renderWhoami } from "../../src/commands/whoami.js"; +import type { Config } from "../../src/config.js"; + +let root: string; + +const BASE_CONFIG = { + token: "test-token", + apiUrl: "https://api.test", + orgId: "global-org", + orgName: "global-org", + userName: "u", + workspaceId: "default", + tableName: "memory", + docsTableName: "hivemind_docs", +} as any as Config; + +function dir(...segs: string[]): string { + const p = join(root, ...segs); + mkdirSync(p, { recursive: true }); + return p; +} + +function writeHivemind(dirPath: string, body: unknown): void { + writeFileSync(join(dirPath, ".hivemind"), JSON.stringify(body)); +} + +/** Stub API: every read resolves to nothing — we assert on ROUTING, not rows. */ +function makeApi() { + return { query: vi.fn(async () => []) } as any; +} + +/** + * Drive a memory read through the hook and return the Config the VFS backend + * was actually constructed with. That config is the routing decision. + */ +async function configUsedForRead(cwd: string, base: Config = BASE_CONFIG): Promise { + const createApi = vi.fn((_table: string, _cfg: Config) => makeApi()); + await processPreToolUse( + { + session_id: "s-route", + tool_name: "Bash", + tool_input: { command: "cat ~/.deeplake/memory/index.md" }, + tool_use_id: "tu-route", + cwd, + }, + { + config: base, + createApi, + executeCompiledBashCommandFn: vi.fn(async () => null) as any, + readCachedIndexContentFn: () => null, + writeCachedIndexContentFn: () => undefined, + writeReadCacheFileFn: () => undefined, + logFn: () => undefined, + } as any, + ); + expect(createApi).toHaveBeenCalled(); + return createApi.mock.calls[0][1]; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "hivemind-read-routing-")); + delete process.env.HIVEMIND_ORG_ID; + delete process.env.HIVEMIND_WORKSPACE_ID; +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe("memory reads route through .hivemind", () => { + it("reads from the workspace the directory is pinned to, not the global one", async () => { + const proj = dir("proj"); + writeHivemind(proj, { workspaceId: "workspace2" }); + const used = await configUsedForRead(proj); + expect(used.workspaceId).toBe("workspace2"); + expect(used.orgId).toBe("global-org"); // org untouched + }); + + it("routes the org too, and inherits it from an ancestor directory", async () => { + writeHivemind(dir("proj"), { orgId: "acme", workspaceId: "client-work" }); + const used = await configUsedForRead(dir("proj", "svc", "deep")); + expect(used.orgId).toBe("acme"); + expect(used.workspaceId).toBe("client-work"); + }); + + it("falls back to the global identity when no .hivemind applies", async () => { + const used = await configUsedForRead(dir("plain")); + expect(used.orgId).toBe("global-org"); + expect(used.workspaceId).toBe("default"); + }); + + it("still routes reads under collect:false — collect gates writes, not reads", async () => { + const proj = dir("readonly"); + writeHivemind(proj, { workspaceId: "client-work", collect: false }); + const used = await configUsedForRead(proj); + expect(used.workspaceId).toBe("client-work"); + }); + + it("honors the HIVEMIND_WORKSPACE_ID lock on the read path too", async () => { + process.env.HIVEMIND_WORKSPACE_ID = "env-ws"; + const proj = dir("proj"); + writeHivemind(proj, { workspaceId: "workspace2" }); + // loadConfig() folds the env in upstream; base reflects that. + const used = await configUsedForRead(proj, { ...BASE_CONFIG, workspaceId: "env-ws" }); + expect(used.workspaceId).toBe("env-ws"); + }); +}); + +describe("whoami reports the identity actually in use", () => { + const creds = { token: "t", orgId: "global-org", orgName: "global-org", workspaceId: "default" } as any; + + it("reports the routed workspace and discloses the file that routed it", () => { + const proj = dir("proj"); + writeHivemind(proj, { workspaceId: "workspace2" }); + const out = renderWhoami(BASE_CONFIG, creds, proj); + expect(out).toContain("Workspace: workspace2"); + expect(out).toContain(join(proj, ".hivemind")); + expect(out).toContain("Stored identity: global-org / default"); + }); + + it("reports the stored identity plainly when nothing overrides it", () => { + const out = renderWhoami(BASE_CONFIG, creds, dir("plain")); + expect(out).toContain("Workspace: default"); + expect(out).not.toContain("Routed by"); + expect(out).not.toContain("Stored identity:"); + }); + + it("discloses an env override as env, not as a .hivemind routing", () => { + const out = renderWhoami({ ...BASE_CONFIG, workspaceId: "env-ws" }, creds, dir("plain")); + expect(out).toContain("Workspace: env-ws"); + expect(out).toContain("Overridden by HIVEMIND_* environment variables"); + expect(out).not.toContain("Routed by"); + }); + + it("surfaces a capture opt-out alongside the identity it still reads from", () => { + const proj = dir("proj"); + writeHivemind(proj, { workspaceId: "client-work", collect: false }); + const out = renderWhoami(BASE_CONFIG, creds, proj); + expect(out).toContain("Workspace: client-work"); + expect(out).toContain("Capture: disabled for this directory"); + }); +}); diff --git a/tests/shared/dir-config.test.ts b/tests/shared/dir-config.test.ts index 055178def..6cd468256 100644 --- a/tests/shared/dir-config.test.ts +++ b/tests/shared/dir-config.test.ts @@ -133,7 +133,7 @@ describe("resolveDirConfig", () => { expect(res.config.workspaceId).toBe("client-work"); }); - it("suppresses capture on collect:false without altering the config", () => { + it("suppresses capture on collect:false, leaving a bare config untouched", () => { write(dir("proj"), ".hivemind", { collect: false }); const res = resolveDirConfig(base(), dir("proj")); expect(res.collect).toBe(false); @@ -141,10 +141,15 @@ describe("resolveDirConfig", () => { expect(res.found?.path).toBe(join(root, "proj", ".hivemind")); }); - it("collect:false wins even when org/workspace are also present", () => { - write(dir("proj"), ".hivemind", { orgId: "acme", collect: false }); + it("collect:false suppresses capture but still applies the identity overlay", () => { + // `collect` governs WRITES only. org/workspace are identity and must still + // route reads — otherwise `{collect:false, orgId:...}` would silently drop + // orgId on the floor. "Read this workspace, never write to it" is valid. + write(dir("proj"), ".hivemind", { orgId: "acme", workspaceId: "client-work", collect: false }); const res = resolveDirConfig(base(), dir("proj")); expect(res.collect).toBe(false); + expect(res.config.orgId).toBe("acme"); + expect(res.config.workspaceId).toBe("client-work"); }); }); From 15d0dab95e2642c42cbaaa940421dbc62baf86be Mon Sep 17 00:00:00 2001 From: khustup2 Date: Thu, 16 Jul 2026 17:24:15 +0000 Subject: [PATCH 2/2] test(recall): cover per-directory routing on the recall path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The routing change added an `input.cwd ?? process.cwd()` branch to recall.ts but no test drove it — recall's routing shipped in the previous commit asserted only indirectly, and the per-file branch threshold (90%) caught it at 88.63%. Record the DeeplakeApi ctor args in the existing mock so a test can assert WHICH org/workspace recall searched, then cover the routing cases directly: pinned workspace, org+workspace inherited from an ancestor, collect:false still routing reads, the unrouted default, and the process.cwd() fallback when the payload omits cwd. Branch coverage for recall.ts: 88.63% -> 90.9%. --- tests/claude-code/recall-hook.test.ts | 71 ++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/tests/claude-code/recall-hook.test.ts b/tests/claude-code/recall-hook.test.ts index ec3064eba..0fdc44f8c 100644 --- a/tests/claude-code/recall-hook.test.ts +++ b/tests/claude-code/recall-hook.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; /** * Orchestration tests for src/hooks/recall.ts — the UserPromptSubmit proactive- @@ -18,6 +21,7 @@ const embeddingsDisabledMock = vi.fn(); const pluginEnabledMock = vi.fn(); const embedMock = vi.fn(); const queryMock = vi.fn(); +const apiCtorMock = vi.fn(); const debugLogMock = vi.fn(); const recordEventMock = vi.fn(); const selfHealMock = vi.fn(); @@ -33,7 +37,12 @@ vi.mock("../../src/embeddings/client.js", () => ({ EmbedClient: class { warmup() { return Promise.resolve(true); } embed(...a: unknown[]) { return embedMock(...a); } }, })); vi.mock("../../src/deeplake-api.js", () => ({ - DeeplakeApi: class { query(sql: string) { return queryMock(sql); } }, + DeeplakeApi: class { + // Record ctor args so tests can assert WHICH org/workspace recall searched + // — that is the routing decision (src/dir-config.ts). + constructor(...a: unknown[]) { apiCtorMock(...a); } + query(sql: string) { return queryMock(sql); } + }, })); const CONFIG = { @@ -81,6 +90,7 @@ beforeEach(() => { embeddingsDisabledMock.mockReset().mockReturnValue(false); // default: semantic on (only search mode) embedMock.mockReset().mockResolvedValue([0.1, 0.2, 0.3]); queryMock.mockReset().mockResolvedValue([]); + apiCtorMock.mockReset(); debugLogMock.mockReset(); recordEventMock.mockReset(); selfHealMock.mockReset(); @@ -292,3 +302,62 @@ describe("recall hook — latency budget + failure isolation", () => { expect(exitSpy).toHaveBeenCalledWith(0); }); }); + +describe("recall hook — per-directory routing (.hivemind)", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "hivemind-recall-route-")); + delete process.env.HIVEMIND_ORG_ID; + delete process.env.HIVEMIND_WORKSPACE_ID; + }); + afterEach(() => { rmSync(root, { recursive: true, force: true }); }); + + /** org/workspace the search was actually issued against. */ + function searchedIdentity(): { orgId: unknown; workspaceId: unknown } { + expect(apiCtorMock).toHaveBeenCalled(); + const [, , orgId, workspaceId] = apiCtorMock.mock.calls[0]; + return { orgId, workspaceId }; + } + + it("recalls from the workspace the session's directory is pinned to", async () => { + writeFileSync(join(root, ".hivemind"), JSON.stringify({ workspaceId: "workspace2" })); + stdinMock.mockResolvedValue({ prompt: "how did we fix the parser typeerror crash bug", session_id: "sid", cwd: root }); + await runHook(); + expect(searchedIdentity()).toEqual({ orgId: "o", workspaceId: "workspace2" }); + }); + + it("routes the org too, inheriting the config from an ancestor directory", async () => { + writeFileSync(join(root, ".hivemind"), JSON.stringify({ orgId: "acme", workspaceId: "client-work" })); + const leaf = join(root, "svc", "deep"); + mkdirSync(leaf, { recursive: true }); + stdinMock.mockResolvedValue({ prompt: "how did we fix the parser typeerror crash bug", session_id: "sid", cwd: leaf }); + await runHook(); + expect(searchedIdentity()).toEqual({ orgId: "acme", workspaceId: "client-work" }); + }); + + it("still routes recall under collect:false — collect gates capture, not reads", async () => { + writeFileSync(join(root, ".hivemind"), JSON.stringify({ workspaceId: "client-work", collect: false })); + stdinMock.mockResolvedValue({ prompt: "how did we fix the parser typeerror crash bug", session_id: "sid", cwd: root }); + await runHook(); + expect(searchedIdentity().workspaceId).toBe("client-work"); + }); + + it("uses the global identity when the directory has no .hivemind", async () => { + stdinMock.mockResolvedValue({ prompt: "how did we fix the parser typeerror crash bug", session_id: "sid", cwd: root }); + await runHook(); + expect(searchedIdentity()).toEqual({ orgId: "o", workspaceId: "w" }); + }); + + it("falls back to process.cwd() when the payload carries no cwd", async () => { + // Claude Code always sends cwd, but the field is optional in the payload — + // resolving from process.cwd() keeps a cwd-less caller on a real directory + // rather than walking up from undefined. + const spy = vi.spyOn(process, "cwd").mockReturnValue(root); + writeFileSync(join(root, ".hivemind"), JSON.stringify({ workspaceId: "from-process-cwd" })); + stdinMock.mockResolvedValue({ prompt: "how did we fix the parser typeerror crash bug", session_id: "sid" }); + await runHook(); + expect(searchedIdentity().workspaceId).toBe("from-process-cwd"); + spy.mockRestore(); + }); +});