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
23 changes: 23 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"**/.next/**",
"**/.eve/**",
"**/.scratch/**",
".agents/**",
".claude/**",
"apps/api/src/generated/**",
"packages/db/src/generated/**",
"packages/ui/src/components/**",
Expand Down Expand Up @@ -41,6 +43,27 @@
"rules": {
"anti-slop/no-chained-type-assertions": "off"
}
},
{
"files": ["packages/validation/src/**"],

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This disables no-unknown-parameters for every future module added under packages/validation/src, not just the files that legitimately decode unknown at their entry points. A new non-boundary function that accepts unknown anywhere in the package will silently pass lint, contradicting the boundary-decoder contract the package exists to enforce. Narrow the override to the specific files that parse unknown (agent-manifest.ts, and index.ts's parse) instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .oxlintrc.json, line 48:

<comment>This disables `no-unknown-parameters` for every future module added under packages/validation/src, not just the files that legitimately decode `unknown` at their entry points. A new non-boundary function that accepts `unknown` anywhere in the package will silently pass lint, contradicting the boundary-decoder contract the package exists to enforce. Narrow the override to the specific files that parse `unknown` (agent-manifest.ts, and index.ts's `parse`) instead.</comment>

<file context>
@@ -41,6 +43,27 @@
 			}
+		},
+		{
+			"files": ["packages/validation/src/**"],
+			"rules": {
+				"anti-slop/no-unknown-parameters": "off"
</file context>
Suggested change
"files": ["packages/validation/src/**"],
"files": ["packages/validation/src/agent-manifest.ts", "packages/validation/src/index.ts"],
Fix with cubic

"rules": {
"anti-slop/no-unknown-parameters": "off"
}
},
{
"files": [
"packages/telemetry/src/**",
"apps/api/src/logging/**",
"packages/db/src/fields.ts",
"packages/db/src/fields-shape.ts",
"apps/api/src/fields/**",
"apps/app/components/crm/inline-field.tsx"
],
"rules": {
"anti-slop/no-unsafe-dictionary-type": "off",
"anti-slop/no-unknown-parameters": "off",
"anti-slop/no-runtime-typeof": "off"
}
}
]
}
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,10 @@ const slack = manifest.actions.find(
const id = slack?.destination.id;
```

`apps/agent/agent/lib/agent-manifest.ts` is the pattern. Rules that follow from
`packages/validation/src/agent-manifest.ts` is the pattern. A shape that crosses
a package boundary — a `Json` column two apps read, a payload one app writes and
another consumes — lives in `packages/validation/src`, one module per shape, and
is imported by subpath (`@crm/validation/agent-manifest`). Rules that follow from
it:

- The schema describes what is **actually stored**, not the loosest thing that
Expand Down
81 changes: 42 additions & 39 deletions apps/agent/agent/channels/crm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { timingSafeEqual } from "node:crypto";
import { EnrichmentStatus, Prisma } from "@crm/db";
import { MAX_ATTEMPTS } from "@crm/db/agent-tasks";
import { schemas } from "@crm/validation";
import { eveTurnFailure } from "@crm/validation/eve-stream";
import { defineChannel, GET, POST } from "eve/channels";
import { z } from "zod";
import { persistBuilderInputRequest } from "../lib/builder-input";
import { verifyKey } from "../lib/context-dev";
import {
Expand All @@ -26,14 +28,36 @@ import {
} from "../lib/dispatch";
import { DISPATCH } from "../lib/dispatch-config";
import { settle } from "../lib/enrichment";
import { finishRun } from "../lib/run-runtime";
import { finishRun, runResultOf } from "../lib/run-runtime";
import { attribute } from "../lib/session-purpose";
import { createSlackChannel } from "../lib/slack-membership";
import { completeTask, taskSubject } from "../lib/tasks";

const TASK_MARKER = "task:";
const STALE_QUEUE_MS = DISPATCH.sweep.staleQueueMs;

type InternalDispatchPrincipal = {
readonly authenticator: string;
readonly principalId: string;
readonly principalType: string;
} | null;

const identifier = z.string().trim().min(1).nullable().catch(null);

const cancelRunRequest = z.object({ runId: identifier }).catch({ runId: null });

const verifyKeyRequest = z
.object({ apiKey: identifier })
.catch({ apiKey: null });

const receiveTarget = z
.object({
builderSubmissionId: z.string().nullable().catch(null),
runId: z.string().nullable().catch(null),
taskId: z.string().nullable().catch(null),
})
.catch({ builderSubmissionId: null, runId: null, taskId: null });

function authorised(request: Request): boolean {
const secret = process.env.AGENT_BRIDGE_SECRET?.trim();
if (!secret) return false;
Expand Down Expand Up @@ -140,10 +164,9 @@ export default defineChannel({
return new Response("Unauthorized", { status: 401 });
}

const body = (await request.json().catch(() => null)) as {
runId?: unknown;
} | null;
const runId = typeof body?.runId === "string" ? body.runId.trim() : null;
const { runId } = cancelRunRequest.parse(
await request.json().catch(() => null),
);
if (!runId) {
return Response.json({ error: "No run id was sent." }, { status: 400 });
}
Expand Down Expand Up @@ -184,12 +207,9 @@ export default defineChannel({
return new Response("Unauthorized", { status: 401 });
}

const body = (await request.json().catch(() => null)) as {
apiKey?: unknown;
} | null;

const apiKey =
typeof body?.apiKey === "string" ? body.apiKey.trim() : null;
const { apiKey } = verifyKeyRequest.parse(
await request.json().catch(() => null),
);

if (!apiKey) {
return Response.json(
Expand Down Expand Up @@ -249,9 +269,7 @@ export default defineChannel({
async "turn.failed"(data, channel) {
const taskId = taskFromToken(channel.continuationToken);
const reason =
typeof data === "object" && data && "message" in data
? String((data as { message: unknown }).message)
: "The agent turn failed.";
eveTurnFailure.parse(data).message ?? "The agent turn failed.";

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When data.message is non-string or null, this parser discards it and reports the generic failure instead of preserving the previous String(data.message) behavior. Preserve the prior coercion while normalizing the failure payload.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/channels/crm.ts, line 272:

<comment>When `data.message` is non-string or `null`, this parser discards it and reports the generic failure instead of preserving the previous `String(data.message)` behavior. Preserve the prior coercion while normalizing the failure payload.</comment>

<file context>
@@ -249,9 +269,7 @@ export default defineChannel({
-				typeof data === "object" && data && "message" in data
-					? String((data as { message: unknown }).message)
-					: "The agent turn failed.";
+				eveTurnFailure.parse(data).message ?? "The agent turn failed.";
 
 			if (taskId) {
</file context>
Fix with cubic


if (taskId) {
const subject = await taskSubject(taskId);
Expand Down Expand Up @@ -300,7 +318,7 @@ export default defineChannel({
try {
await finishRun(runId, {
summary: run.summary ?? "The agent run completed.",
result: recordOf(run.result),
result: runResultOf(run.result),
});
} catch (error) {
await failRun(
Expand Down Expand Up @@ -357,47 +375,32 @@ export default defineChannel({
},

async receive(input, { send }) {
const builderSubmissionId =
typeof input.target?.builderSubmissionId === "string"
? input.target.builderSubmissionId
: null;
if (builderSubmissionId) {
const target = receiveTarget.parse(input.target);
if (target.builderSubmissionId) {
assertInternalDispatchAuth(input.auth);
return dispatchBuilderSubmission(builderSubmissionId, send);
return dispatchBuilderSubmission(target.builderSubmissionId, send);
}

const runId =
typeof input.target?.runId === "string" ? input.target.runId : null;
if (runId) {
if (target.runId) {
assertInternalDispatchAuth(input.auth);
return dispatchAgentRun(runId, send);
return dispatchAgentRun(target.runId, send);
}

const taskId =
typeof input.target?.taskId === "string" ? input.target.taskId : null;

return send(input.message, {
auth: input.auth,
continuationToken: taskId
? taskToken(taskId)
continuationToken: target.taskId
? taskToken(target.taskId)
: `crm:adhoc:${crypto.randomUUID()}`,
});
},
});

function assertInternalDispatchAuth(value: unknown): void {
const auth = recordOf(value);
function assertInternalDispatchAuth(auth: InternalDispatchPrincipal): void {
if (
auth.authenticator !== "app" ||
auth?.authenticator !== "app" ||
auth.principalType !== "runtime" ||
auth.principalId !== "eve:app"
) {
throw new Error("Internal agent dispatch requires Eve app authentication.");
}
}

function recordOf(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
10 changes: 7 additions & 3 deletions apps/agent/agent/hooks/activity.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { defineHook, type HookEvent } from "eve/hooks";
import { z } from "zod";

type ActionRequest = HookEvent<"actions.requested">["data"]["actions"][number];
type ActionResult = HookEvent<"action.result">["data"]["result"];
type ActionInput = ActionRequest["input"];

const inputText = z.string().nullable().catch(null);

const SHOW_CONTENT = process.env.NODE_ENV !== "production";
const MAX_IN_FLIGHT = 256;
Expand All @@ -16,14 +20,14 @@ function line(symbol: string, text: string): void {
console.error(`[agent] ${symbol} ${text}`);
}

function preview(input: unknown): string {
if (!SHOW_CONTENT || typeof input !== "object" || input === null) return "";
function preview(input: ActionInput): string {
if (!SHOW_CONTENT) return "";

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The refactor removed the typeof input !== "object" || input === null guard that previously protected the Object.entries(input) loop. preview now relies solely on the compile-time ActionInput type. This hook reads raw event data directly (unlike agent-builder-state, which parses through the zod schema), so a malformed action whose input is null/undefined or a primitive at runtime will throw in Object.entries instead of returning "", contradicting the PR's stated goal that malformed inputs still degrade the same way. An exception here would propagate out of the actions.requested hook handler and can break the session it is only meant to log.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/hooks/activity.ts, line 24:

<comment>The refactor removed the `typeof input !== "object" || input === null` guard that previously protected the `Object.entries(input)` loop. `preview` now relies solely on the compile-time `ActionInput` type. This hook reads raw event data directly (unlike agent-builder-state, which parses through the zod schema), so a malformed action whose `input` is null/undefined or a primitive at runtime will throw in `Object.entries` instead of returning "", contradicting the PR's stated goal that malformed inputs still degrade the same way. An exception here would propagate out of the `actions.requested` hook handler and can break the session it is only meant to log.</comment>

<file context>
@@ -16,14 +20,14 @@ function line(symbol: string, text: string): void {
-function preview(input: unknown): string {
-	if (!SHOW_CONTENT || typeof input !== "object" || input === null) return "";
+function preview(input: ActionInput): string {
+	if (!SHOW_CONTENT) return "";
 
 	const parts: string[] = [];
</file context>
Suggested change
if (!SHOW_CONTENT) return "";
if (!SHOW_CONTENT) return "";
if (typeof input !== "object" || input === null) return "";
Fix with cubic


const parts: string[] = [];

for (const [key, value] of Object.entries(input)) {
if (value === null || value === undefined) continue;
const text = typeof value === "string" ? value : JSON.stringify(value);
const text = inputText.parse(value) ?? JSON.stringify(value);
parts.push(`${key}=${truncate(text ?? String(value), 48)}`);
}

Expand Down
47 changes: 27 additions & 20 deletions apps/agent/agent/hooks/audit.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
import { db, Prisma } from "@crm/db";
import { defineHook } from "eve/hooks";
import { z } from "zod";
import { isTransportOnlyEvent } from "../lib/event-persistence";
import { currentFocus } from "../lib/focus";
import { lockAgentRun } from "../lib/run-state";
import { attribute, purposeOf } from "../lib/session-purpose";

const finiteNumber = z.number().refine(Number.isFinite).nullable().catch(null);

const stepUsage = z
.object({
usage: z
.object({
inputTokens: finiteNumber,
outputTokens: finiteNumber,
costUsd: finiteNumber,
})
.catch({ inputTokens: null, outputTokens: null, costUsd: null }),
})
.catch({ usage: { inputTokens: null, outputTokens: null, costUsd: null } });

const completedMessage = z
.object({ message: z.string().nullable().catch(null) })
.catch({ message: null });

export default defineHook({
events: {
async "*"(event, ctx) {
Expand All @@ -13,7 +32,9 @@ export default defineHook({
if (!id || isTransportOnlyEvent(event.type)) return;

try {
const data = ("data" in event ? (event.data ?? {}) : {}) as object;
const data = (
"data" in event ? (event.data ?? {}) : {}
) as Prisma.InputJsonObject;
const emittedAt = event.meta?.at ? new Date(event.meta.at) : new Date();
const purpose = purposeOf(ctx);
const conversationId =
Expand Down Expand Up @@ -86,7 +107,7 @@ async function persistRunEvent(
tx: Prisma.TransactionClient,
eventId: string,
type: string,
data: object,
data: Prisma.InputJsonObject,
emittedAt: Date,
ctx: Parameters<typeof purposeOf>[0] & { session: { id: string } },
) {
Expand Down Expand Up @@ -128,17 +149,13 @@ async function persistRunEvent(
runId,
sequence,
type,
data: data as Prisma.InputJsonValue,
data,
emittedAt,
},
});

if (type === "step.completed") {
const usage = recordOf(data).usage;
const values = recordOf(usage);
const inputTokens = numberOf(values.inputTokens);
const outputTokens = numberOf(values.outputTokens);
const costUsd = numberOf(values.costUsd);
const { inputTokens, outputTokens, costUsd } = stepUsage.parse(data).usage;
const current = await tx.agentRun.findUniqueOrThrow({
where: { id: runId },
select: { inputTokens: true, outputTokens: true, costUsd: true },
Expand All @@ -161,8 +178,8 @@ async function persistRunEvent(
}

if (type === "message.completed") {
const message = recordOf(data).message;
if (typeof message === "string" && message.trim()) {
const { message } = completedMessage.parse(data);
if (message?.trim()) {
await tx.agentRun.updateMany({
where: { id: runId, status: "RUNNING" },
data: { summary: message.slice(0, 1000) },
Expand All @@ -174,13 +191,3 @@ async function persistRunEvent(
function isRootSession(ctx: Parameters<typeof purposeOf>[0]): boolean {
return !("parent" in ctx.session) || !ctx.session.parent;
}

function recordOf(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}

function numberOf(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
14 changes: 9 additions & 5 deletions apps/agent/agent/hooks/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import { db } from "@crm/db";
import { readAgentModel } from "@crm/db/settings";
import { agentError, modelError } from "@crm/telemetry";
import { defineHook } from "eve/hooks";
import { z } from "zod";

type SessionPrincipal = {
readonly attributes?: Readonly<Record<string, string | readonly string[]>>;
} | null;

const attributeText = z.string().trim().min(1).nullable().catch(null);

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This change re-declares two things already defined in apps/agent/agent/lib/session-purpose.ts: the session attributes type (SessionAttributes = Readonly<Record<string, string | readonly string[]>>) and the identical attributeText = z.string().trim().min(1).nullable().catch(null) schema. This contradicts the PR's stated goal of a single source of truth for session shapes. Extract the schema and attribute type into a shared module (or reuse session-purpose's) and import them here instead of defining local copies, so a shape change is made once.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/hooks/telemetry.ts, line 11:

<comment>This change re-declares two things already defined in apps/agent/agent/lib/session-purpose.ts: the session attributes type (`SessionAttributes = Readonly<Record<string, string | readonly string[]>>`) and the identical `attributeText = z.string().trim().min(1).nullable().catch(null)` schema. This contradicts the PR's stated goal of a single source of truth for session shapes. Extract the schema and attribute type into a shared module (or reuse session-purpose's) and import them here instead of defining local copies, so a shape change is made once.</comment>

<file context>
@@ -2,6 +2,13 @@ import { db } from "@crm/db";
+	readonly attributes?: Readonly<Record<string, string | readonly string[]>>;
+} | null;
+
+const attributeText = z.string().trim().min(1).nullable().catch(null);
 
 let modelId: string | null = null;
</file context>
Fix with cubic


let modelId: string | null = null;

Expand All @@ -27,11 +34,8 @@ const MODEL_CODES = [
"unauthorized",
];

function taskKind(
auth: { attributes?: Record<string, unknown> } | null,
): string | null {
const kind = auth?.attributes?.taskKind;
return typeof kind === "string" && kind.trim() ? kind.trim() : null;
function taskKind(auth: SessionPrincipal): string | null {
return attributeText.parse(auth?.attributes?.taskKind);
}

function looksLikeModel(code: string): boolean {
Expand Down
Loading
Loading