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
37 changes: 35 additions & 2 deletions apps/extension/.oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,44 @@
"vitest/prefer-to-be-falsy": "off",
"vitest/prefer-to-be-truthy": "off",
"vitest/require-hook": "off",
"vitest/require-test-timeout": "off"
"vitest/require-test-timeout": "off",
"anti-slop/no-chained-type-assertions": "error",
"anti-slop/no-known-value-widening": "error",
"anti-slop/no-object-parameters": "error",
"anti-slop/no-reflect-apply": "error",
"anti-slop/no-reflect-get": "error",
"anti-slop/no-runtime-typeof": "error",
"anti-slop/no-unknown-returns": "error",
"zod-utils/no-inline-zod-schema": "error"
},
"env": {
"browser": true,
"builtin": true,
"node": true
}
},
"jsPlugins": [
{
"name": "anti-slop",
"specifier": "../../tools/oxlint/anti-slop/index.ts"
},
{
"name": "zod-utils",
"specifier": "../../tools/oxlint/zod-utils.mjs"
}
],
"overrides": [
{
"files": ["**/*.test.ts", "**/*.test.tsx", "tests/**", "scripts/**"],
"rules": {
"anti-slop/no-chained-type-assertions": "off",
"anti-slop/no-known-value-widening": "off",
"anti-slop/no-object-parameters": "off",
"anti-slop/no-reflect-apply": "off",
"anti-slop/no-reflect-get": "off",
"anti-slop/no-runtime-typeof": "off",
"anti-slop/no-unknown-returns": "off",
"zod-utils/no-inline-zod-schema": "off"
}
}
]
}
24 changes: 20 additions & 4 deletions apps/extension/entrypoints/background.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable max-lines */
import { storage } from '#imports';
import { z } from 'zod';
import { buildPendingMemoryDraft } from '@/src/shared/agent-memories';
import { savePendingAgentMemoryDraft } from '@/src/shared/agent-memories-storage';
import {
Expand Down Expand Up @@ -64,16 +65,31 @@ interface ChromeRuntimeApi {
* extension `id` but reports the host page's web origin, while an extension page reports an
* extension-scheme origin (`chrome-extension://` on Chrome, `moz-extension://` on Firefox).
*/
const extensionSchemeSchema = z
.string()
.refine(value => value.startsWith('chrome-extension://') || value.startsWith('moz-extension://'));

const isExtensionScheme = (value: unknown): boolean =>
typeof value === 'string' &&
(value.startsWith('chrome-extension://') || value.startsWith('moz-extension://'));
extensionSchemeSchema.safeParse(value).success;

const extensionSenderSchema = z.object({
id: z.string().optional(),
origin: z.unknown().optional(),
url: z.unknown().optional(),
});

const isTrustedExtensionSender = (sender: unknown, runtimeId: string | undefined): boolean => {
if (runtimeId === undefined || typeof sender !== 'object' || sender === null) {
if (runtimeId === undefined) {
return false;
}

const parsed = extensionSenderSchema.safeParse(sender);

if (!parsed.success) {
return false;
}

const { id, origin, url } = sender as { id?: unknown; origin?: unknown; url?: unknown };
const { id, origin, url } = parsed.data;

// Same-extension is already pinned by `id === runtimeId`, so origin only separates an extension page from a content script.
return id === runtimeId && (isExtensionScheme(origin) || isExtensionScheme(url));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@ import { CollapsibleCodeBlock } from './collapsible-code-block.tsx';
const remarkPlugins = [remarkGfm];

const extractCodeText = (codeChildren: unknown): string | undefined => {
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- Walks react-markdown's ReactNode children tree, not a parseable data contract.
if (typeof codeChildren === 'string') {
return codeChildren;
}

if (Array.isArray(codeChildren) && codeChildren.every(part => typeof part === 'string')) {
if (
Array.isArray(codeChildren) &&
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- Walks react-markdown's ReactNode children tree, not a parseable data contract.
codeChildren.every(part => typeof part === 'string')
) {
return codeChildren.join('');
}

Expand All @@ -39,6 +44,7 @@ const extractCodeChild = (
children: ReactNode
): { readonly className: string | undefined; readonly code: string } | undefined => {
for (const child of asNodeList(children)) {
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- Walks a ReactNode children tree, not a parseable data contract.
if (child === null || typeof child !== 'object') {
// Skip non-element children (text nodes, null).
} else if (
Expand Down Expand Up @@ -81,13 +87,17 @@ const assistantMarkdownComponentsStreaming = createAssistantMarkdownComponents(t
const assistantMarkdownComponentsFinalized = createAssistantMarkdownComponents(false);

const formatToolValue = (value: unknown): string => {
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- Formats an arbitrary tool-result value by its JS runtime type; no fixed data contract to parse against.
if (typeof value === 'string') {
return value;
}

if (
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- Formats an arbitrary tool-result value by its JS runtime type; no fixed data contract to parse against.
typeof value === 'number' ||
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- Formats an arbitrary tool-result value by its JS runtime type; no fixed data contract to parse against.
typeof value === 'boolean' ||
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- Formats an arbitrary tool-result value by its JS runtime type; no fixed data contract to parse against.
typeof value === 'bigint' ||
value === null
) {
Expand Down
31 changes: 14 additions & 17 deletions apps/extension/entrypoints/sidepanel/agent-conversation-schemas.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { z } from 'zod';
import type { RemoteMcpAgentToolName, WorkflowToolName } from '@/src/shared/agent-conversation';
import type { WebMcpGatewayToolName } from '@/src/shared/kilo-gateway-chat-client';

const remoteMcpAgentToolNameSchema = z.templateLiteral(['mcp_', z.string()]);
const workflowToolNameSchema = z.enum([
'delete_workflow',
'get_workflow',
'run_workflow',
'save_memory',
'save_workflow',
'search_workflows',
]);
const genericStringSchema = z.string();

export const conversationEventSchema = z.union([
z.object({
id: z.string(),
Expand Down Expand Up @@ -42,9 +52,7 @@ export const conversationEventSchema = z.union([
z.object({
arguments: z.record(z.string(), z.unknown()),
id: z.string(),
name: z.custom<RemoteMcpAgentToolName>(
value => typeof value === 'string' && value.startsWith('mcp_')
),
name: remoteMcpAgentToolNameSchema,
providerToolCallId: z.string().optional(),
remoteToolName: z.string(),
serverId: z.string(),
Expand All @@ -54,18 +62,7 @@ export const conversationEventSchema = z.union([
z.object({
arguments: z.record(z.string(), z.unknown()),
id: z.string(),
name: z.custom<WorkflowToolName>(
(value): value is WorkflowToolName =>
typeof value === 'string' &&
[
'delete_workflow',
'get_workflow',
'run_workflow',
'save_memory',
'save_workflow',
'search_workflows',
].includes(value)
),
name: workflowToolNameSchema,
providerToolCallId: z.string().optional(),
tabId: z.number(),
type: z.literal('tool-call'),
Expand All @@ -75,7 +72,7 @@ export const conversationEventSchema = z.union([
definitionSignature: z.string(),
documentId: z.string(),
id: z.string(),
name: z.custom<WebMcpGatewayToolName>(value => typeof value === 'string'),
name: z.custom<WebMcpGatewayToolName>(value => genericStringSchema.safeParse(value).success),
providerToolCallId: z.string().optional(),
tabId: z.number(),
type: z.literal('tool-call'),
Expand Down
24 changes: 13 additions & 11 deletions apps/extension/entrypoints/sidepanel/agent-safe-tool-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,23 +145,23 @@ const readViewportScreenshot = async (tabId: number): Promise<EvalTabResult> =>
const getSnapshot = async (
tabId: number,
options: { readonly query?: string; readonly textStart?: number } = {}
): Promise<PageSnapshot | string> => {
): Promise<{ error: string; ok: false } | { ok: true; value: PageSnapshot }> => {
const result = await readPageSnapshot(tabId, options);

if (!result.ok) {
return result.error;
return { error: result.error, ok: false };
}

const snapshot = pageSnapshotSchema.safeParse(result.value);

if (!snapshot.success) {
return 'Page snapshot was invalid.';
return { error: 'Page snapshot was invalid.', ok: false };
}

const pageSnapshot = toPageSnapshot(snapshot.data);
cacheSnapshot(tabId, pageSnapshot);

return pageSnapshot;
return { ok: true, value: pageSnapshot };
};

const searchableFields = ['text', 'label', 'href', 'role', 'tag'] as const;
Expand Down Expand Up @@ -277,15 +277,17 @@ const runSafeToolCall = async (
}

if (toolCall.name === 'get_page_snapshot') {
const snapshot = await getSnapshot(
const snapshotResult = await getSnapshot(
toolCall.tabId,
toolCall.textStart === undefined ? {} : { textStart: toolCall.textStart }
);

if (typeof snapshot === 'string') {
return { error: snapshot, ok: false };
if (!snapshotResult.ok) {
return { error: snapshotResult.error, ok: false };
}

const snapshot = snapshotResult.value;

// Serving the same unchanged page again only burns context and invites a snapshot loop; a compact marker tells the model to act on what it already has.
const contentKey = JSON.stringify({
nodes: snapshot.nodes,
Expand Down Expand Up @@ -326,13 +328,13 @@ const runSafeToolCall = async (
}

// One injection serves both halves of find_in_page: the snapshot nodes and the full-page text matches come from the same walk.
const snapshot = await getSnapshot(toolCall.tabId, { query });
const snapshotResult = await getSnapshot(toolCall.tabId, { query });

if (typeof snapshot === 'string') {
return { error: snapshot, ok: false };
if (!snapshotResult.ok) {
return { error: snapshotResult.error, ok: false };
}

return { ok: true, value: getFindResults(snapshot, query) };
return { ok: true, value: getFindResults(snapshotResult.value, query) };
};

// One executor per turn: its unchanged-snapshot memory must not cross conversations (or a compaction), where the marker would reference a snapshot the model never saw.
Expand Down
20 changes: 14 additions & 6 deletions apps/extension/entrypoints/sidepanel/agent-web-mcp-tool-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { browser } from '#imports';
import { z } from 'zod';
import type { WebMcpToolCallEvent } from '@/src/shared/agent-conversation';
import {
WEB_MCP_DISCOVER_MESSAGE,
Expand All @@ -13,23 +14,30 @@ import { capRemoteMcpToolResult } from '@/src/shared/remote-mcp-tools';
* result enters conversation state as an object, not a quoted string. A
* non-JSON string and null pass through verbatim.
*/
const parseWebMcpResult = (value: unknown): unknown => {
if (typeof value !== 'string') {
const stringSchema = z.string();
const jsonRecordSchema = z.record(z.string(), z.unknown());

const parseWebMcpResult = (value: unknown) => {
const asString = stringSchema.safeParse(value);
if (!asString.success) {
return value;
}

try {
return JSON.parse(value);
const parsed: unknown = JSON.parse(asString.data);
return parsed;
} catch {
return value;
return asString.data;
}
};

const isRecordObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
jsonRecordSchema.safeParse(value).success;

const isWebMcpDiscoveryResult = (value: unknown): value is WebMcpDiscoveryResult =>
isRecordObject(value) && typeof value['documentId'] === 'string' && Array.isArray(value['tools']);
isRecordObject(value) &&
stringSchema.safeParse(value['documentId']).success &&
Array.isArray(value['tools']);

/*
* Discover the WebMCP tools registered by the selected tab's page through the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,24 @@ const postSearch = async (query: string, context: WebSearchContext): Promise<Fet
}
};

const readJson = async (response: Response): Promise<unknown> => {
const readJson = async <Value>(
response: Response,
schema: z.ZodType<Value>
): Promise<Value | undefined> => {
try {
return await response.json();
const raw: unknown = await response.json();
const parsed = schema.safeParse(raw);
return parsed.success ? parsed.data : undefined;
} catch {
return undefined;
}
};

const toResultEntry = (result: z.infer<typeof exaResponseSchema>['results'][number]) => ({
...(result.publishedDate === undefined ? {} : { publishedDate: result.publishedDate }),
...(typeof result.title === 'string' && result.title !== '' ? { title: result.title } : {}),
...(result.title !== undefined && result.title !== null && result.title !== ''
? { title: result.title }
: {}),
...(result.text === undefined ? {} : { text: result.text.slice(0, MAX_SNIPPET_CHARS) }),
url: result.url,
});
Expand Down Expand Up @@ -99,25 +106,24 @@ export const executeWebSearchToolCall = async (
}

const { response } = outcome;
const body = await readJson(response);

if (!response.ok) {
// The proxy explains allowance and balance failures in its error body; pass that through so the model can tell the user why.
const errorBody = errorBodySchema.safeParse(body);
const detail = errorBody.success ? ` ${errorBody.data.error}` : '';
const errorBody = await readJson(response, errorBodySchema);
const detail = errorBody === undefined ? '' : ` ${errorBody.error}`;
return {
error: `Web search failed with status ${String(response.status)}.${detail}`,
ok: false,
};
}

const parsed = exaResponseSchema.safeParse(body);
const parsed = await readJson(response, exaResponseSchema);

if (!parsed.success) {
if (parsed === undefined) {
return { error: 'Web search returned an invalid response.', ok: false };
}

const results = parsed.data.results.slice(0, MAX_RESULTS).map(result => toResultEntry(result));
const results = parsed.results.slice(0, MAX_RESULTS).map(result => toResultEntry(result));

return {
ok: true,
Expand Down
Loading