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
17 changes: 2 additions & 15 deletions .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,13 @@ jobs:
semantic-pr-title:
runs-on: ubuntu-latest
steps:
- name: Check out PR head
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 1

- name: Validate semantic PR title and head commit subject
- name: Validate semantic PR title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
python3 - <<'PY'
import os
import re
import subprocess
import sys

pattern = re.compile(
Expand All @@ -43,7 +36,7 @@ jobs:

def validate(label: str, value: str) -> None:
if pattern.match(value):
print(f"{label} is a valid conventional commit title: {value}")
print(f"{label} is a valid conventional title: {value}")
return

print(f"Invalid {label}: {value}", file=sys.stderr)
Expand All @@ -52,10 +45,4 @@ jobs:
raise SystemExit(1)

validate("PR title", os.environ["PR_TITLE"])

head_subject = subprocess.check_output(
["git", "log", "-1", "--pretty=%s"],
text=True,
).strip()
validate("head commit subject", head_subject)
PY
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ This plugin integrates OpenClaw with [Agent Control](https://github.com/agentcon

When the gateway starts, the plugin loads the OpenClaw tool catalog and syncs it to Agent Control. On every tool call, the plugin intercepts the invocation through a `before_tool_call` hook, builds an evaluation context (session, channel, provider, agent identity), and sends it to Agent Control for a policy decision. If the evaluation comes back safe the call proceeds normally. If it comes back denied the call is blocked and the user sees a rejection message.

The plugin handles multiple agents, tracks tool catalog changes between calls, and re-syncs automatically when the catalog drifts.
The plugin handles multiple agents, caches the resolved tool catalog briefly to keep the pre-tool hook fast, and re-syncs automatically when the catalog drifts.

## Quick start

Expand Down
30 changes: 30 additions & 0 deletions src/agent-control-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "./observability.ts";
import { resolveStepsForContext } from "./tool-catalog.ts";
import { buildEvaluationContext } from "./session-context.ts";
import { warmSessionIdentityResolver } from "./session-store.ts";
import {
asPositiveInt,
asString,
Expand Down Expand Up @@ -106,6 +107,7 @@ export default function register(api: OpenClawPluginApi) {
const states = new Map<string, AgentState>();
let gatewayWarmupPromise: Promise<void> | null = null;
let gatewayWarmupStatus: "idle" | "running" | "done" | "failed" = "idle";
let sessionIdentityWarmupPromise: Promise<void> | null = null;

const getOrCreateState = (sourceAgentId: string): AgentState => {
const existing = states.get(sourceAgentId);
Expand Down Expand Up @@ -160,9 +162,36 @@ export default function register(api: OpenClawPluginApi) {
return gatewayWarmupPromise;
};

const ensureSessionIdentityWarmup = (): Promise<void> => {
if (sessionIdentityWarmupPromise) {
return sessionIdentityWarmupPromise;
}

const warmupStartedAt = process.hrtime.bigint();
sessionIdentityWarmupPromise = warmSessionIdentityResolver({
api,
sourceAgentId: BOOT_WARMUP_AGENT_ID,
})
.then(() => {
logger.debug(
`agent-control: session_identity_warmup done duration_sec=${secondsSince(warmupStartedAt)}`,
);
})
.catch((err) => {
logger.debug(
`agent-control: session_identity_warmup failed duration_sec=${secondsSince(warmupStartedAt)} error=${formatAgentControlError(err)}`,
);
});

return sessionIdentityWarmupPromise;
};

const syncAgent = async (state: AgentState): Promise<void> => {
if (state.syncPromise) {
await state.syncPromise;
if (state.lastSyncedStepsHash !== state.stepsHash) {
await syncAgent(state);
}
return;
}
if (state.lastSyncedStepsHash === state.stepsHash) {
Expand Down Expand Up @@ -205,6 +234,7 @@ export default function register(api: OpenClawPluginApi) {
};

api.on("gateway_start", async () => {
void ensureSessionIdentityWarmup();
await ensureGatewayWarmup();
});

Expand Down
6 changes: 5 additions & 1 deletion src/session-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,11 @@ export async function buildEvaluationContext(params: {
configuredAgentVersion?: string;
}): Promise<Record<string, unknown>> {
const channelFromSessionKey = deriveChannelContext(params.ctx.sessionKey);
const sessionIdentity = await resolveSessionIdentity(params.ctx.sessionKey);
const sessionIdentity = await resolveSessionIdentity({
api: params.api,
sessionKey: params.ctx.sessionKey,
sourceAgentId: params.sourceAgentId,
});
const mergedChannelType =
sessionIdentity.type !== "unknown" ? sessionIdentity.type : channelFromSessionKey.type;
const mergedChannelProvider = sessionIdentity.provider ?? channelFromSessionKey.provider;
Expand Down
189 changes: 166 additions & 23 deletions src/session-store.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,48 @@
import type { SessionIdentitySnapshot, SessionMetadataCacheEntry, SessionStoreInternals } from "./types.ts";
import { asString, isRecord } from "./shared.ts";
import { getResolvedOpenClawRootDir, importOpenClawInternalModule } from "./openclaw-runtime.ts";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";

const SESSION_META_CACHE_TTL_MS = 2_000;
const SESSION_META_CACHE_MAX = 512;

let sessionStoreInternalsPromise: Promise<SessionStoreInternals> | null = null;
const sessionMetadataCache = new Map<string, SessionMetadataCacheEntry>();

async function loadSessionStoreInternals(): Promise<SessionStoreInternals> {
function resolveRuntimeSessionStoreInternals(
api: OpenClawPluginApi | undefined,
): SessionStoreInternals | null {
const runtime = isRecord(api?.runtime) ? api.runtime : undefined;
const runtimeConfig = isRecord(runtime?.config) ? runtime.config : undefined;
const runtimeAgent = isRecord(runtime?.agent) ? runtime.agent : undefined;
const runtimeAgentSession = isRecord(runtimeAgent?.session) ? runtimeAgent.session : undefined;

const resolveStorePath = runtimeAgentSession?.resolveStorePath;
const loadSessionStore = runtimeAgentSession?.loadSessionStore;
if (typeof resolveStorePath !== "function" || typeof loadSessionStore !== "function") {
return null;
}

const loadConfig = runtimeConfig?.loadConfig;
const fallbackConfig = isRecord(api?.config) ? api.config : {};
return {
loadConfig:
typeof loadConfig === "function"
? (loadConfig as SessionStoreInternals["loadConfig"])
: () => fallbackConfig,
resolveStorePath: resolveStorePath as SessionStoreInternals["resolveStorePath"],
loadSessionStore: loadSessionStore as SessionStoreInternals["loadSessionStore"],
};
}

async function loadSessionStoreInternals(
api: OpenClawPluginApi | undefined,
): Promise<SessionStoreInternals> {
const runtimeInternals = resolveRuntimeSessionStoreInternals(api);
if (runtimeInternals) {
return runtimeInternals;
}

if (sessionStoreInternalsPromise) {
return sessionStoreInternalsPromise;
}
Expand Down Expand Up @@ -50,6 +84,19 @@ async function loadSessionStoreInternals(): Promise<SessionStoreInternals> {
return sessionStoreInternalsPromise;
}

export async function warmSessionIdentityResolver(params: {
api: OpenClawPluginApi;
sourceAgentId?: string;
}): Promise<void> {
const internals = await loadSessionStoreInternals(params.api);
const cfg = internals.loadConfig();
const sessionCfg = isRecord(cfg.session) ? cfg.session : undefined;
const storePath = internals.resolveStorePath(asString(sessionCfg?.store), {
agentId: asString(params.sourceAgentId),
});
internals.loadSessionStore(storePath);
}

function unknownSessionIdentity(): SessionIdentitySnapshot {
return {
provider: null,
Expand Down Expand Up @@ -80,6 +127,18 @@ function resolveBaseSessionKey(sessionKey: string): string {
return base || sessionKey;
}

function resolveSessionAgentId(
normalizedSessionKey: string,
sourceAgentId: string | undefined,
): string | undefined {
const parts = normalizedSessionKey.split(":").filter(Boolean);
if (parts.length >= 2 && parts[0] === "agent" && parts[1]) {
return parts[1];
}
const normalizedSourceAgentId = asString(sourceAgentId);
return normalizedSourceAgentId === "default" ? undefined : normalizedSourceAgentId;
}

function readSessionIdentityFromEntry(entry: Record<string, unknown>): SessionIdentitySnapshot {
const origin = isRecord(entry.origin) ? entry.origin : undefined;
const deliveryContext = isRecord(entry.deliveryContext) ? entry.deliveryContext : undefined;
Expand Down Expand Up @@ -120,7 +179,12 @@ function readSessionIdentityFromEntry(entry: Record<string, unknown>): SessionId
}

function setSessionMetadataCache(key: string, data: SessionIdentitySnapshot): void {
sessionMetadataCache.set(key, { at: Date.now(), data });
const now = Date.now();
sessionMetadataCache.set(key, {
at: now,
data,
expiresAt: now + SESSION_META_CACHE_TTL_MS,
});
if (sessionMetadataCache.size > SESSION_META_CACHE_MAX) {
const oldest = sessionMetadataCache.keys().next().value;
if (typeof oldest === "string") {
Expand All @@ -129,36 +193,115 @@ function setSessionMetadataCache(key: string, data: SessionIdentitySnapshot): vo
}
}

export async function resolveSessionIdentity(
sessionKey: string | undefined,
): Promise<SessionIdentitySnapshot> {
const normalizedKey = normalizeSessionStoreKey(sessionKey);
if (!normalizedKey) {
return unknownSessionIdentity();
}

const cached = sessionMetadataCache.get(normalizedKey);
if (cached && Date.now() - cached.at < SESSION_META_CACHE_TTL_MS) {
return cached.data;
function setSessionMetadataCachePromise(
key: string,
promise: Promise<SessionIdentitySnapshot>,
): void {
sessionMetadataCache.set(key, { at: Date.now(), promise });
if (sessionMetadataCache.size > SESSION_META_CACHE_MAX) {
const oldest = sessionMetadataCache.keys().next().value;
if (typeof oldest === "string" && oldest !== key) {
sessionMetadataCache.delete(oldest);
}
}
}

async function readSessionIdentity(params: {
normalizedKey: string;
internals: SessionStoreInternals;
storePath: string;
}): Promise<SessionIdentitySnapshot> {
try {
const internals = await loadSessionStoreInternals();
const cfg = internals.loadConfig();
const sessionCfg = isRecord(cfg.session) ? cfg.session : undefined;
const storePath = internals.resolveStorePath(asString(sessionCfg?.store));
const store = internals.loadSessionStore(storePath);
const directEntry = store[normalizedKey];
const baseEntry = store[resolveBaseSessionKey(normalizedKey)];
const store = params.internals.loadSessionStore(params.storePath);
const directEntry = store[params.normalizedKey];
const baseEntry = store[resolveBaseSessionKey(params.normalizedKey)];
const entry: Record<string, unknown> | undefined = isRecord(directEntry)
? directEntry
: isRecord(baseEntry)
? baseEntry
: undefined;
const data = entry ? readSessionIdentityFromEntry(entry) : unknownSessionIdentity();
setSessionMetadataCache(normalizedKey, data);
return data;
return entry ? readSessionIdentityFromEntry(entry) : unknownSessionIdentity();
} catch {
return unknownSessionIdentity();
}
}

function buildSessionMetadataCacheKey(params: {
normalizedKey: string;
storePath: string;
}): string {
return JSON.stringify({
normalizedKey: params.normalizedKey,
storePath: params.storePath,
});
}

async function resolveSessionStoreLookupContext(params: {
api?: OpenClawPluginApi;
normalizedKey: string;
sourceAgentId?: string;
}): Promise<{ internals: SessionStoreInternals; storePath: string } | null> {
try {
const internals = await loadSessionStoreInternals(params.api);
const cfg = internals.loadConfig();
const sessionCfg = isRecord(cfg.session) ? cfg.session : undefined;
const storeAgentId = resolveSessionAgentId(params.normalizedKey, params.sourceAgentId);
const storePath = internals.resolveStorePath(asString(sessionCfg?.store), {
agentId: storeAgentId,
});
return { internals, storePath };
} catch {
return null;
}
}

export async function resolveSessionIdentity(
input:
| string
| undefined
| {
api?: OpenClawPluginApi;
sessionKey?: string;
sourceAgentId?: string;
},
): Promise<SessionIdentitySnapshot> {
const sessionKey = typeof input === "object" ? input.sessionKey : input;
const api = typeof input === "object" ? input.api : undefined;
const sourceAgentId = typeof input === "object" ? input.sourceAgentId : undefined;
const normalizedKey = normalizeSessionStoreKey(sessionKey);
if (!normalizedKey) {
return unknownSessionIdentity();
}

const lookupContext = await resolveSessionStoreLookupContext({
api,
normalizedKey,
sourceAgentId,
});
if (!lookupContext) {
return unknownSessionIdentity();
}

const cacheKey = buildSessionMetadataCacheKey({
normalizedKey,
storePath: lookupContext.storePath,
});
const cached = sessionMetadataCache.get(cacheKey);
if (cached?.data && cached.expiresAt && Date.now() < cached.expiresAt) {
return cached.data;
}
if (cached?.promise) {
return cached.promise;
}

const promise = readSessionIdentity({
normalizedKey,
internals: lookupContext.internals,
storePath: lookupContext.storePath,
}).then((data) => {
setSessionMetadataCache(cacheKey, data);
return data;
});
setSessionMetadataCachePromise(cacheKey, promise);
return promise;
}
Loading
Loading