diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcb0f8b..9c73005 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,8 +32,6 @@ jobs: - name: Build run: npm run build - env: - BUILD_API_KEY: ${{ secrets.BUILD_API_KEY }} - name: Package smoke test run: npm pack --dry-run diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c5a5f22..952a2fa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -33,8 +33,6 @@ jobs: - name: Build run: npm run build - env: - BUILD_API_KEY: ${{ secrets.BUILD_API_KEY }} - name: Unit tests run: npm test diff --git a/README.md b/README.md index b64aef6..132c853 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,6 @@ src/ recall/ # Before-turn memory injection + context profiles internal/ # Shared utilities (not stable public API) agent-instructions.ts - api-key.ts audit-logger.ts capture-watermark-store.ts cleaner.ts @@ -336,7 +335,7 @@ npm install npm run build # TypeScript → dist/ npm test # Run vitest (504 tests) npm run test:watch # Watch mode -npm run test:integration # Live Cortex API tests (uses the baked-in API key) +npm run test:integration # Live Cortex API tests (requires CORTEX_API_KEY env var) ``` Manual proof scripts live under `tests/manual/`. diff --git a/package.json b/package.json index 0f38ff2..44d8002 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ ] }, "scripts": { - "build": "tsc && node scripts/inject-api-key.mjs", + "build": "tsc", "test": "vitest run", "test:watch": "vitest", "test:integration": "vitest run -c vitest.integration.config.ts", diff --git a/scripts/inject-api-key.mjs b/scripts/inject-api-key.mjs deleted file mode 100644 index f9a4322..0000000 --- a/scripts/inject-api-key.mjs +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env node -/** - * Post-build script: replaces __OPENCLAW_API_KEY__ placeholder in compiled - * dist files with the value of the BUILD_API_KEY environment variable. - * - * Usage: - * BUILD_API_KEY=your-key npm run build - * - * If BUILD_API_KEY is not set the placeholder is left as-is. Users must - * provide their own API key via plugin config or the CORTEX_API_KEY env var. - */ -import { readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const apiKey = process.env.BUILD_API_KEY; - -if (!apiKey) { - console.log("[inject-api-key] BUILD_API_KEY not set — placeholder left in dist. Users must provide their own API key."); - process.exit(0); -} - -const distDir = join(fileURLToPath(import.meta.url), "../../dist"); -const targetFile = join(distDir, "internal/api-key.js"); - -try { - const content = readFileSync(targetFile, "utf-8"); - const replaced = content.replace(/__OPENCLAW_API_KEY__/g, apiKey); - if (replaced === content) { - console.warn("[inject-api-key] Warning: placeholder not found in dist file — already replaced?"); - } else { - writeFileSync(targetFile, replaced, "utf-8"); - console.log("[inject-api-key] API key injected successfully"); - } -} catch (err) { - console.error("[inject-api-key] Failed to inject API key:", err.message); - process.exit(1); -} diff --git a/src/cortex/client.ts b/src/cortex/client.ts index 3ae4cab..ca3868d 100644 --- a/src/cortex/client.ts +++ b/src/cortex/client.ts @@ -354,7 +354,26 @@ export class CortexClient { try { const body = await res.text(); if (body) detail = ` — ${body.slice(0, 300)}`; - } catch {} + // Surface scoped-key authorization errors as clear, user-facing messages + if (res.status === 403 && body) { + if (body.includes("Scoped key is bound to a different user_id")) { + throw new Error( + `Cortex ${label}: API key is scoped to a different user. ` + + "Your key cannot access this user_id. Generate a new key at https://cortex.ubundi.com or check your userId config.", + ); + } + if (body.includes("Key lacks")) { + throw new Error( + `Cortex ${label}: ${body.trim()}. ` + + "Your API key does not have the required permission for this operation. " + + "Update your key permissions at https://cortex.ubundi.com.", + ); + } + } + } catch (parseErr) { + // Re-throw if this is one of our explicit 403 errors + if (parseErr instanceof Error && parseErr.message.startsWith(`Cortex ${label}:`)) throw parseErr; + } throw new Error(`Cortex ${label} failed: ${res.status}${detail}`); } @@ -396,6 +415,17 @@ export class CortexClient { }; } + async whoami( + timeoutMs = DEFAULT_INSPECT_TIMEOUT_MS, + ): Promise<{ key_type: string; tenant_id: string; user_id: string | null; permissions: string[] }> { + return this.fetchRequest( + `${this.baseUrl}/v1/keys/whoami`, + { method: "GET" }, + timeoutMs, + "keys/whoami", + ); + } + async healthCheck(timeoutMs = DEFAULT_HEALTH_TIMEOUT_MS): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); diff --git a/src/internal/api-key.ts b/src/internal/api-key.ts deleted file mode 100644 index f243405..0000000 --- a/src/internal/api-key.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * The Cortex API key is injected at publish time by scripts/inject-api-key.mjs. - * In source this is a placeholder; the dist file contains the real value. - * Never commit a real key here. - */ -export const BAKED_API_KEY = "__OPENCLAW_API_KEY__"; diff --git a/src/internal/index.ts b/src/internal/index.ts index 0abcda6..bfd2add 100644 --- a/src/internal/index.ts +++ b/src/internal/index.ts @@ -1,6 +1,5 @@ export { AuditLogger, type AuditEntry } from "./audit-logger.js"; export { CaptureWatermarkStore } from "./capture-watermark-store.js"; -export { BAKED_API_KEY } from "./api-key.js"; export { cleanTranscript, cleanTranscriptChunk } from "./cleaner.js"; export { RecentSaves } from "./dedupe.js"; export { injectAgentInstructions } from "./agent-instructions.js"; diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 9d8f322..4f694a5 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -11,7 +11,6 @@ import { createBridgeHandler, buildBridgeFollowUpPrompt } from "../features/brid import { RetryQueue } from "../internal/retry-queue.js"; import { LatencyMetrics } from "../internal/latency-metrics.js"; import { loadOrCreateUserId } from "../internal/user-id.js"; -import { BAKED_API_KEY } from "../internal/api-key.js"; import { AuditLogger } from "../internal/audit-logger.js"; import { RecentSaves } from "../internal/dedupe.js"; import { RecallEchoStore } from "../internal/recall-echo-store.js"; @@ -174,7 +173,37 @@ async function checkForUpdate(logger: Logger): Promise { } } -async function bootstrapClient( +/** + * Resolves the scoped user_id from the API key via whoami. + * Returns the scoped user_id when the key is user-scoped, undefined otherwise. + * Fatal auth errors (mis-scoped key) are re-thrown so callers can halt bootstrap. + */ +async function resolveScopedIdentity( + client: CortexClient, + logger: Logger, +): Promise { + try { + const whoami = await client.whoami(); + const perms = whoami.permissions?.join(", ") ?? "none"; + logger.info(`[Cortex] Authenticated as user: ${whoami.user_id ?? "unscoped"} (permissions: ${perms})`); + return whoami.user_id ?? undefined; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("API key is scoped to a different user") || msg.includes("Key lacks")) { + logger.warn(`[Cortex] ${msg}`); + throw err; // Fatal — caller should abort bootstrap + } + // Other errors (network, old backend without whoami) are non-fatal + logger.debug?.(`Cortex whoami unavailable: ${msg}`); + return undefined; + } +} + +/** + * Fetches knowledge state and pre-warms the ECS task. + * Must be called after identity resolution so userId is final. + */ +async function bootstrapKnowledge( client: CortexClient, logger: Logger, knowledgeState: KnowledgeState, @@ -352,25 +381,13 @@ const plugin = { const config: CortexConfig = parsed.data; - // Resolve API key: plugin config → CORTEX_API_KEY env var → baked build key. - // The baked key is a placeholder ("__OPENCLAW_API_KEY__") in source and may - // be empty in published builds, so user-provided keys take priority. - const resolvedApiKey = - config.apiKey || - process.env.CORTEX_API_KEY || - (BAKED_API_KEY !== "__OPENCLAW_API_KEY__" && BAKED_API_KEY) || - ""; + // Resolve API key: plugin config → CORTEX_API_KEY env var. + const resolvedApiKey = config.apiKey || process.env.CORTEX_API_KEY || ""; if (!resolvedApiKey) { - api.logger.warn( - "[Cortex] This plugin is currently in early testing and requires an API key to use.", - ); - api.logger.warn( - '[Cortex] Set "apiKey" in your plugin config (openclaw.json) or export the CORTEX_API_KEY environment variable.', - ); - api.logger.warn( - "[Cortex] To request access, reach out to the Ubundi team: https://ubundi.com", - ); + api.logger.warn("[Cortex] No API key configured."); + api.logger.warn("[Cortex] Generate your personal key at: https://cortex.ubundi.com"); + api.logger.warn('[Cortex] Then set "apiKey" in your plugin config (openclaw.json) or export CORTEX_API_KEY.'); return; } @@ -434,8 +451,9 @@ const plugin = { // userId: use explicit config value if provided, otherwise load/create a // stable UUID persisted at ~/.openclaw/cortex-user-id. Resolved eagerly so // commands and hooks work even if start() is never called (e.g. [plugins] - // instances in multi-process runtimes). The capture handler awaits - // userIdReady before firing — user_id is required by the API. + // instances in multi-process runtimes). All consumers (capture, tools, CLI, + // bridge) await identityReady, which chains through whoami to adopt scoped + // key user_id before any API calls are made. let userId: string | undefined = config.userId; const userIdReady: Promise = userId ? Promise.resolve() @@ -449,14 +467,32 @@ const plugin = { api.logger.warn("Cortex: could not persist user ID, using ephemeral ID for this session"); }); - // Health check + knowledge probe — runs after userId resolves so recall - // knows whether memories exist. Must happen in register() because some - // runtime instances never call start(). - // Skip when running CLI commands (e.g. `openclaw cortex status`) — the - // async log races with command output and CLI commands fetch their own data. + // Identity resolution: load local ID, then check for scoped key override. + // Runs for ALL paths (CLI and non-CLI) so every consumer uses the correct ID. + let identityAborted = false; + const identityReady: Promise = userIdReady.then(async () => { + try { + const scopedId = await resolveScopedIdentity(client, api.logger); + if (scopedId) { + userId = scopedId; + api.logger.debug?.(`Cortex: adopted scoped user ID from API key: ${userId}`); + } + } catch { + // Fatal auth error already logged by resolveScopedIdentity. + // userId remains the local install ID; scoped-key calls will 403. + identityAborted = true; + } + }); + + // Knowledge probe + warmup — only for interactive sessions (not CLI commands). + // CLI commands fetch their own data and the async log races with command output. + // Skipped when identity resolution hit a fatal auth error (mis-scoped key). const isCliInvocation = process.argv.some((a) => a === "cortex"); if (!isCliInvocation) { - void userIdReady.then(() => bootstrapClient(client, api.logger, knowledgeState, userId!)); + void identityReady.then(() => { + if (identityAborted) return; + return bootstrapKnowledge(client, api.logger, knowledgeState, userId!); + }); void checkForUpdate(api.logger); } @@ -503,13 +539,13 @@ const plugin = { logger: api.logger, retryQueue, getUserId: () => userId, - userIdReady, + userIdReady: identityReady, pluginSessionId: sessionId, auditLogger: auditLoggerProxy, }); if (!isCliInvocation) { - void userIdReady.then(() => bridgeHandler.refreshLinkStatus(true)); + void identityReady.then(() => bridgeHandler.refreshLinkStatus(true)); } // Auto-Recall: inject relevant memories before every agent turn @@ -586,7 +622,7 @@ const plugin = { // Auto-Capture: extract facts after agent responses const watermarkStore = new CaptureWatermarkStore(); void watermarkStore.load().catch((err) => api.logger.debug?.(`Cortex watermark load failed: ${String(err)}`)); - const captureHandler = createCaptureHandler(client, config, api.logger, retryQueue, knowledgeState, () => userId, userIdReady, sessionId, auditLoggerProxy, echoStore, watermarkStore, sessionGoalStore); + const captureHandler = createCaptureHandler(client, config, api.logger, retryQueue, knowledgeState, () => userId, identityReady, sessionId, auditLoggerProxy, echoStore, watermarkStore, sessionGoalStore); registerHookCompat( api, "agent_end", @@ -669,7 +705,7 @@ const plugin = { logger: api.logger, getUserId: () => userId, getActiveSessionKey: () => currentSessionKey, - userIdReady, + userIdReady: identityReady, sessionId, sessionStats, persistStats, @@ -697,7 +733,7 @@ const plugin = { config, logger: api.logger, getUserId: () => userId, - userIdReady, + userIdReady: identityReady, getLastMessages: () => lastMessages, sessionId, auditLoggerProxy, @@ -746,7 +782,7 @@ const plugin = { config, version, getUserId: () => userId, - userIdReady, + userIdReady: identityReady, getNamespace: () => namespace, sessionStats, loadPersistedStats, diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts index 7cd4d9e..3cc613a 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -285,14 +285,69 @@ describe("CortexClient", () => { }); }); + describe("whoami", () => { + it("sends GET to /v1/keys/whoami", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + key_type: "scoped", + tenant_id: "tenant-1", + user_id: "user-123", + permissions: ["read", "write"], + }), + }); + + const result = await client.whoami(); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/v1/keys/whoami", + expect.objectContaining({ method: "GET" }), + ); + expect(result.key_type).toBe("scoped"); + expect(result.user_id).toBe("user-123"); + expect(result.permissions).toEqual(["read", "write"]); + }); + + it("throws on non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + await expect(client.whoami()).rejects.toThrow("Cortex keys/whoami failed: 401"); + }); + }); + describe("error status codes", () => { it("throws with status code for 401 Unauthorized", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); await expect(client.recall("q", 500)).rejects.toThrow("Cortex recall failed: 401"); }); - it("throws with status code for 403 Forbidden", async () => { - mockFetch.mockResolvedValueOnce({ ok: false, status: 403 }); + it("throws user-facing error when 403 indicates scoped key bound to different user", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + text: async () => "Scoped key is bound to a different user_id", + }); + await expect(client.recall("q", 500)).rejects.toThrow( + "API key is scoped to a different user", + ); + }); + + it("throws user-facing error when 403 indicates missing permission", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + text: async () => "Key lacks 'write' permission", + }); + await expect(client.remember("text", "s1", undefined, undefined, "user-1")).rejects.toThrow( + "Key lacks 'write' permission", + ); + }); + + it("throws generic 403 when body does not match scoped-key patterns", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + text: async () => "Forbidden", + }); await expect(client.remember("text", "s1", undefined, undefined, "user-1")).rejects.toThrow("Cortex remember failed: 403"); }); diff --git a/tests/unit/plugin-lifecycle.test.ts b/tests/unit/plugin-lifecycle.test.ts index 0edb38e..f1406b8 100644 --- a/tests/unit/plugin-lifecycle.test.ts +++ b/tests/unit/plugin-lifecycle.test.ts @@ -159,6 +159,12 @@ describe("plugin lifecycle contract", () => { mockClientHealth(); mockClientKnowledge(); vi.spyOn(CortexClient.prototype, "stats").mockResolvedValue({ pipeline_tier: 1, pipeline_maturity: "cold" }); + vi.spyOn(CortexClient.prototype, "whoami").mockResolvedValue({ + key_type: "tenant", + tenant_id: "test-tenant", + user_id: null, + permissions: ["read", "write"], + }); // Default: ensureToolsAllowlist silently skips (no config file found) mockReadFileSync.mockImplementation((...args: any[]) => { const path = String(args[0]); @@ -660,6 +666,12 @@ describe("plugin lifecycle contract", () => { vi.restoreAllMocks(); mockClientHealth(); mockClientKnowledge({ total_memories: 10, total_sessions: 5, maturity: "warming" }); + vi.spyOn(CortexClient.prototype, "whoami").mockResolvedValue({ + key_type: "scoped", + tenant_id: "test-tenant", + user_id: "user-1", + permissions: ["read", "write"], + }); vi.spyOn(RetryQueue.prototype, "stop").mockImplementation(() => {}); vi.spyOn(CortexClient.prototype, "retrieve").mockResolvedValue({ results: [ @@ -703,11 +715,35 @@ describe("plugin lifecycle contract", () => { expect(services).toHaveLength(0); }); + it("logs setup instructions and returns early when no API key is configured", () => { + delete process.env.CORTEX_API_KEY; + + const { api, hooks, services, logger } = makeApi({}); + + plugin.register(api as any); + + expect(logger.warn).toHaveBeenCalledWith("[Cortex] No API key configured."); + expect(logger.warn).toHaveBeenCalledWith( + "[Cortex] Generate your personal key at: https://cortex.ubundi.com", + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('set "apiKey" in your plugin config'), + ); + expect(Object.keys(hooks)).toHaveLength(0); + expect(services).toHaveLength(0); + }); + it("logs maturity and tier info on startup", async () => { vi.restoreAllMocks(); mockClientHealth(); mockClientKnowledge({ total_memories: 142, total_sessions: 18, maturity: "warming" }); vi.spyOn(CortexClient.prototype, "stats").mockResolvedValue({ pipeline_tier: 2, pipeline_maturity: "warming" }); + vi.spyOn(CortexClient.prototype, "whoami").mockResolvedValue({ + key_type: "scoped", + tenant_id: "test-tenant", + user_id: "agent-user-1", + permissions: ["read", "write"], + }); const { api, logger, services } = makeApi({ userId: "agent-user-1" }); @@ -721,9 +757,64 @@ describe("plugin lifecycle contract", () => { }); }); + it("adopts scoped user_id from whoami when key is user-scoped", async () => { + vi.restoreAllMocks(); + mockClientHealth(); + vi.spyOn(CortexClient.prototype, "whoami").mockResolvedValue({ + key_type: "scoped", + tenant_id: "test-tenant", + user_id: "scoped-user-from-key", + permissions: ["read", "write"], + }); + const knowledgeSpy = vi.spyOn(CortexClient.prototype, "knowledge").mockResolvedValue({ + total_memories: 5, + total_sessions: 1, + maturity: "cold", + entities: [], + }); + vi.spyOn(CortexClient.prototype, "stats").mockResolvedValue({ pipeline_tier: 1, pipeline_maturity: "cold" }); + mockReadFileSync.mockImplementation(() => { throw new Error("ENOENT"); }); + + const { api, logger } = makeApi({}); + + plugin.register(api as any); + await new Promise((r) => setTimeout(r, 50)); + + expect(knowledgeSpy).toHaveBeenCalledWith("scoped-user-from-key"); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining("adopted scoped user ID"), + ); + }); + + it("warns on whoami 403 for mis-scoped key and stops bootstrap", async () => { + vi.restoreAllMocks(); + mockClientHealth(); + vi.spyOn(CortexClient.prototype, "whoami").mockRejectedValue( + new Error("Cortex keys/whoami: API key is scoped to a different user. Your key cannot access this user_id."), + ); + const knowledgeSpy = vi.spyOn(CortexClient.prototype, "knowledge").mockResolvedValue({ + total_memories: 0, + total_sessions: 0, + maturity: "cold", + entities: [], + }); + mockReadFileSync.mockImplementation(() => { throw new Error("ENOENT"); }); + + const { api, logger } = makeApi({}); + + plugin.register(api as any); + await new Promise((r) => setTimeout(r, 50)); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("API key is scoped to a different user"), + ); + expect(knowledgeSpy).not.toHaveBeenCalled(); + }); + it("proceeds without knowledge when endpoint is unavailable", async () => { vi.restoreAllMocks(); mockClientHealth(); + vi.spyOn(CortexClient.prototype, "whoami").mockRejectedValue(new Error("Not found")); vi.spyOn(CortexClient.prototype, "knowledge").mockRejectedValue(new Error("Not found")); const { api, logger } = makeApi({});