feat(sessions): label lookup tightening (#570) (thanks @azade-c)
This commit is contained in:
254
src/gateway/sessions-patch.ts
Normal file
254
src/gateway/sessions-patch.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
|
||||
import {
|
||||
buildAllowedModelSet,
|
||||
buildModelAliasIndex,
|
||||
modelKey,
|
||||
resolveConfiguredModelRef,
|
||||
resolveModelRefFromString,
|
||||
} from "../agents/model-selection.js";
|
||||
import { normalizeGroupActivation } from "../auto-reply/group-activation.js";
|
||||
import {
|
||||
normalizeElevatedLevel,
|
||||
normalizeReasoningLevel,
|
||||
normalizeThinkLevel,
|
||||
normalizeUsageDisplay,
|
||||
normalizeVerboseLevel,
|
||||
} from "../auto-reply/thinking.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { isSubagentSessionKey } from "../routing/session-key.js";
|
||||
import { normalizeSendPolicy } from "../sessions/send-policy.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
type ErrorShape,
|
||||
errorShape,
|
||||
type SessionsPatchParams,
|
||||
} from "./protocol/index.js";
|
||||
|
||||
export const SESSION_LABEL_MAX_LENGTH = 64;
|
||||
|
||||
function invalid(message: string): { ok: false; error: ErrorShape } {
|
||||
return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) };
|
||||
}
|
||||
|
||||
function normalizeLabel(
|
||||
raw: unknown,
|
||||
): { ok: true; label: string } | ReturnType<typeof invalid> {
|
||||
const trimmed = String(raw ?? "").trim();
|
||||
if (!trimmed) return invalid("invalid label: empty");
|
||||
if (trimmed.length > SESSION_LABEL_MAX_LENGTH) {
|
||||
return invalid(`invalid label: too long (max ${SESSION_LABEL_MAX_LENGTH})`);
|
||||
}
|
||||
return { ok: true, label: trimmed };
|
||||
}
|
||||
|
||||
export async function applySessionsPatchToStore(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
store: Record<string, SessionEntry>;
|
||||
storeKey: string;
|
||||
patch: SessionsPatchParams;
|
||||
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
|
||||
}): Promise<
|
||||
{ ok: true; entry: SessionEntry } | { ok: false; error: ErrorShape }
|
||||
> {
|
||||
const { cfg, store, storeKey, patch } = params;
|
||||
const now = Date.now();
|
||||
|
||||
const existing = store[storeKey];
|
||||
const next: SessionEntry = existing
|
||||
? {
|
||||
...existing,
|
||||
updatedAt: Math.max(existing.updatedAt ?? 0, now),
|
||||
}
|
||||
: { sessionId: randomUUID(), updatedAt: now };
|
||||
|
||||
if ("spawnedBy" in patch) {
|
||||
const raw = patch.spawnedBy;
|
||||
if (raw === null) {
|
||||
if (existing?.spawnedBy)
|
||||
return invalid("spawnedBy cannot be cleared once set");
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) return invalid("invalid spawnedBy: empty");
|
||||
if (!isSubagentSessionKey(storeKey)) {
|
||||
return invalid("spawnedBy is only supported for subagent:* sessions");
|
||||
}
|
||||
if (existing?.spawnedBy && existing.spawnedBy !== trimmed) {
|
||||
return invalid("spawnedBy cannot be changed once set");
|
||||
}
|
||||
next.spawnedBy = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
if ("label" in patch) {
|
||||
const raw = patch.label;
|
||||
if (raw === null) {
|
||||
delete next.label;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeLabel(raw);
|
||||
if (!normalized.ok) return normalized;
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
if (key === storeKey) continue;
|
||||
if (entry?.label === normalized.label) {
|
||||
return invalid(`label already in use: ${normalized.label}`);
|
||||
}
|
||||
}
|
||||
next.label = normalized.label;
|
||||
}
|
||||
}
|
||||
|
||||
if ("thinkingLevel" in patch) {
|
||||
const raw = patch.thinkingLevel;
|
||||
if (raw === null) {
|
||||
delete next.thinkingLevel;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeThinkLevel(String(raw));
|
||||
if (!normalized) {
|
||||
return invalid(
|
||||
"invalid thinkingLevel (use off|minimal|low|medium|high)",
|
||||
);
|
||||
}
|
||||
if (normalized === "off") delete next.thinkingLevel;
|
||||
else next.thinkingLevel = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("verboseLevel" in patch) {
|
||||
const raw = patch.verboseLevel;
|
||||
if (raw === null) {
|
||||
delete next.verboseLevel;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeVerboseLevel(String(raw));
|
||||
if (!normalized) return invalid('invalid verboseLevel (use "on"|"off")');
|
||||
if (normalized === "off") delete next.verboseLevel;
|
||||
else next.verboseLevel = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("reasoningLevel" in patch) {
|
||||
const raw = patch.reasoningLevel;
|
||||
if (raw === null) {
|
||||
delete next.reasoningLevel;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeReasoningLevel(String(raw));
|
||||
if (!normalized) {
|
||||
return invalid('invalid reasoningLevel (use "on"|"off"|"stream")');
|
||||
}
|
||||
if (normalized === "off") delete next.reasoningLevel;
|
||||
else next.reasoningLevel = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("responseUsage" in patch) {
|
||||
const raw = patch.responseUsage;
|
||||
if (raw === null) {
|
||||
delete next.responseUsage;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeUsageDisplay(String(raw));
|
||||
if (!normalized) return invalid('invalid responseUsage (use "on"|"off")');
|
||||
if (normalized === "off") delete next.responseUsage;
|
||||
else next.responseUsage = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("elevatedLevel" in patch) {
|
||||
const raw = patch.elevatedLevel;
|
||||
if (raw === null) {
|
||||
delete next.elevatedLevel;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeElevatedLevel(String(raw));
|
||||
if (!normalized) return invalid('invalid elevatedLevel (use "on"|"off")');
|
||||
if (normalized === "off") delete next.elevatedLevel;
|
||||
else next.elevatedLevel = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("model" in patch) {
|
||||
const raw = patch.model;
|
||||
if (raw === null) {
|
||||
delete next.providerOverride;
|
||||
delete next.modelOverride;
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) return invalid("invalid model: empty");
|
||||
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
});
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: trimmed,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) return invalid(`invalid model: ${trimmed}`);
|
||||
|
||||
if (!params.loadGatewayModelCatalog) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
"model catalog unavailable",
|
||||
),
|
||||
};
|
||||
}
|
||||
const catalog = await params.loadGatewayModelCatalog();
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
catalog,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
defaultModel: resolvedDefault.model,
|
||||
});
|
||||
const key = modelKey(resolved.ref.provider, resolved.ref.model);
|
||||
if (!allowed.allowAny && !allowed.allowedKeys.has(key)) {
|
||||
return invalid(`model not allowed: ${key}`);
|
||||
}
|
||||
if (
|
||||
resolved.ref.provider === resolvedDefault.provider &&
|
||||
resolved.ref.model === resolvedDefault.model
|
||||
) {
|
||||
delete next.providerOverride;
|
||||
delete next.modelOverride;
|
||||
} else {
|
||||
next.providerOverride = resolved.ref.provider;
|
||||
next.modelOverride = resolved.ref.model;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ("sendPolicy" in patch) {
|
||||
const raw = patch.sendPolicy;
|
||||
if (raw === null) {
|
||||
delete next.sendPolicy;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeSendPolicy(String(raw));
|
||||
if (!normalized)
|
||||
return invalid('invalid sendPolicy (use "allow"|"deny")');
|
||||
next.sendPolicy = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("groupActivation" in patch) {
|
||||
const raw = patch.groupActivation;
|
||||
if (raw === null) {
|
||||
delete next.groupActivation;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeGroupActivation(String(raw));
|
||||
if (!normalized) {
|
||||
return invalid('invalid groupActivation (use "mention"|"always")');
|
||||
}
|
||||
next.groupActivation = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
store[storeKey] = next;
|
||||
return { ok: true, entry: next };
|
||||
}
|
||||
Reference in New Issue
Block a user