diff --git a/README.md b/README.md index edef5218..5370dabf 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 d65ca5fa..33f67f03 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 00000000..08fa9cc6 --- /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 784a6831..e582cda4 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 e2944503..363affed 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 469c18d0..e8339ad0 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 cc71ec92..f0d91c33 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 9b5ea0e1..43710635 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 302e0e72..629f1f41 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/claude-code/recall-hook.test.ts b/tests/claude-code/recall-hook.test.ts index ec3064eb..0fdc44f8 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(); + }); +}); 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 00000000..9cd6e934 --- /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 055178de..6cd46825 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"); }); });