Skip to content
Closed
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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ on:
push:
branches: [main]
pull_request:
branches: [main]

# Cancel in-progress runs when a new commit is pushed to the same PR
concurrency:
Expand Down Expand Up @@ -63,6 +62,7 @@ jobs:
- 'apps/web/jest.config.ts'
- 'apps/web/postcss.config.mjs'
- 'apps/web/sentry.*'
- 'packages/agent-harness/**'
- 'packages/db/**'
- 'packages/encryption/**'
- 'packages/trpc/**'
Expand Down Expand Up @@ -99,6 +99,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Verify agent harness CI contracts and boundaries
run: pnpm run test:agent-harness-ci

- name: Plugin shared-schemas sync check
run: bash scripts/sync-plugin-shared.sh --check

Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/kilo-app-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
- 'apps/mobile/**'
- 'packages/trpc/**'
- 'apps/web/src/routers/**'
- 'packages/agent-harness/**'
- 'packages/app-shared/**'
- 'packages/kilo-chat/**'
- 'packages/kilo-chat-hooks/**'
Expand All @@ -16,11 +17,11 @@ on:
- 'pnpm-lock.yaml'
- '.github/workflows/kilo-app-ci.yml'
pull_request:
branches: [main]
paths:
- 'apps/mobile/**'
- 'packages/trpc/**'
- 'apps/web/src/routers/**'
- 'packages/agent-harness/**'
- 'packages/app-shared/**'
- 'packages/kilo-chat/**'
- 'packages/kilo-chat-hooks/**'
Expand Down
21 changes: 19 additions & 2 deletions apps/web/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,20 @@ import type { Config } from 'jest';
const config: Config = {
testEnvironment: 'node',
transform: {
'^.+\\.(t|j)sx?$': [
'^.+\\.tsx$': [
'@swc/jest',
{
jsc: {
parser: { syntax: 'typescript', tsx: true, decorators: true },
transform: {
legacyDecorator: true,
decoratorMetadata: true,
react: { runtime: 'automatic' },
},
},
},
],
'^.+\\.(ts|jsx?)$': [
'@swc/jest',
{
jsc: {
Expand Down Expand Up @@ -35,7 +48,11 @@ const config: Config = {
'^@/(.*)$': '<rootDir>/src/$1',
'^server-only$': '<rootDir>/src/tests/setup/__mocks__/server-only.js',
},
testMatch: ['**/src/**/*.test.ts', '<rootDir>/../../packages/db/src/**/*.test.ts'],
testMatch: [
'**/src/**/*.test.ts',
'<rootDir>/src/components/quick-chat/**/*.test.tsx',
'<rootDir>/../../packages/db/src/**/*.test.ts',
],
testPathIgnorePatterns: [
'<rootDir>/../../.kilocode/',
'<rootDir>/../../services/cloud-agent-next/',
Expand Down
8 changes: 8 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@chat-adapter/state-redis": "4.36.0",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@kilocode/agent-harness": "workspace:*",
"@kilocode/app-shared": "workspace:*",
"@kilocode/auto-routing-contracts": "workspace:*",
"@kilocode/cloud-agent-profile": "workspace:*",
Expand Down Expand Up @@ -140,6 +141,7 @@
"fflate": "0.8.2",
"form-data": "4.0.6",
"google-auth-library": "10.6.2",
"idb": "8.0.3",
"jotai": "2.18.1",
"jotai-minidb": "0.0.8",
"js-cookie": "3.0.8",
Expand Down Expand Up @@ -185,6 +187,10 @@
"@swc/jest": "0.2.39",
"@tailwindcss/postcss": "4.2.4",
"@tailwindcss/typography": "0.5.19",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/jest": "30.0.0",
"@types/jsonwebtoken": "catalog:",
"@types/linkify-it": "5.0.0",
Expand All @@ -196,9 +202,11 @@
"babel-plugin-react-compiler": "1.0.0",
"dependency-cruiser": "17.3.10",
"dotenv": "17.3.1",
"fake-indexeddb": "6.2.4",
"graphql": "16.14.2",
"ink": "6.8.0",
"jest": "30.3.0",
"jest-environment-jsdom": "30.3.0",
"knip": "5.86.0",
"madge": "8.0.0",
"postcss": "8.5.23",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"test:web-env": "tsx --tsconfig scripts/web-env/tsconfig.json --test scripts/web-env/*.test.ts",
"test:setup-smoke": "pnpm --filter web run test:setup-smoke",
"test:mobile-artifacts": "node --test scripts/inspect-mobile-artifacts.test.mjs",
"test:agent-harness-ci": "node --test scripts/agent-harness-ci.test.mjs && pnpm --filter @kilocode/agent-harness exec vitest run src/boundaries.test.ts",
"lint": "scripts/lint-all.sh",
"format": "oxfmt",
"format:check": "oxfmt --list-different .",
Expand Down
38 changes: 38 additions & 0 deletions packages/agent-harness/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@kilocode/agent-harness",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/version.ts",
"./react": "./src/react.ts",
"./*.test": null,
"./*": "./src/*.ts"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsgo --noEmit",
"lint": "oxlint --config ../../.oxlintrc.json",
"format": "oxfmt"
},
"dependencies": {
"@kilocode/mcp-gateway": "workspace:*",
"zod": "catalog:"
},
"peerDependencies": {
"react": "^19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
},
"devDependencies": {
"@types/node": "catalog:",
"@types/react": "19.2.14",
"@typescript/native-preview": "catalog:",
"dependency-cruiser": "17.3.10",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
184 changes: 184 additions & 0 deletions packages/agent-harness/src/boundaries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { globSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { cruise, type IForbiddenRuleType } from 'dependency-cruiser';
import extractTSConfig from 'dependency-cruiser/config-utl/extract-ts-config';
import ts from 'typescript';
import { afterEach, expect, it } from 'vitest';

const root = fileURLToPath(new URL('../../../', import.meta.url));
const core = '^packages/agent-harness/src/(?!react\\.ts$)';
const browser = '^apps/web/src/(lib/agent-harness/(browser-|transport\\.)|components/quick-chat/)';
const native = '^apps/mobile/src/lib/agent-harness/';
const rules: IForbiddenRuleType[] = [
{
name: 'portable-core',
severity: 'error',
from: { path: core },
to: {
reachable: true,
pathNot: `${core}|^packages/mcp-gateway/src/|(^|/)node_modules/zod/`,
},
},
{
name: 'no-client-executors-or-credentials',
severity: 'error',
from: { path: `${browser}|${native}|^packages/agent-harness/src/react\\.ts$` },
to: {
reachable: true,
path: '^services/|^packages/(db|encryption|worker-utils|cloud-agent-profile)/|^apps/web/src/(routers/|app/api/|lib/(tokens|drizzle|agent-harness/(?!browser-|transport\\.)))|(^|/)(server-only|@ai-sdk|@modelcontextprotocol|ai)(/|$)',
},
},
];

async function violations(baseDir: string, files: string[], project?: string) {
// The resolver follows symlinks before calculating paths relative to this directory.
const directory = realpathSync(baseDir);
const tsConfigFile = project ? join(directory, project, 'tsconfig.json') : undefined;
const tsConfig = tsConfigFile ? extractTSConfig(tsConfigFile) : undefined;
// Otherwise dependency-cruiser resolves aliases from process.cwd(), not the project.
if (tsConfig && project) tsConfig.options.baseUrl ??= join(directory, project);
const { output } = await cruise(
files,
{
baseDir: directory,
validate: true,
ruleSet: { forbidden: rules },
doNotFollow: { path: 'node_modules' },
enhancedResolveOptions: { exportsFields: ['exports'], conditionNames: ['import', 'default'] },
exclude: '\\.test\\.[jt]sx?$',
tsConfig: tsConfigFile ? { fileName: tsConfigFile } : undefined,
},
undefined,
{ tsConfig }
);
if (typeof output === 'string') throw new Error('Expected a dependency graph');
return output.summary.violations;
}

function portabilityErrors(files: string[]) {
const program = ts.createProgram(files, {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
lib: ['lib.es2022.d.ts'],
types: [],
strict: true,
skipLibCheck: true,
noEmit: true,
});
return ts
.getPreEmitDiagnostics(program)
.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n'));
}

const fixtures: string[] = [];
afterEach(() => fixtures.splice(0).forEach(path => rmSync(path, { recursive: true, force: true })));
function fixture(files: Record<string, string>) {
const directory = mkdtempSync(join(tmpdir(), 'harness-boundary-'));
fixtures.push(directory);
for (const [path, content] of Object.entries(files)) {
mkdirSync(dirname(join(directory, path)), { recursive: true });
writeFileSync(join(directory, path), content);
}
return directory;
}

it('keeps the portable package and present host entrypoints within their boundaries', async () => {
const surfaces = [
['packages/agent-harness', ['packages/agent-harness/src/**/*.ts']],
[
'apps/web',
[
'apps/web/src/lib/agent-harness/browser-*.ts',
'apps/web/src/lib/agent-harness/transport.ts',
'apps/web/src/components/quick-chat/**/*.tsx',
],
],
['apps/mobile', ['apps/mobile/src/lib/agent-harness/**/*.ts']],
] as const;
for (const [project, patterns] of surfaces) {
const files = globSync([...patterns], { cwd: root }).filter(
file => !/\.test\.[jt]sx?$/.test(file)
);
if (files.length) expect(await violations(root, files, project)).toEqual([]);
if (project === 'packages/agent-harness') {
expect(
portabilityErrors(
files.filter(file => !file.endsWith('/react.ts')).map(file => join(root, file))
)
).toEqual([]);
}
}
});

const entry = 'packages/agent-harness/src/entry.ts';
const relay = 'packages/mcp-gateway/src/relay.ts';
const relayImport = "export * from '../../mcp-gateway/src/relay';";
it('allows a transitive portable helper but rejects its Node builtin import', async () => {
const directory = fixture({
[entry]: relayImport,
[relay]: 'export const value = Math.max(1, 2);',
});
expect(await violations(directory, [entry])).toEqual([]);
expect(portabilityErrors([join(directory, entry)])).toEqual([]);
writeFileSync(join(directory, relay), "import 'node:fs';");
expect(await violations(directory, [entry])).toContainEqual(
expect.objectContaining({
from: entry,
to: 'fs',
rule: { name: 'portable-core', severity: 'error' },
})
);
});
it.each([
'services/agent-harness/src/dispatch.ts',
'apps/web/src/lib/tokens.ts',
'node_modules/react-native/index.js',
'node_modules/react/index.js',
'node_modules/idb/index.js',
])('rejects a transitive core import of %s', async target => {
const directory = fixture({
[entry]: relayImport,
[relay]: `export * from '${relative(dirname(relay), target)}';`,
[target]: 'export const value = 1;',
});
expect(await violations(directory, [entry])).toContainEqual(
expect.objectContaining({ from: entry, to: target })
);
});

it.each(['window', 'document', 'navigator', 'localStorage', 'indexedDB'])(
'rejects the browser global %s through a helper',
name => {
const directory = fixture({
[entry]: relayImport,
[relay]: `export const value = ${name};`,
});
expect(portabilityErrors([join(directory, entry)]).join('\n')).toContain(
`Cannot find name '${name}'`
);
}
);

it.each([
'apps/web/src/lib/agent-harness/browser-bridge.ts',
'apps/mobile/src/lib/agent-harness/native-bridge.ts',
])('rejects transitive credentials from the host %s', async host => {
const target = 'apps/web/src/lib/tokens.ts';
const project = host.startsWith('apps/web/') ? 'apps/web' : 'apps/mobile';
const helper = `${project}/src/relay.ts`;
const directory = fixture({
[`${project}/tsconfig.json`]: JSON.stringify({
compilerOptions: { paths: { '@/*': ['./src/*'] } },
}),
[host]: "export * from '@/relay';",
[helper]: `export * from '${relative(dirname(helper), relay)}';`,
[relay]: `export * from '${relative(dirname(relay), target)}';`,
[target]: 'export const credential = "fixture";',
});
expect(await violations(directory, [host], project)).toContainEqual(
expect.objectContaining({ from: host, to: target })
);
});
1 change: 1 addition & 0 deletions packages/agent-harness/src/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const AGENT_HARNESS_PROTOCOL_VERSION = 1;
15 changes: 15 additions & 0 deletions packages/agent-harness/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": [],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}
3 changes: 3 additions & 0 deletions packages/agent-harness/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({ test: { environment: 'node', include: ['src/**/*.test.ts'] } });
Loading