Skip to content
Open
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
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/`.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 0 additions & 38 deletions scripts/inject-api-key.mjs

This file was deleted.

32 changes: 31 additions & 1 deletion src/cortex/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}

Expand Down Expand Up @@ -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<boolean> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
Expand Down
6 changes: 0 additions & 6 deletions src/internal/api-key.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/internal/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
102 changes: 69 additions & 33 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -174,7 +173,37 @@ async function checkForUpdate(logger: Logger): Promise<void> {
}
}

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<string | undefined> {
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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<void> = userId
? Promise.resolve()
Expand All @@ -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<void> = 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);
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -669,7 +705,7 @@ const plugin = {
logger: api.logger,
getUserId: () => userId,
getActiveSessionKey: () => currentSessionKey,
userIdReady,
userIdReady: identityReady,
sessionId,
sessionStats,
persistStats,
Expand Down Expand Up @@ -697,7 +733,7 @@ const plugin = {
config,
logger: api.logger,
getUserId: () => userId,
userIdReady,
userIdReady: identityReady,
getLastMessages: () => lastMessages,
sessionId,
auditLoggerProxy,
Expand Down Expand Up @@ -746,7 +782,7 @@ const plugin = {
config,
version,
getUserId: () => userId,
userIdReady,
userIdReady: identityReady,
getNamespace: () => namespace,
sessionStats,
loadPersistedStats,
Expand Down
59 changes: 57 additions & 2 deletions tests/unit/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand Down
Loading
Loading