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
1 change: 1 addition & 0 deletions .oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@
{
"files": ["**/tests/**/*.{ts,tsx,mts,cts}", "**/*.test.{ts,tsx,mts,cts}"],
"rules": {
"anti-slop/no-module-mocking": "off",
"vitest/consistent-test-filename": [
"error",
{
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,15 @@ Run the validation the task requests. When it does not establish the behavior yo
- The workspace manager lives on `/` and the agent chat on `/chat`; browser execution belongs only to the declared worker's flat tool surface under `agent/subagents/worker/tools`.
- Keep each worker browser tool's schema and implementation together. Share the Kernel SDK client through `src/lib/kernel.ts`; do not add a Kernel extension or root browser connection.
- `agent/subagents/worker/lib` is for code genuinely shared by worker tools. Group a shared worker domain in a lower-case folder, such as `trace/domains.ts` or `autofill/provider.ts`; do not use it as a holding area for a tool's one-off logic.
- Validate runtime environment variables through `src/lib/env.ts`. `KERNEL_API_KEY` is required by the worker browser tools.
- Validate runtime environment variables through `src/env.ts`. `KERNEL_API_KEY` is required by the worker browser tools.
- Run `pnpm check` and `pnpm build` before handing off changes.

## Code organization

- Treat `src/lib` as a small shared infrastructure and contract boundary, not a default destination for application code. A file belongs there only when it has real cross-feature ownership; put database access in `db/services`, agent behavior under `agent`, and route or section behavior with its route.
- Do not add a generic `src/modules` layer. Give code a concrete owner and colocate it there. A route section owns its section components, forms, and local parsing; split it only when the files have distinct responsibilities.
- Prefer one cohesive call-site file for code used once. Do not add production factories, dependency containers, server wrappers, or files solely to make a unit test easier to mock.
- Mock imported modules at their owning or external boundary in tests. Do not export mutable dependency bags, dependency setters, reset hooks, or other test-only seams from production modules; keep production exports limited to application behavior and real domain contracts.
- Use lower-case file and folder names. When several files share a domain prefix, make that prefix a folder and name files for their role, such as `trace/domains.ts` rather than `trace-domains.ts`. Do not introduce camel-case filenames.
- Avoid catch-all names such as `manager`, `store`, `helpers`, or `utils` for feature ownership. Reuse an existing narrowly named boundary or place the code at the concrete owner instead.

Expand Down
2 changes: 1 addition & 1 deletion agent/channels/linq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
extractBrowserImageMarkdownReferences,
stripBrowserImageMarkdownReferences,
} from "../lib/linq-browser-image-markdown";
import { env } from "@/lib/env";
import { env } from "@/env";
import { consumeWorkerCancellationTurn } from "../lib/worker-cancellation-delivery";

const verifiedPhoneUserSchema = z.object({
Expand Down
8 changes: 3 additions & 5 deletions agent/hooks/session-owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,21 @@ import { ensureScope } from "@/db/services/scope";
import { claimSession } from "@/db/services/sessions";
import { scopeFromPrincipal } from "@/lib/access-scope";

export const sessionOwnerDependencies = { claimSession, ensureScope, saveChat };

export default defineHook({
events: {
async "session.started"(_event, ctx) {
const initiator = ctx.session.auth.initiator;
if (!initiator) return;

const scope = scopeFromPrincipal(initiator);
await sessionOwnerDependencies.ensureScope(scope);
await sessionOwnerDependencies.claimSession(scope, ctx.session.id);
await ensureScope(scope);
await claimSession(scope, ctx.session.id);
},
async "message.received"(_event, ctx) {
const initiator = ctx.session.auth.initiator;
if (!initiator) return;

await sessionOwnerDependencies.saveChat(scopeFromPrincipal(initiator), {
await saveChat(scopeFromPrincipal(initiator), {
sessionId: ctx.session.id,
});
},
Expand Down
2 changes: 1 addition & 1 deletion agent/lib/google-workspace/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { auth } from "@googleapis/gmail";
import { connect, type EveAuthorizationOptions } from "@vercel/connect/eve";
import type { ToolContext } from "eve/tools";
import { z } from "zod";
import { env } from "@/lib/env";
import { env } from "@/env";
import {
googleWorkspaceSubject,
googleWorkspaceScopes,
Expand Down
10 changes: 2 additions & 8 deletions agent/lib/linq-browser-image-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { get } from "@vercel/blob";
import type { AccessScope } from "@/lib/access-scope";
import { readReadyBrowserImageArtifact } from "@/db/services/browser-images";
import { maximumBrowserImageBytes } from "@/lib/browser-artifact";
import { env } from "@/lib/env";
import { env } from "@/env";
import { maximumWorkerCompletionImages } from "@/lib/worker-completion";
import {
extractBrowserImageMarkdownReferences,
Expand Down Expand Up @@ -81,14 +81,8 @@ async function readLinqBrowserImage(
!artifact.mediaType
)
return;
const blobAuth = env.BLOB_STORE_ID
? { storeId: env.BLOB_STORE_ID }
: env.BLOB_READ_WRITE_TOKEN
? { token: env.BLOB_READ_WRITE_TOKEN }
: undefined;
if (!blobAuth) return;
if (!env.BLOB_STORE_ID && !env.BLOB_READ_WRITE_TOKEN) return;
const result = await get(artifact.storagePathname, {
...blobAuth,
access: "private",
abortSignal: options.signal,
});
Expand Down
2 changes: 1 addition & 1 deletion agent/lib/profile-memory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { MemoryScopeContext } from "eve/memory";
import { z } from "zod";
import type { env } from "@/lib/env";
import type { env } from "@/env";

export function resolveProfileMemoryBackend(
environment: Pick<
Expand Down
2 changes: 1 addition & 1 deletion agent/lib/tests/linq-browser-image-delivery.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* oxlint-disable anti-slop/no-module-mocking, vitest/require-mock-type-parameters -- Linq delivery owns the Blob read. These fakes isolate storage without a production wrapper. */
/* oxlint-disable vitest/require-mock-type-parameters -- The Blob mock implements only the read operation exercised here. */
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AccessScope } from "@/lib/access-scope";

Expand Down
2 changes: 1 addition & 1 deletion agent/memory/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
resolveProfileMemoryBackend,
resolveProfileMemoryScope,
} from "../lib/profile-memory";
import { env } from "@/lib/env";
import { env } from "@/env";

const backend = resolveProfileMemoryBackend(env);
const provider =
Expand Down
25 changes: 5 additions & 20 deletions agent/subagents/worker/hooks/trace-telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,6 @@ import { scopeFromPrincipal } from "@/lib/access-scope";
import { taskCompletionOutputSchema } from "@/lib/worker-completion";
import { harvestBrowserTraceDomains } from "@/agent/subagents/worker/lib/trace/domains";

export const traceTelemetryDependencies = {
beginBrowserTrace,
completeBrowserTrace,
harvestBrowserTraceDomains,
listWorkerBrowserSessions,
recordBrowserTraceEvents,
};

function traceScope(ctx: HookContext) {
const initiator = ctx.session.auth.initiator;
return initiator ? scopeFromPrincipal(initiator) : undefined;
Expand All @@ -30,17 +22,10 @@ function logTraceFailure(sessionId: string, cause: unknown) {
}

async function sweepLiveBrowserDomains(scope: AccessScope, sessionId: string) {
const browsers = await traceTelemetryDependencies.listWorkerBrowserSessions(
scope,
sessionId
);
const browsers = await listWorkerBrowserSessions(scope, sessionId);
await Promise.all(
browsers.map((browser) =>
traceTelemetryDependencies.harvestBrowserTraceDomains(
scope,
sessionId,
browser
)
harvestBrowserTraceDomains(scope, sessionId, browser)
)
);
}
Expand All @@ -55,7 +40,7 @@ async function finishTrace(
) {
const scope = traceScope(ctx);
if (!scope) return;
await traceTelemetryDependencies.completeBrowserTrace(scope, ctx.session.id, {
await completeBrowserTrace(scope, ctx.session.id, {
completedAt: emittedAt,
resultMessage: outcome.resultMessage,
status: outcome.status,
Expand All @@ -69,7 +54,7 @@ export default defineHook({
try {
const scope = traceScope(ctx);
if (!scope) return;
await traceTelemetryDependencies.recordBrowserTraceEvents(
await recordBrowserTraceEvents(
scope,
ctx.session.id,
traceTimelineRows(event)
Expand All @@ -82,7 +67,7 @@ export default defineHook({
try {
const scope = traceScope(ctx);
if (!scope) return;
await traceTelemetryDependencies.beginBrowserTrace(scope, {
await beginBrowserTrace(scope, {
sessionId: ctx.session.id,
startedAt: event.meta.at,
task: event.data.message,
Expand Down
2 changes: 1 addition & 1 deletion agent/subagents/worker/lib/autofill/native.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Kernel from "@onkernel/sdk";
import { z } from "zod";
import { env } from "@/lib/env";
import { env } from "@/env";
import type { AutofillClaim } from "./protocol";
import {
classifyNativeLoginControl,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* oxlint-disable anti-slop/no-module-mocking -- The vault provider intentionally reads the concrete vault service. This focused test replaces only persistence I/O without adding a production-only factory. */
import { runInNewContext } from "node:vm";
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";
Expand Down
60 changes: 28 additions & 32 deletions agent/subagents/worker/lib/tests/vault-screenshot-mask.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,51 +2,51 @@
import { describe, expect, it, vi } from "vitest";
import { withVaultScreenshotMask } from "../vault-screenshot-mask";

const playwrightExecuteMock =
vi.fn<
(
sessionId: string,
body: { readonly code: string; readonly timeout_sec: number },
options: { readonly signal?: AbortSignal }
) => Promise<{ readonly success: boolean }>
>();
const dependencies = { execute: playwrightExecuteMock };
const mocks = vi.hoisted(() => ({
execute:
vi.fn<
(
sessionId: string,
body: { readonly code: string; readonly timeout_sec: number },
options: { readonly signal?: AbortSignal }
) => Promise<{ readonly success: boolean }>
>(),
}));

vi.mock("@/lib/kernel", () => ({
kernel: { browsers: { playwright: { execute: mocks.execute } } },
}));

describe("Vault screenshot masking", () => {
it("removes the mask with a fresh request after capture cancellation", async () => {
const controller = new AbortController();
playwrightExecuteMock.mockResolvedValue({ success: true });
mocks.execute.mockResolvedValue({ success: true });

await expect(
withVaultScreenshotMask(
"browser-1",
controller.signal,
async () => {
controller.abort();
throw new Error("Capture cancelled");
},
dependencies
)
withVaultScreenshotMask("browser-1", controller.signal, async () => {
controller.abort();
throw new Error("Capture cancelled");
})
).rejects.toThrow("Capture cancelled");

expect(playwrightExecuteMock).toHaveBeenCalledTimes(2);
expect(JSON.stringify(playwrightExecuteMock.mock.calls[0]?.[1])).toContain(
expect(mocks.execute).toHaveBeenCalledTimes(2);
expect(JSON.stringify(mocks.execute.mock.calls[0]?.[1])).toContain(
"append(style)"
);
expect(playwrightExecuteMock.mock.calls[0]?.[2]).toEqual({
expect(mocks.execute.mock.calls[0]?.[2]).toEqual({
signal: controller.signal,
});
expect(JSON.stringify(playwrightExecuteMock.mock.calls[1]?.[1])).toContain(
expect(JSON.stringify(mocks.execute.mock.calls[1]?.[1])).toContain(
"remove()"
);
expect(playwrightExecuteMock.mock.calls[1]?.[2]).toEqual({
expect(mocks.execute.mock.calls[1]?.[2]).toEqual({
signal: undefined,
});
});

it("keeps the shared mask until every overlapping capture completes", async () => {
let maskReferences = 0;
playwrightExecuteMock.mockImplementation(
mocks.execute.mockImplementation(
async (_sessionId: string, body: { code: string }) => {
maskReferences += body.code.includes("remainingRefs") ? -1 : 1;
return { success: true };
Expand All @@ -60,8 +60,7 @@ describe("Vault screenshot masking", () => {
() =>
new Promise<void>((resolve) => {
finishFirst = resolve;
}),
dependencies
})
);
await vi.waitFor(() => {
expect(maskReferences).toBe(1);
Expand All @@ -72,8 +71,7 @@ describe("Vault screenshot masking", () => {
() =>
new Promise<void>((resolve) => {
finishSecond = resolve;
}),
dependencies
})
);
await vi.waitFor(() => {
expect(maskReferences).toBe(2);
Expand All @@ -86,8 +84,6 @@ describe("Vault screenshot masking", () => {
finishSecond?.();
await second;
expect(maskReferences).toBe(0);
expect(JSON.stringify(playwrightExecuteMock.mock.calls)).toContain(
"vaultMaskRefs"
);
expect(JSON.stringify(mocks.execute.mock.calls)).toContain("vaultMaskRefs");
});
});
31 changes: 6 additions & 25 deletions agent/subagents/worker/lib/vault-screenshot-mask.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,23 @@
import { kernel } from "@/lib/kernel";

interface VaultScreenshotMaskDependencies {
execute(
sessionId: string,
body: { readonly code: string; readonly timeout_sec: number },
options: { readonly signal?: AbortSignal }
): Promise<{ readonly success: boolean }>;
}

const defaultDependencies: VaultScreenshotMaskDependencies = {
async execute(sessionId, body, options) {
return await kernel.browsers.playwright.execute(sessionId, body, options);
},
};

export async function withVaultScreenshotMask<T>(
sessionId: string,
signal: AbortSignal | undefined,
capture: () => Promise<T>,
dependencies: VaultScreenshotMaskDependencies = defaultDependencies
capture: () => Promise<T>
) {
await setVaultScreenshotMask(sessionId, "add", dependencies, signal);
await setVaultScreenshotMask(sessionId, "add", signal);
try {
return await capture();
} finally {
await setVaultScreenshotMask(
sessionId,
"remove",
dependencies,
undefined
).catch(() => undefined);
await setVaultScreenshotMask(sessionId, "remove", undefined).catch(
() => undefined
);
}
}

async function setVaultScreenshotMask(
sessionId: string,
action: "add" | "remove",
dependencies: VaultScreenshotMaskDependencies,
signal?: AbortSignal
) {
const styleId = "vault-screenshot-mask";
Expand Down Expand Up @@ -76,7 +57,7 @@ for (const currentContext of browser.contexts()) {
}
}
return true;`;
const result = await dependencies.execute(
const result = await kernel.browsers.playwright.execute(
sessionId,
{ code, timeout_sec: 10 },
{ signal }
Expand Down
16 changes: 6 additions & 10 deletions agent/subagents/worker/tools/capture_browser_image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
maximumBrowserImageBytes,
sniffBrowserImageMediaType,
} from "@/lib/browser-artifact";
import { env } from "@/lib/env";
import { env } from "@/env";
import { kernel } from "@/lib/kernel";

const regionSchema = z.object({
Expand Down Expand Up @@ -297,15 +297,11 @@ async function persistCapturedImage(
throw new Error("The captured resource is not a supported browser image.");
const contentHash = createHash("sha256").update(input.bytes).digest("hex");
const storagePathname = `${reservation.storagePathname}/${contentHash}`;
const blobAuth = env.BLOB_STORE_ID
? { storeId: env.BLOB_STORE_ID }
: env.BLOB_READ_WRITE_TOKEN
? { token: env.BLOB_READ_WRITE_TOKEN }
: undefined;
if (!blobAuth) throw new Error("Browser image storage is not configured.");
if (!env.BLOB_STORE_ID && !env.BLOB_READ_WRITE_TOKEN) {
throw new Error("Browser image storage is not configured.");
}

await put(storagePathname, Buffer.from(input.bytes), {
...blobAuth,
access: "private",
abortSignal: signal,
addRandomSuffix: false,
Expand All @@ -324,11 +320,11 @@ async function persistCapturedImage(
storagePathname,
});
if (finalized.storagePathname !== storagePathname) {
await del(storagePathname, blobAuth).catch(() => undefined);
await del(storagePathname).catch(() => undefined);
}
return finalized.image;
} catch (error) {
await del(storagePathname, blobAuth).catch(() => undefined);
await del(storagePathname).catch(() => undefined);
throw error;
}
}
Expand Down
Loading