Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions App/lib/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,33 @@ export async function getAccessContext(client: Client, request: Request): Promis
agentId = identity.rows[0]?.agent_id || null;
}

// Only persist/use agent ids that are onboarded and active. Some runtimes
// (OpenClaw/Hermes/LLM gateways) naturally send an `x-agent-id` before the
// Knowledge service has onboarded that agent. Treat those callers as public
// consumers instead of letting FK-backed usage/request writes fail with 500s.
// Resolve the agent id against the `agents` table (the FK target for usage /
// attribution / receipt writes). Some runtimes (OpenClaw/Hermes/LLM gateways)
// send an `x-agent-id` before Knowledge has a row for that agent. If the
// caller also identifies a wallet, self-register it as a consumer so its
// usage + earnings attribution is actually captured (agents-in-the-market —
// a provisioned agent gets KNOWLEDGE_AGENT_ID + KNOWLEDGE_AGENT_WALLET and
// becomes a first-class consumer on its first paid query). Without a wallet,
// or for a disabled agent, treat the caller as a public consumer so FK-backed
// writes don't 500.
if (agentId) {
const registered = await client.query(
`SELECT id
FROM agents
WHERE id = $1 AND status = 'active'
LIMIT 1`,
const existing = await client.query(
`SELECT status FROM agents WHERE id = $1 LIMIT 1`,
[agentId]
);
if (!registered.rowCount) agentId = null;
if (existing.rowCount) {
if (existing.rows[0].status !== 'active') agentId = null;
} else if (explicitAgentId && wallet) {
await client.query(
`INSERT INTO agents (id, display_name, agent_type, metadata)
VALUES ($1, $1, 'consumer', jsonb_build_object('self_registered', true, 'wallet', $2::text))
ON CONFLICT (id) DO NOTHING`,
[agentId, wallet]
);
// agentId now references an active 'consumer' row → attribution is captured.
} else {
agentId = null;
}
}

if (agentId) {
Expand Down
58 changes: 56 additions & 2 deletions App/tests/access.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,60 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";

import { hashQuery, requestId } from "../lib/access";
import { getAccessContext, hashQuery, requestId } from "../lib/access";

// Minimal fake pg Client: dispatch by the SQL it sees so getAccessContext can be
// unit-tested without a real DB. `agentsRow` is what `SELECT status FROM agents`
// returns (null = no row).
function fakeClient(opts: { agentsRow?: { status: string } | null } = {}) {
const calls: Array<{ sql: string; params: unknown[] }> = [];
const query = vi.fn(async (sql: string, params: unknown[] = []) => {
calls.push({ sql, params });
if (/FROM agent_identities/.test(sql)) return { rows: [], rowCount: 0 };
if (/SELECT status FROM agents/.test(sql)) {
const row = opts.agentsRow ?? null;
return { rows: row ? [row] : [], rowCount: row ? 1 : 0 };
}
if (/INSERT INTO agents/.test(sql)) return { rows: [], rowCount: 1 };
if (/FROM organization_agents/.test(sql)) return { rows: [], rowCount: 0 };
return { rows: [], rowCount: 0 };
});
return { client: { query } as never, calls, query };
}

function req(headers: Record<string, string>) {
return new Request("https://knowledge.perkos.xyz/skill/query", { headers });
}

describe("getAccessContext — consumer attribution", () => {
it("self-registers an unknown agent as a consumer when a wallet is present, and keeps the id", async () => {
const { client, query } = fakeClient({ agentsRow: null });
const access = await getAccessContext(client, req({ "x-agent-id": "Aria", "x-agent-wallet": "0x" + "a".repeat(40) }));
expect(access.agentId).toBe("Aria"); // captured, not dropped
const insert = query.mock.calls.find((c) => /INSERT INTO agents/.test(c[0] as string));
expect(insert).toBeTruthy(); // a consumer row was upserted
expect((insert as unknown[])[1]).toEqual(["Aria", "0x" + "a".repeat(40)]);
});

it("treats an unknown agent with NO wallet as a public consumer (agentId null, no insert)", async () => {
const { client, query } = fakeClient({ agentsRow: null });
const access = await getAccessContext(client, req({ "x-agent-id": "Anon" }));
expect(access.agentId).toBeNull();
expect(query.mock.calls.some((c) => /INSERT INTO agents/.test(c[0] as string))).toBe(false);
});

it("keeps an already-active agent without inserting", async () => {
const { client, query } = fakeClient({ agentsRow: { status: "active" } });
const access = await getAccessContext(client, req({ "x-agent-id": "Known", "x-agent-wallet": "0x" + "b".repeat(40) }));
expect(access.agentId).toBe("Known");
expect(query.mock.calls.some((c) => /INSERT INTO agents/.test(c[0] as string))).toBe(false);
});

it("nulls out a disabled agent (never attributes to it)", async () => {
const { client } = fakeClient({ agentsRow: { status: "disabled" } });
const access = await getAccessContext(client, req({ "x-agent-id": "Banned", "x-agent-wallet": "0x" + "c".repeat(40) }));
expect(access.agentId).toBeNull();
});
});

describe("requestId", () => {
it("returns kreq_<uuid>-shaped id", () => {
Expand Down
Loading