From ea4e7a66aa00c29f331851d26835312ef292d9f8 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 8 Sep 2026 13:04:53 +0200 Subject: [PATCH 1/2] refactor(cloud-agent-next): consolidate creation ownership --- .github/workflows/ci.yml | 15 + pnpm-lock.yaml | 68 ++ services/cloud-agent-next/AGENTS.md | 7 +- services/cloud-agent-next/package.json | 4 + .../scripts/check-architecture.test.ts | 472 +++++++++++ .../scripts/check-architecture.ts | 781 ++++++++++++++++++ .../scripts/check-duplication.test.ts | 401 +++++++++ .../scripts/check-duplication.ts | 508 ++++++++++++ .../scripts/duplication-allowlist.json | 27 + .../src/persistence/CloudAgentSession.ts | 33 +- .../src/persistence/schemas.test.ts | 70 ++ .../src/persistence/schemas.ts | 23 +- .../session-creation-preflight.test.ts | 307 +++++++ .../handlers/session-creation-preflight.ts | 203 +++++ .../router/handlers/session-prepare.test.ts | 240 +++++- .../src/router/handlers/session-prepare.ts | 228 +---- .../src/router/handlers/session-start.ts | 49 +- .../sandbox-session/control-plane-ingest.ts | 16 +- .../src/session-prepare.test.ts | 36 +- .../src/session-service.test.ts | 110 +++ .../cloud-agent-next/src/session-service.ts | 111 +-- .../session/safe-failure-projection.test.ts | 41 + .../src/session/session-message-state.test.ts | 89 ++ .../src/session/session-message-state.ts | 72 +- .../src/session/wrapper-supervisor.ts | 21 +- .../src/shared/assistant-failure.ts | 19 +- .../cloud-agent-next/src/shared/kilo-event.ts | 16 + .../cloud-agent-next/src/websocket/ingest.ts | 19 +- .../session/message-terminalization.test.ts | 2 +- .../session/pending-messages.test.ts | 18 +- .../wrapper/src/control/feed.ts | 16 +- 31 files changed, 3536 insertions(+), 486 deletions(-) create mode 100644 services/cloud-agent-next/scripts/check-architecture.test.ts create mode 100644 services/cloud-agent-next/scripts/check-architecture.ts create mode 100644 services/cloud-agent-next/scripts/check-duplication.test.ts create mode 100644 services/cloud-agent-next/scripts/check-duplication.ts create mode 100644 services/cloud-agent-next/scripts/duplication-allowlist.json create mode 100644 services/cloud-agent-next/src/router/handlers/session-creation-preflight.test.ts create mode 100644 services/cloud-agent-next/src/router/handlers/session-creation-preflight.ts create mode 100644 services/cloud-agent-next/src/shared/kilo-event.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 660c63b32c..124d7132ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,9 @@ jobs: - 'pnpm-lock.yaml' cloud_agent_next: - 'services/cloud-agent-next/**' + - '.github/workflows/ci.yml' + - 'package.json' + - 'pnpm-lock.yaml' - name: Detect changes (retry) id: filter_retry # Keep the filters byte-identical to the attempt above: @@ -115,6 +118,9 @@ jobs: - 'pnpm-lock.yaml' cloud_agent_next: - 'services/cloud-agent-next/**' + - '.github/workflows/ci.yml' + - 'package.json' + - 'pnpm-lock.yaml' - name: Detect changed workspaces with tests id: workspaces run: | @@ -391,6 +397,15 @@ jobs: working-directory: services/cloud-agent-next/wrapper run: bun run build.ts + - name: Test cloud-agent-next checkers + run: pnpm --filter cloud-agent-next test:checkers + + - name: Check cloud-agent-next architecture + run: pnpm --filter cloud-agent-next check:architecture + + - name: Check cloud-agent-next duplication + run: pnpm --filter cloud-agent-next check:duplication + - name: Run cloud-agent-next tests run: pnpm --filter cloud-agent-next test:all diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce574ee91e..11c3cc3da5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2091,6 +2091,9 @@ importers: drizzle-kit: specifier: 'catalog:' version: 0.31.10 + jscpd: + specifier: 5.0.16 + version: 5.0.16 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -14518,6 +14521,44 @@ packages: jsc-safe-url@0.2.4: resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} + jscpd-darwin-arm64@5.0.16: + resolution: {integrity: sha512-Tu6OAg3Rp6m7LCZqOtPViJxYGTKXTLrI/xMyjEKe79N9L7GpbI3XhgotGDpoE+PjLRbgJgGhRKbkKK18FPySmQ==} + cpu: [arm64] + os: [darwin] + + jscpd-darwin-x64@5.0.16: + resolution: {integrity: sha512-3btQ1aG8K7+9rvJjyaRJRcSBD0DvHgZOUVlecjnyO99FYzMvdFREq67UIl5CAXFeYf5EY8TSorCMZQxL6TBD0w==} + cpu: [x64] + os: [darwin] + + jscpd-linux-arm64-gnu@5.0.16: + resolution: {integrity: sha512-L1F9CNHxRPqGJCKgGRA4gtZcESACFkS2lgfPgWn9fG+4CkiewLcYyy/oxrafJJL1/PPTQ24sbLuzPaKUxJuvuw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + jscpd-linux-x64-gnu@5.0.16: + resolution: {integrity: sha512-BruP+sAr0+6QsEQZvOkgcS+xR23y4s1hLA0xfkcO1zFFXdnmQdJ4w23/U8VlPwqO2dFRaAEseYEXjHs5oQQGFw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + jscpd-linux-x64-musl@5.0.16: + resolution: {integrity: sha512-T36RtMnF695Y1/T3cLEhgvspHdKoiF+oxK/lklGJWriLieImZolSOjGXsecW2bp7MNMMGlqLLRzr2RNgnwy+Hg==} + cpu: [x64] + os: [linux] + libc: [musl] + + jscpd-windows-x64-msvc@5.0.16: + resolution: {integrity: sha512-BcrY18r6uje+TjgBKdFZJz6qfAAh9QT24KZYZk+jk6tuIKOYIm6oRMeQEu3Tzr/len9ZethN2h1bKmv+EfHcGw==} + cpu: [x64] + os: [win32] + + jscpd@5.0.16: + resolution: {integrity: sha512-TiQ4zKtKeldep6UswXFHjVCDhVdLBaJyQcZjhCSzVOmKpT6HBj0jUZiphP1vK1X3VSSuzwcfifJVNpsOIiwRCg==} + engines: {node: '>=18'} + hasBin: true + jsdom@29.1.1: resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} @@ -32815,6 +32856,33 @@ snapshots: jsc-safe-url@0.2.4: {} + jscpd-darwin-arm64@5.0.16: + optional: true + + jscpd-darwin-x64@5.0.16: + optional: true + + jscpd-linux-arm64-gnu@5.0.16: + optional: true + + jscpd-linux-x64-gnu@5.0.16: + optional: true + + jscpd-linux-x64-musl@5.0.16: + optional: true + + jscpd-windows-x64-msvc@5.0.16: + optional: true + + jscpd@5.0.16: + optionalDependencies: + jscpd-darwin-arm64: 5.0.16 + jscpd-darwin-x64: 5.0.16 + jscpd-linux-arm64-gnu: 5.0.16 + jscpd-linux-x64-gnu: 5.0.16 + jscpd-linux-x64-musl: 5.0.16 + jscpd-windows-x64-msvc: 5.0.16 + jsdom@29.1.1: dependencies: '@asamuzakjp/css-color': 5.1.11 diff --git a/services/cloud-agent-next/AGENTS.md b/services/cloud-agent-next/AGENTS.md index efbfee4492..41ffd53a6f 100644 --- a/services/cloud-agent-next/AGENTS.md +++ b/services/cloud-agent-next/AGENTS.md @@ -43,6 +43,9 @@ Git tokens (GitHub App installation tokens, managed GitLab tokens) are resolved - `pnpm run format` - oxfmt write (src only) - `pnpm run format:check` - oxfmt check (src only) - `pnpm run typecheck` - TypeScript (tsgo) + wrapper typecheck +- `pnpm run test:checkers` - Duplication and architecture checker fixtures +- `pnpm run check:architecture` - Production ownership boundaries +- `pnpm run check:duplication` - Production duplicate-code ratchet ### Deployment @@ -126,7 +129,9 @@ This pattern blocks API endpoints from running for external contributors who don - A legacy pending-message flush blocked on exhaustion forces one out-of-cadence recheck (`recoverExhaustedDeliveryBlock` → `recheckExhaustedCleanup`) because a user is actively waiting, then retries on the `WRAPPER_CLEANUP_EXHAUSTED` budget before failing closed. The retry budget is what keeps the two halves consistent — recovery takes minutes, so terminalizing on the first blocked attempt would discard messages a later probe would have delivered — and failing closed at the end of it is what keeps a message from sitting `queued` with no terminal signal. The flush failure code must stay authoritative: `INTERNAL` is treated as non-authoritative by `recordPendingFlushFailure` and would terminalize the message under whatever earlier cause it carried. - Callback delivery retry policy is paired with `wrangler.jsonc`: `CALLBACK_DELIVERY_MAX_ATTEMPTS` includes the initial attempt, and each Cloud Agent Next callback queue consumer must configure `max_retries` for the remaining redeliveries. - Queue/drain emits unfenced `MessageDeliveryRequest`; only `AgentRuntime` may allocate/reuse current identity and construct `FencedWrapperDispatchRequest` with complete `WrapperRunFence` for downstream dispatch. -- Session creation selects an explicit `ProfileResolutionPolicy` at the handler boundary. Implicit repository/default profile resolution is limited to the closed set of approved session origins; omitted, unknown, and non-approved automation origins fail closed unless they supply an explicit profile id. +- Session creation preflight selects an explicit `ProfileResolutionPolicy`. Implicit repository/default profile resolution is limited to the closed set of approved session origins; omitted, unknown, and non-approved automation origins fail closed unless they supply an explicit profile id. +- Creation admission belongs to `src/router/handlers/session-creation-preflight.ts`. Registration/ledger and stored-session preflight remain separate owners. +- Wrapper production code may import Worker code only through `src/shared`; Worker production code must not import `wrapper`. - Public `start` must authorize any supplied `kilocodeOrganizationId` against `organization_memberships` before resolving profile layers or creating session ownership state. Balance validation is billing-only and `x-skip-balance-check` must never bypass organization authorization. - Current wrapper identity is fenced `wrapperRunId` plus generation/connection; do not reintroduce execution-ID-only reconnect, supervision, or pending-drain blocking. Legacy endpoint/result/callback `executionId` fields remain boundary compatibility aliases only. - A control-plane session must remain recoverable after the physical sandbox dies. `SandboxControl` retains its allocation and stop tombstone until stop confirmation or a non-waking terminal observation; only explicit authorized demand may create a replacement. Do not require a new `workspace_*` session. Cloudflare cleanup retains five fast attempts, then observes and issues at most one native stop per five-minute reconciliation pass until death is confirmed. Keep these slow reaping alarms beyond one hour without resetting the attempt budget or postponing them on demand. Vercel retains its observation-only cutoff. Continued reaping retains cleanup responsibility, not an absolute physical lifetime guarantee during provider unavailability. diff --git a/services/cloud-agent-next/package.json b/services/cloud-agent-next/package.json index 09e33e3e83..8993458eb5 100644 --- a/services/cloud-agent-next/package.json +++ b/services/cloud-agent-next/package.json @@ -20,7 +20,10 @@ "format": "oxfmt src scripts", "format:check": "oxfmt --list-different src scripts", "build:wrapper": "bun run --cwd wrapper build", + "check:architecture": "tsx scripts/check-architecture.ts", + "check:duplication": "tsx scripts/check-duplication.ts", "test": "vitest run", + "test:checkers": "vitest run scripts/check-duplication.test.ts scripts/check-architecture.test.ts", "test:watch": "vitest", "test:integration": "vitest run --config vitest.workers.config.ts", "test:integration:watch": "vitest --config vitest.workers.config.ts", @@ -59,6 +62,7 @@ "@typescript/native-preview": "catalog:", "@vitest/ui": "catalog:", "drizzle-kit": "catalog:", + "jscpd": "5.0.16", "tsx": "^4.21.0", "typescript": "catalog:", "vitest": "catalog:", diff --git a/services/cloud-agent-next/scripts/check-architecture.test.ts b/services/cloud-agent-next/scripts/check-architecture.test.ts new file mode 100644 index 0000000000..97a8d123a9 --- /dev/null +++ b/services/cloud-agent-next/scripts/check-architecture.test.ts @@ -0,0 +1,472 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { analyzeArchitecture, type ArchitectureViolation } from './check-architecture.js'; + +const owner = `export async function preflightSessionCreation(request: unknown) { return request; } +export function profileResolutionPolicyForSessionCreateOrigin() { return {}; } +export function resolveEffectiveSessionConfiguration(request: unknown) { return request; } +export function assertModeAvailableForProfile() {} +`; +const registration = `export async function registerNewSession(request: unknown) { return request; } +export async function startNewSession(request: unknown) { return request; } +export async function createSessionWithLedger(request: unknown) { + return startNewSession(request); +} +`; +const startHandler = `import { preflightSessionCreation as admit } from './preflight-barrel.js'; +import { startNewSession as register } from '../../session/session-registration.js'; +function withLogTags(_tags: unknown, callback: () => unknown) { return callback(); } +export async function start(request: unknown) { + return withLogTags({}, async () => { + const admitted = await admit(request); + return register(admitted); + }); +} +`; +const prepareHandler = `import { preflightSessionCreation } from './session-creation-preflight.js'; +import { createSessionWithLedger, registerNewSession, startNewSession } from '../../session/session-registration.js'; +function withLogTags(_tags: unknown, callback: () => unknown) { return callback(); } +export async function prepare(request: unknown, autoInitiate: boolean, operationKey?: string) { + return withLogTags({}, async () => { + const admitted = await preflightSessionCreation(request); + return autoInitiate && operationKey + ? createSessionWithLedger(admitted) + : autoInitiate + ? startNewSession(admitted) + : registerNewSession(admitted); + }); +} +`; + +function baseFiles(): Record { + return { + 'src/router/handlers/session-creation-preflight.ts': owner, + 'src/router/handlers/preflight-barrel.ts': + "export { preflightSessionCreation } from './session-creation-preflight.js';\n", + 'src/router/handlers/session-start.ts': startHandler, + 'src/router/handlers/session-prepare.ts': prepareHandler, + 'src/session/session-registration.ts': registration, + 'src/model-validation.ts': 'export function assertKiloModelAvailable() {}\n', + 'src/session/validate-repository-access.ts': + 'export function assertRepositoryAccessBeforeSessionCreation() {}\n', + 'src/router/handlers/organization-membership.ts': + 'export function assertOrganizationMembership() {}\n', + 'src/shared/protocol.ts': 'export type Protocol = string;\n', + }; +} + +async function fixture( + changes: Record, + run: (violations: ArchitectureViolation[], root: string) => void | Promise +) { + const root = await mkdtemp(join(tmpdir(), 'cloud-agent-architecture-test-')); + try { + const files = { ...baseFiles(), ...changes }; + for (const [name, content] of Object.entries(files)) { + const file = join(root, name); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, content); + } + await run(await analyzeArchitecture(root), root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +function messages(violations: readonly ArchitectureViolation[]): string { + return violations.map(violation => `${violation.rule}: ${violation.message}`).join('\n'); +} + +describe('creation ownership architecture', () => { + it('accepts aliased/re-exported owner use and the production withLogTags prepare ternary', async () => { + await fixture({}, violations => expect(violations).toEqual([])); + }); + + it.each([ + [ + 'skipped preflight', + `import { startNewSession } from '../../session/session-registration.js'; + export async function start(request: unknown) { return startNewSession(request); }`, + 'must call preflightSessionCreation', + ], + [ + 'unused owner import', + `import { preflightSessionCreation } from './session-creation-preflight.js'; + import { startNewSession } from '../../session/session-registration.js'; + export async function start(request: unknown) { return startNewSession(request); }`, + 'import alone is not sufficient', + ], + [ + 'unresolved request registration', + `import { preflightSessionCreation } from './session-creation-preflight.js'; + import { startNewSession } from '../../session/session-registration.js'; + export async function start(request: unknown) { + const admitted = await preflightSessionCreation(request); + return startNewSession(request); + }`, + 'exact unshadowed preflight result', + ], + [ + 'conditional preflight', + `import { preflightSessionCreation } from './session-creation-preflight.js'; + import { startNewSession } from '../../session/session-registration.js'; + export async function start(request: unknown, condition: boolean) { + let admitted = request; + if (condition) { admitted = await preflightSessionCreation(request); } + return startNewSession(admitted); + }`, + 'unconditional const binding', + ], + [ + 'preflight in deeper callback', + `import { preflightSessionCreation } from './session-creation-preflight.js'; + import { startNewSession } from '../../session/session-registration.js'; + export async function start(request: unknown) { + const admitted = await Promise.resolve().then(() => preflightSessionCreation(request)); + return startNewSession(admitted); + }`, + 'unconditional const binding', + ], + [ + 'preflight outside the executing body', + `import { preflightSessionCreation } from './session-creation-preflight.js'; + import { startNewSession } from '../../session/session-registration.js'; + function withLogTags(callback: () => unknown) { return callback(); } + export async function start(request: unknown) { + const admitted = await preflightSessionCreation(request); + return withLogTags(async () => startNewSession(admitted)); + }`, + 'unconditional preflight result', + ], + [ + 'overwritten admitted result', + `import { preflightSessionCreation } from './session-creation-preflight.js'; + import { startNewSession } from '../../session/session-registration.js'; + export async function start(request: unknown) { + const admitted = await preflightSessionCreation(request); + admitted = request; + return startNewSession(admitted); + }`, + 'must not be overwritten', + ], + [ + 'restored original profile after preflight', + `import { preflightSessionCreation } from './session-creation-preflight.js'; + import { startNewSession } from '../../session/session-registration.js'; + export async function start(request: any) { + const admitted = await preflightSessionCreation(request); + admitted.profile = request.profile; + return startNewSession(admitted); + }`, + 'must not be overwritten', + ], + [ + 'restored original agent through a nested element write', + `import { preflightSessionCreation } from './session-creation-preflight.js'; + import { startNewSession } from '../../session/session-registration.js'; + export async function start(request: any) { + const admitted = await preflightSessionCreation(request); + admitted['profile'].resolved.runtimeAgents[0] = request.profile.resolved.runtimeAgents[0]; + return startNewSession(admitted); + }`, + 'must not be overwritten', + ], + [ + 'deleted admitted profile data', + `import { preflightSessionCreation } from './session-creation-preflight.js'; + import { startNewSession } from '../../session/session-registration.js'; + export async function start(request: any) { + const admitted = await preflightSessionCreation(request); + delete admitted.profile.resolved; + return startNewSession(admitted); + }`, + 'must not be overwritten', + ], + [ + 'shadowed admitted result', + `import { preflightSessionCreation } from './session-creation-preflight.js'; + import { startNewSession } from '../../session/session-registration.js'; + export async function start(request: unknown) { + const admitted = await preflightSessionCreation(request); + { const admitted = request; return startNewSession(admitted); } + }`, + 'exact unshadowed preflight result', + ], + ])('rejects %s', async (_name, replacement, expected) => { + await fixture({ 'src/router/handlers/session-start.ts': replacement }, violations => { + expect(messages(violations)).toContain(expected); + }); + }); + + it('rejects direct and re-exported low-level admission imports', async () => { + const direct = `import { preflightSessionCreation } from './session-creation-preflight.js'; +import { assertKiloModelAvailable } from '../../model-validation.js'; +import { startNewSession } from '../../session/session-registration.js'; +export async function start(request: unknown) { + assertKiloModelAvailable(); + const admitted = await preflightSessionCreation(request); + return startNewSession(admitted); +}`; + await fixture({ 'src/router/handlers/session-start.ts': direct }, violations => { + expect(messages(violations)).toContain( + 'imports low-level admission symbol assertKiloModelAvailable' + ); + }); + + const reexported = `import { preflightSessionCreation } from './session-creation-preflight.js'; +import { checkModel } from './low-level-barrel.js'; +import { startNewSession } from '../../session/session-registration.js'; +export async function start(request: unknown) { + checkModel(); + const admitted = await preflightSessionCreation(request); + return startNewSession(admitted); +}`; + await fixture( + { + 'src/router/handlers/session-start.ts': reexported, + 'src/router/handlers/low-level-barrel.ts': + "export { assertKiloModelAvailable as checkModel } from '../../model-validation.js';\n", + }, + violations => { + expect(messages(violations)).toContain( + 'imports low-level admission symbol assertKiloModelAvailable' + ); + } + ); + + const profileResolver = `import { preflightSessionCreation } from './session-creation-preflight.js'; +import { mergeProfileConfiguration as resolveProfile } from '@kilocode/cloud-agent-profile'; +import { startNewSession } from '../../session/session-registration.js'; +export async function start(request: unknown) { + resolveProfile; + const admitted = await preflightSessionCreation(request); + return startNewSession(admitted); +}`; + await fixture({ 'src/router/handlers/session-start.ts': profileResolver }, violations => { + expect(messages(violations)).toContain( + 'imports low-level profile resolver mergeProfileConfiguration' + ); + }); + + const packageReexport = `import { preflightSessionCreation } from './session-creation-preflight.js'; +import { resolveProfile } from './profile-package-barrel.js'; +import { startNewSession } from '../../session/session-registration.js'; +export async function start(request: unknown) { + resolveProfile; + const admitted = await preflightSessionCreation(request); + return startNewSession(admitted); +}`; + await fixture( + { + 'src/router/handlers/session-start.ts': packageReexport, + 'src/router/handlers/profile-package-barrel.ts': + "export { mergeProfileConfiguration as resolveProfile } from '@kilocode/cloud-agent-profile';\n", + 'node_modules/@kilocode/cloud-agent-profile/index.d.ts': + 'export declare function mergeProfileConfiguration(): unknown;\n', + }, + violations => { + expect(messages(violations)).toContain( + 'imports low-level profile resolver mergeProfileConfiguration' + ); + } + ); + }); + + it('rejects direct-package and local-barrel dynamic low-level admission imports', async () => { + const packageImport = `import { preflightSessionCreation } from './session-creation-preflight.js'; +import { startNewSession } from '../../session/session-registration.js'; +export async function start(request: unknown) { + const profile = await import('@kilocode/cloud-agent-profile'); + await profile.mergeProfileConfiguration(); + const admitted = await preflightSessionCreation(request); + return startNewSession(admitted); +}`; + await fixture( + { + 'src/router/handlers/session-start.ts': packageImport, + 'node_modules/@kilocode/cloud-agent-profile/index.d.ts': + 'export declare function mergeProfileConfiguration(): unknown;\n', + }, + violations => { + expect(messages(violations)).toContain( + 'imports low-level profile resolver mergeProfileConfiguration' + ); + } + ); + + const profileBarrelImport = `import { preflightSessionCreation } from './session-creation-preflight.js'; +import { startNewSession } from '../../session/session-registration.js'; +export async function start(request: unknown) { + const profile = await import('./profile-package-barrel.js'); + await profile.resolveProfile(); + const admitted = await preflightSessionCreation(request); + return startNewSession(admitted); +}`; + await fixture( + { + 'src/router/handlers/session-start.ts': profileBarrelImport, + 'src/router/handlers/profile-package-barrel.ts': + "export { mergeProfileConfiguration as resolveProfile } from '@kilocode/cloud-agent-profile';\n", + 'node_modules/@kilocode/cloud-agent-profile/index.d.ts': + 'export declare function mergeProfileConfiguration(): unknown;\n', + }, + violations => { + expect(messages(violations)).toContain( + 'imports low-level profile resolver mergeProfileConfiguration' + ); + } + ); + + const admissionBarrelImport = `import { preflightSessionCreation } from './session-creation-preflight.js'; +import { startNewSession } from '../../session/session-registration.js'; +export async function start(request: unknown) { + const checks = await import('./all-low-level-barrel.js'); + checks.checkOrganization(); + const admitted = await preflightSessionCreation(request); + return startNewSession(admitted); +}`; + await fixture( + { + 'src/router/handlers/session-start.ts': admissionBarrelImport, + 'src/router/handlers/all-low-level-barrel.ts': `export { assertKiloModelAvailable as checkModel } from '../../model-validation.js'; +export { assertRepositoryAccessBeforeSessionCreation as checkRepository } from '../../session/validate-repository-access.js'; +export { assertOrganizationMembership as checkOrganization } from './organization-membership.js';\n`, + }, + violations => { + const output = messages(violations); + expect(output).toContain('imports low-level admission symbol assertKiloModelAvailable'); + expect(output).toContain( + 'imports low-level admission symbol assertRepositoryAccessBeforeSessionCreation' + ); + expect(output).toContain('imports low-level admission symbol assertOrganizationMembership'); + } + ); + }); + + it('rejects indirect and aliased registration callers outside the two handlers', async () => { + await fixture( + { + 'src/helper.ts': `import { startNewSession as bypass } from './session/session-registration.js'; +export function helper(request: unknown) { return bypass(request); }`, + }, + violations => { + expect(messages(violations)).toContain('may be called only by the creation handlers'); + } + ); + }); + + it.each([ + [ + 'namespace property access', + `import * as registration from './session/session-registration.js'; +export function helper(request: unknown) { return registration.startNewSession(request); }`, + 'may be called only by the creation handlers', + ], + [ + 'literal namespace element access', + `import * as registration from './session/session-registration.js'; +export function helper(request: unknown) { return registration['startNewSession'](request); }`, + 'may be called only by the creation handlers', + ], + [ + 'shorthand namespace destructuring', + `import * as registration from './session/session-registration.js'; +const { startNewSession } = registration; +export function helper(request: unknown) { return startNewSession(request); }`, + 'must not be destructured, aliased, or passed', + ], + [ + 'computed namespace element access', + `import * as registration from './session/session-registration.js'; +export function helper(name: string, request: unknown) { return registration[name](request); }`, + 'must not be destructured, aliased, or passed', + ], + ])('rejects registration through %s', async (_name, source, expected) => { + await fixture({ 'src/helper.ts': source }, violations => { + expect(messages(violations)).toContain(expected); + }); + }); + + it.each([ + [ + 'a local alias', + `import { startNewSession } from './session/session-registration.js'; +const create = startNewSession; +export function helper(request: unknown) { return create(request); }`, + ], + [ + 'a passed reference', + `import { startNewSession } from './session/session-registration.js'; +function invoke(callback: (request: unknown) => unknown, request: unknown) { return callback(request); } +export function helper(request: unknown) { return invoke(startNewSession, request); }`, + ], + ])('rejects registration functions used through %s', async (_name, source) => { + await fixture({ 'src/helper.ts': source }, violations => { + expect(messages(violations)).toContain('local aliases and passed references are not allowed'); + }); + }); +}); + +describe('worker-wrapper source ownership', () => { + it('accepts wrapper imports from src/shared', async () => { + await fixture( + { 'wrapper/src/valid.ts': "import type { Protocol } from '../../src/shared/protocol.js';\n" }, + violations => expect(violations).toEqual([]) + ); + }); + + it('rejects wrapper-to-worker and worker-to-wrapper production imports', async () => { + await fixture( + { + 'src/worker-bypass.ts': "import '../wrapper/src/wrapper-only.js';\n", + 'wrapper/src/wrapper-only.ts': 'export const wrapperOnly = true;\n', + 'wrapper/src/wrapper-bypass.ts': "import '../../src/model-validation.js';\n", + }, + violations => { + const output = messages(violations); + expect(output).toContain( + 'Wrapper production code may import Worker code only from src/shared' + ); + expect(output).toContain('Worker production code must not import wrapper code'); + } + ); + }); + + it('rejects literal dynamic imports in both worker-wrapper directions', async () => { + await fixture( + { + 'src/dynamic-worker-bypass.ts': + "export const wrapper = import('../wrapper/src/wrapper-only.js');\n", + 'wrapper/src/wrapper-only.ts': 'export const wrapperOnly = true;\n', + 'wrapper/src/dynamic-wrapper-bypass.ts': + "export const worker = import('../../src/model-validation.js');\n", + }, + violations => { + const output = messages(violations); + expect(output).toContain( + 'Wrapper production code may import Worker code only from src/shared' + ); + expect(output).toContain('Worker production code must not import wrapper code'); + } + ); + }); + + it('excludes tests, specs, and fixture paths from the production scan', async () => { + const forbidden = `import { startNewSession } from './session-registration.js'; +startNewSession({});`; + await fixture( + { + 'src/session/bypass.test.ts': forbidden, + 'src/session/bypass.spec.ts': forbidden, + 'src/session/bypass-fixture.ts': forbidden, + 'src/fixtures/bypass.ts': forbidden, + 'src/__snapshots__/bypass.ts': forbidden, + 'src/recordings/bypass.ts': forbidden, + 'wrapper/src/bypass.test.ts': "import '../../src/model-validation.js';\n", + }, + violations => expect(violations).toEqual([]) + ); + }); +}); diff --git a/services/cloud-agent-next/scripts/check-architecture.ts b/services/cloud-agent-next/scripts/check-architecture.ts new file mode 100644 index 0000000000..5008ff05a1 --- /dev/null +++ b/services/cloud-agent-next/scripts/check-architecture.ts @@ -0,0 +1,781 @@ +import { readdir, realpath } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const handlerFiles = new Set([ + 'src/router/handlers/session-start.ts', + 'src/router/handlers/session-prepare.ts', +]); +const registrationFile = 'src/session/session-registration.ts'; +const preflightFile = 'src/router/handlers/session-creation-preflight.ts'; +const registrationNames = new Set([ + 'registerNewSession', + 'startNewSession', + 'createSessionWithLedger', +]); +const forbiddenHandlerSymbols = new Set([ + 'src/model-validation.ts\0assertKiloModelAvailable', + 'src/session/validate-repository-access.ts\0assertRepositoryAccessBeforeSessionCreation', + 'src/router/handlers/organization-membership.ts\0assertOrganizationMembership', + `${preflightFile}\0profileResolutionPolicyForSessionCreateOrigin`, + `${preflightFile}\0resolveEffectiveSessionConfiguration`, + `${preflightFile}\0assertModeAvailableForProfile`, +]); +const excludedDirectories = new Set([ + '__fixtures__', + '__mocks__', + '__snapshots__', + '__tests__', + '.wrangler', + 'build', + 'coverage', + 'deps', + 'dist', + 'fixture', + 'fixtures', + 'generated', + 'node_modules', + 'out', + 'recordings', + 'specs', + 'test', + 'testdata', + 'tests', +]); + +export type ArchitectureViolation = { + file: string; + rule: 'creation-preflight' | 'registration-owner' | 'worker-wrapper-boundary'; + message: string; + line?: number; +}; + +function normalizePath(path: string): string { + return path.replaceAll('\\', '/'); +} + +function isExcluded(path: string): boolean { + const parts = path.split('/'); + const basename = parts.at(-1) ?? ''; + return ( + parts.some(part => excludedDirectories.has(part)) || + /\.(?:test|spec)\.[^.]+$/.test(basename) || + /(?:^|[-_.])fixtures?(?:[-_.]|$)/.test(basename) || + /(?:^|[-_.])test[-_.]?data(?:[-_.]|$)/.test(basename) || + /\.d\.[cm]?tsx?$/.test(basename) || + /\.(?:gen|generated)\.[cm]?tsx?$/.test(basename) + ); +} + +async function collectProductionFiles(root: string): Promise { + const files: string[] = []; + async function visit(directory: string): Promise { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + for (const entry of entries) { + const absolute = join(directory, entry.name); + const relativePath = normalizePath(relative(root, absolute)); + if (isExcluded(relativePath)) continue; + if (entry.isDirectory()) await visit(absolute); + else if (entry.isFile() && /\.[cm]?tsx?$/.test(entry.name)) files.push(absolute); + } + } + await visit(join(root, 'src')); + await visit(join(root, 'wrapper/src')); + return files.toSorted(); +} + +function sourceFilePath(root: string, sourceFile: ts.SourceFile): string { + return normalizePath(relative(root, sourceFile.fileName)); +} + +function lineOf(sourceFile: ts.SourceFile, node: ts.Node): number { + return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; +} + +function resolveLocalModule( + sourceFile: string, + specifier: string, + files: ReadonlySet +): string | undefined { + if (!specifier.startsWith('.')) return undefined; + const requested = normalizePath(resolve(dirname(sourceFile), specifier)); + const withoutScriptExtension = requested.replace(/\.(?:mjs|cjs|js|jsx|mts|cts|ts|tsx)$/, ''); + const candidates = [ + requested, + `${withoutScriptExtension}.ts`, + `${withoutScriptExtension}.tsx`, + `${withoutScriptExtension}.mts`, + `${withoutScriptExtension}.cts`, + `${requested}/index.ts`, + `${requested}/index.tsx`, + ]; + return candidates.find(candidate => files.has(candidate)); +} + +function moduleSpecifiers(sourceFile: ts.SourceFile): ts.StringLiteralLike[] { + const specifiers: ts.StringLiteralLike[] = []; + function visit(node: ts.Node): void { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier && + ts.isStringLiteralLike(node.moduleSpecifier) + ) { + specifiers.push(node.moduleSpecifier); + } + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length === 1 && + ts.isStringLiteralLike(node.arguments[0]) + ) { + specifiers.push(node.arguments[0]); + } + ts.forEachChild(node, visit); + } + visit(sourceFile); + return specifiers; +} + +function canonicalSymbol( + checker: ts.TypeChecker, + symbol: ts.Symbol | undefined +): ts.Symbol | undefined { + const seen = new Set(); + let current = symbol; + while (current && (current.flags & ts.SymbolFlags.Alias) !== 0 && !seen.has(current)) { + seen.add(current); + const next = checker.getAliasedSymbol(current); + if (next === current) break; + current = next; + } + return current; +} + +function symbolAtExpression( + checker: ts.TypeChecker, + expression: ts.Expression +): ts.Symbol | undefined { + let current = expression; + while (ts.isParenthesizedExpression(current)) current = current.expression; + if (ts.isPropertyAccessExpression(current)) { + return canonicalSymbol(checker, checker.getSymbolAtLocation(current.name)); + } + if (ts.isElementAccessExpression(current) && ts.isStringLiteralLike(current.argumentExpression)) { + return canonicalSymbol(checker, checker.getSymbolAtLocation(current.argumentExpression)); + } + return canonicalSymbol(checker, checker.getSymbolAtLocation(current)); +} + +function symbolIdentity( + root: string, + checker: ts.TypeChecker, + symbol: ts.Symbol | undefined +): string | undefined { + const resolvedSymbol = canonicalSymbol(checker, symbol); + const declaration = resolvedSymbol?.declarations?.find(candidate => + sourceFilePath(root, candidate.getSourceFile()).startsWith('src/') + ); + if (!resolvedSymbol || !declaration) return undefined; + return `${sourceFilePath(root, declaration.getSourceFile())}\0${resolvedSymbol.getName()}`; +} + +function callIdentity( + root: string, + checker: ts.TypeChecker, + call: ts.CallExpression +): string | undefined { + return symbolIdentity(root, checker, symbolAtExpression(checker, call.expression)); +} + +function collectCalls(sourceFile: ts.SourceFile): ts.CallExpression[] { + const calls: ts.CallExpression[] = []; + function visit(node: ts.Node): void { + if (ts.isCallExpression(node)) calls.push(node); + ts.forEachChild(node, visit); + } + visit(sourceFile); + return calls; +} + +function containingFunction(node: ts.Node): ts.FunctionLikeDeclaration | undefined { + let current: ts.Node | undefined = node.parent; + while (current) { + if ( + ts.isFunctionDeclaration(current) || + ts.isFunctionExpression(current) || + ts.isArrowFunction(current) || + ts.isMethodDeclaration(current) || + ts.isGetAccessorDeclaration(current) || + ts.isSetAccessorDeclaration(current) || + ts.isConstructorDeclaration(current) + ) { + return current; + } + current = current.parent; + } + return undefined; +} + +function preflightDeclaration( + root: string, + checker: ts.TypeChecker, + body: ts.Block +): ts.VariableDeclaration | undefined { + const candidates: ts.VariableDeclaration[] = []; + for (const statement of body.statements) { + if ( + !ts.isVariableStatement(statement) || + (statement.declarationList.flags & ts.NodeFlags.Const) === 0 || + statement.declarationList.declarations.length !== 1 + ) { + continue; + } + const declaration = statement.declarationList.declarations[0]; + if ( + declaration && + ts.isIdentifier(declaration.name) && + declaration.initializer && + ts.isAwaitExpression(declaration.initializer) && + ts.isCallExpression(declaration.initializer.expression) && + callIdentity(root, checker, declaration.initializer.expression) === + `${preflightFile}\0preflightSessionCreation` + ) { + candidates.push(declaration); + } + } + return candidates.length === 1 ? candidates[0] : undefined; +} + +const assignmentOperators = new Set([ + ts.SyntaxKind.EqualsToken, + ts.SyntaxKind.PlusEqualsToken, + ts.SyntaxKind.MinusEqualsToken, + ts.SyntaxKind.AsteriskEqualsToken, + ts.SyntaxKind.AsteriskAsteriskEqualsToken, + ts.SyntaxKind.SlashEqualsToken, + ts.SyntaxKind.PercentEqualsToken, + ts.SyntaxKind.LessThanLessThanEqualsToken, + ts.SyntaxKind.GreaterThanGreaterThanEqualsToken, + ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, + ts.SyntaxKind.AmpersandEqualsToken, + ts.SyntaxKind.BarEqualsToken, + ts.SyntaxKind.CaretEqualsToken, + ts.SyntaxKind.BarBarEqualsToken, + ts.SyntaxKind.AmpersandAmpersandEqualsToken, + ts.SyntaxKind.QuestionQuestionEqualsToken, +]); + +function bindingIsMutated( + checker: ts.TypeChecker, + body: ts.Block, + declaration: ts.VariableDeclaration, + before: number +): boolean { + const declarationSymbol = canonicalSymbol(checker, checker.getSymbolAtLocation(declaration.name)); + let mutated = false; + function isRootedAtDeclaration(expression: ts.Expression): boolean { + let current = expression; + while ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isSatisfiesExpression(current) || + ts.isNonNullExpression(current) + ) { + current = current.expression; + } + while (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current)) { + current = current.expression; + while ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isSatisfiesExpression(current) || + ts.isNonNullExpression(current) + ) { + current = current.expression; + } + } + return ( + ts.isIdentifier(current) && + canonicalSymbol(checker, checker.getSymbolAtLocation(current)) === declarationSymbol + ); + } + function visit(node: ts.Node): void { + if (mutated || node.getStart() >= before) return; + if (ts.isBinaryExpression(node) && assignmentOperators.has(node.operatorToken.kind)) { + if (isRootedAtDeclaration(node.left)) { + mutated = true; + return; + } + } + if ( + (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + (node.operator === ts.SyntaxKind.PlusPlusToken || + node.operator === ts.SyntaxKind.MinusMinusToken) && + isRootedAtDeclaration(node.operand) + ) { + mutated = true; + return; + } + if (ts.isDeleteExpression(node) && isRootedAtDeclaration(node.expression)) { + mutated = true; + return; + } + ts.forEachChild(node, visit); + } + visit(body); + return mutated; +} + +function importedSymbols(checker: ts.TypeChecker, sourceFile: ts.SourceFile): ts.Symbol[] { + const symbols: ts.Symbol[] = []; + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement) || !statement.importClause) continue; + const clause = statement.importClause; + if (clause.name) { + const symbol = canonicalSymbol(checker, checker.getSymbolAtLocation(clause.name)); + if (symbol) symbols.push(symbol); + } + if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) { + for (const element of clause.namedBindings.elements) { + const symbol = canonicalSymbol(checker, checker.getSymbolAtLocation(element.name)); + if (symbol) symbols.push(symbol); + } + } else if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) { + const moduleSymbol = checker.getSymbolAtLocation(statement.moduleSpecifier); + if (moduleSymbol) { + for (const exported of checker.getExportsOfModule(moduleSymbol)) { + const symbol = canonicalSymbol(checker, exported); + if (symbol) symbols.push(symbol); + } + } + } + } + return symbols; +} + +function dynamicImportedSymbols(checker: ts.TypeChecker, sourceFile: ts.SourceFile): ts.Symbol[] { + const symbols: ts.Symbol[] = []; + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length === 1 && + ts.isStringLiteralLike(node.arguments[0]) + ) { + const moduleSymbol = checker.getSymbolAtLocation(node.arguments[0]); + if (moduleSymbol) { + for (const exported of checker.getExportsOfModule(moduleSymbol)) { + const symbol = canonicalSymbol(checker, exported); + if (symbol) symbols.push(symbol); + } + } + } + ts.forEachChild(node, visit); + } + visit(sourceFile); + return symbols; +} + +function importsProfileResolverDirectly(sourceFile: ts.SourceFile): boolean { + for (const statement of sourceFile.statements) { + if ( + !ts.isImportDeclaration(statement) || + !ts.isStringLiteralLike(statement.moduleSpecifier) || + statement.moduleSpecifier.text !== '@kilocode/cloud-agent-profile' || + !statement.importClause?.namedBindings + ) { + continue; + } + if (ts.isNamespaceImport(statement.importClause.namedBindings)) return true; + if ( + statement.importClause.namedBindings.elements.some( + element => (element.propertyName ?? element.name).text === 'mergeProfileConfiguration' + ) + ) { + return true; + } + } + return false; +} + +function dynamicallyImportsProfileResolverDirectly(sourceFile: ts.SourceFile): boolean { + return moduleSpecifiers(sourceFile).some( + specifier => + ts.isCallExpression(specifier.parent) && + specifier.parent.expression.kind === ts.SyntaxKind.ImportKeyword && + specifier.text === '@kilocode/cloud-agent-profile' + ); +} + +function isProfileResolverSymbol(checker: ts.TypeChecker, symbol: ts.Symbol): boolean { + const resolvedSymbol = canonicalSymbol(checker, symbol); + if (resolvedSymbol?.getName() !== 'mergeProfileConfiguration') return false; + return Boolean( + resolvedSymbol.declarations?.some(declaration => { + const file = normalizePath(declaration.getSourceFile().fileName); + return ( + file.includes('/node_modules/@kilocode/cloud-agent-profile/') || + file.includes('/packages/cloud-agent-profile/') + ); + }) + ); +} + +function isSupportedRegistrationReference(node: ts.Identifier): boolean { + if ( + ts.isImportSpecifier(node.parent) || + ts.isExportSpecifier(node.parent) || + (ts.isFunctionDeclaration(node.parent) && node.parent.name === node) + ) { + return true; + } + + let expression: ts.Expression = node; + if (ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) { + expression = node.parent; + } + while (ts.isParenthesizedExpression(expression.parent)) expression = expression.parent; + return ts.isCallExpression(expression.parent) && expression.parent.expression === expression; +} + +function isDirectCallExpression(expression: ts.Expression): boolean { + let current = expression; + while (ts.isParenthesizedExpression(current.parent)) current = current.parent; + return ts.isCallExpression(current.parent) && current.parent.expression === current; +} + +function registrationNamespaceSymbols( + root: string, + checker: ts.TypeChecker, + sourceFile: ts.SourceFile +): Set { + const symbols = new Set(); + for (const statement of sourceFile.statements) { + if ( + !ts.isImportDeclaration(statement) || + !statement.importClause?.namedBindings || + !ts.isNamespaceImport(statement.importClause.namedBindings) + ) { + continue; + } + const moduleSymbol = checker.getSymbolAtLocation(statement.moduleSpecifier); + if ( + !moduleSymbol || + !checker + .getExportsOfModule(moduleSymbol) + .some( + exported => + symbolIdentity(root, checker, exported)?.startsWith(`${registrationFile}\0`) ?? false + ) + ) { + continue; + } + const symbol = checker.getSymbolAtLocation(statement.importClause.namedBindings.name); + if (symbol) symbols.add(symbol); + } + return symbols; +} + +function addViolation( + violations: ArchitectureViolation[], + sourceFile: ts.SourceFile, + root: string, + rule: ArchitectureViolation['rule'], + message: string, + node?: ts.Node +): void { + violations.push({ + file: sourceFilePath(root, sourceFile), + rule, + message, + ...(node ? { line: lineOf(sourceFile, node) } : {}), + }); +} + +export async function analyzeArchitecture(rootInput: string): Promise { + const root = await realpath(rootInput); + const files = await collectProductionFiles(root); + if (!files.length) + throw new Error('No production TypeScript files found for architecture analysis'); + const fileSet = new Set(files.map(file => resolve(file))); + const program = ts.createProgram({ + rootNames: files, + options: { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + allowJs: false, + skipLibCheck: true, + noEmit: true, + }, + }); + const checker = program.getTypeChecker(); + const violations: ArchitectureViolation[] = []; + + for (const sourceFile of program.getSourceFiles()) { + const file = sourceFilePath(root, sourceFile); + if (!fileSet.has(resolve(sourceFile.fileName)) || isExcluded(file)) continue; + + for (const specifier of moduleSpecifiers(sourceFile)) { + if (!specifier.text.startsWith('.')) continue; + const target = resolveLocalModule(sourceFile.fileName, specifier.text, fileSet); + if (!target) { + continue; + } + const targetFile = normalizePath(relative(root, target)); + if ( + file.startsWith('wrapper/src/') && + targetFile.startsWith('src/') && + !targetFile.startsWith('src/shared/') + ) { + addViolation( + violations, + sourceFile, + root, + 'worker-wrapper-boundary', + `Wrapper production code may import Worker code only from src/shared (resolved ${targetFile})`, + specifier + ); + } + if (file.startsWith('src/') && targetFile.startsWith('wrapper/')) { + addViolation( + violations, + sourceFile, + root, + 'worker-wrapper-boundary', + `Worker production code must not import wrapper code (resolved ${targetFile})`, + specifier + ); + } + } + + if (handlerFiles.has(file)) { + const imports = importedSymbols(checker, sourceFile); + const dynamicImports = dynamicImportedSymbols(checker, sourceFile); + const lowLevelImports = [...imports, ...dynamicImports]; + if ( + importsProfileResolverDirectly(sourceFile) || + dynamicallyImportsProfileResolverDirectly(sourceFile) || + lowLevelImports.some(symbol => isProfileResolverSymbol(checker, symbol)) + ) { + addViolation( + violations, + sourceFile, + root, + 'creation-preflight', + 'Creation handler imports low-level profile resolver mergeProfileConfiguration' + ); + } + for (const symbol of lowLevelImports) { + const identity = symbolIdentity(root, checker, symbol); + if (identity && forbiddenHandlerSymbols.has(identity)) { + addViolation( + violations, + sourceFile, + root, + 'creation-preflight', + `Creation handler imports low-level admission symbol ${identity.split('\0')[1]}` + ); + } + } + } + + const calls = collectCalls(sourceFile); + const registrationNamespaces = registrationNamespaceSymbols(root, checker, sourceFile); + + function checkRegistrationReferences(node: ts.Node): void { + if (ts.isIdentifier(node)) { + const localSymbol = checker.getSymbolAtLocation(node); + if ( + localSymbol && + registrationNamespaces.has(localSymbol) && + !ts.isNamespaceImport(node.parent) && + !( + (ts.isPropertyAccessExpression(node.parent) && node.parent.expression === node) || + (ts.isElementAccessExpression(node.parent) && + node.parent.expression === node && + ts.isStringLiteralLike(node.parent.argumentExpression)) + ) + ) { + addViolation( + violations, + sourceFile, + root, + 'registration-owner', + 'Registration namespaces must not be destructured, aliased, or passed as references', + node + ); + } + const identity = symbolIdentity(root, checker, checker.getSymbolAtLocation(node)); + if (identity) { + const separator = identity.lastIndexOf('\0'); + const originFile = identity.slice(0, separator); + const name = identity.slice(separator + 1); + if ( + originFile === registrationFile && + registrationNames.has(name) && + !isSupportedRegistrationReference(node) + ) { + addViolation( + violations, + sourceFile, + root, + 'registration-owner', + `${name} must be called directly; local aliases and passed references are not allowed`, + node + ); + } + } + } + if (ts.isElementAccessExpression(node)) { + const identity = symbolIdentity(root, checker, symbolAtExpression(checker, node)); + if (identity) { + const separator = identity.lastIndexOf('\0'); + const originFile = identity.slice(0, separator); + const name = identity.slice(separator + 1); + if ( + originFile === registrationFile && + registrationNames.has(name) && + !isDirectCallExpression(node) + ) { + addViolation( + violations, + sourceFile, + root, + 'registration-owner', + `${name} must be called directly; local aliases and passed references are not allowed`, + node + ); + } + } + } + ts.forEachChild(node, checkRegistrationReferences); + } + checkRegistrationReferences(sourceFile); + + if ( + handlerFiles.has(file) && + !calls.some( + call => callIdentity(root, checker, call) === `${preflightFile}\0preflightSessionCreation` + ) + ) { + addViolation( + violations, + sourceFile, + root, + 'creation-preflight', + 'Creation handler must call preflightSessionCreation; an import alone is not sufficient' + ); + } + + for (const call of calls) { + const identity = callIdentity(root, checker, call); + if (!identity) continue; + const separator = identity.lastIndexOf('\0'); + const originFile = separator >= 0 ? identity.slice(0, separator) : undefined; + const name = separator >= 0 ? identity.slice(separator + 1) : undefined; + if (originFile !== registrationFile || !name || !registrationNames.has(name)) continue; + + if (file !== registrationFile && !handlerFiles.has(file)) { + addViolation( + violations, + sourceFile, + root, + 'registration-owner', + `${name} may be called only by the creation handlers or session-registration.ts`, + call + ); + continue; + } + if (file === registrationFile) continue; + + const owner = containingFunction(call); + if (!owner?.body || !ts.isBlock(owner.body)) { + addViolation( + violations, + sourceFile, + root, + 'creation-preflight', + `${name} must execute in a block with an unconditional preflight result`, + call + ); + continue; + } + const admitted = preflightDeclaration(root, checker, owner.body); + if (!admitted || admitted.getStart(sourceFile) >= call.getStart(sourceFile)) { + addViolation( + violations, + sourceFile, + root, + 'creation-preflight', + `${name} must follow one unconditional const binding awaited from preflightSessionCreation in the same function body`, + call + ); + continue; + } + if (bindingIsMutated(checker, owner.body, admitted, call.getStart(sourceFile))) { + addViolation( + violations, + sourceFile, + root, + 'creation-preflight', + 'The admitted preflight result must not be overwritten or updated', + call + ); + continue; + } + const firstArgument = call.arguments[0]; + const admittedSymbol = canonicalSymbol(checker, checker.getSymbolAtLocation(admitted.name)); + const argumentSymbol = + firstArgument && ts.isIdentifier(firstArgument) + ? canonicalSymbol(checker, checker.getSymbolAtLocation(firstArgument)) + : undefined; + if (!firstArgument || !ts.isIdentifier(firstArgument) || argumentSymbol !== admittedSymbol) { + addViolation( + violations, + sourceFile, + root, + 'creation-preflight', + `${name} must receive the exact unshadowed preflight result as its first argument`, + call + ); + } + } + } + + return violations.toSorted((left, right) => + `${left.file}\0${String(left.line ?? 0)}\0${left.rule}\0${left.message}`.localeCompare( + `${right.file}\0${String(right.line ?? 0)}\0${right.rule}\0${right.message}` + ) + ); +} + +export async function runArchitectureCheck(root = serviceRoot): Promise { + const violations = await analyzeArchitecture(root); + if (violations.length) { + const details = violations + .map( + violation => + `[${violation.rule}] ${violation.file}${violation.line ? `:${violation.line}` : ''}\n ${violation.message}` + ) + .join('\n'); + throw new Error(`Found ${violations.length} architecture boundary violation(s):\n${details}`); + } + console.log('check:architecture: production ownership boundaries are valid.'); +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : undefined; +if (invokedPath === fileURLToPath(import.meta.url)) { + runArchitectureCheck().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/services/cloud-agent-next/scripts/check-duplication.test.ts b/services/cloud-agent-next/scripts/check-duplication.test.ts new file mode 100644 index 0000000000..d95ed60b43 --- /dev/null +++ b/services/cloud-agent-next/scripts/check-duplication.test.ts @@ -0,0 +1,401 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + compare, + parseAllowlist, + prune, + runCli, + scan, + scanner, + type DuplicationException, + type Finding, +} from './check-duplication.js'; + +const copiedSource = `export function summarize(input: readonly number[]) { + const positive = input.filter(value => Number.isFinite(value) && value > 0); + const negative = input.filter(value => Number.isFinite(value) && value < 0); + const total = positive.reduce((sum, value) => sum + value, 0); + const sorted = positive.toSorted((left, right) => left - right); + const first = sorted.at(0) ?? 0; + const last = sorted.at(-1) ?? 0; + const average = positive.length ? total / positive.length : 0; + const result = { + count: input.length, + positive: positive.length, + negative: negative.length, + total, + first, + last, + average, + range: last - first, + valid: input.every(value => Number.isFinite(value)), + }; + return Object.freeze(result); +} +`; +const extendedCopiedSource = copiedSource.replace( + ' return Object.freeze(result);', + ` const doubled = sorted.map(value => value * 2); + const bounded = doubled.filter(value => value < 1000); + const boundedTotal = bounded.reduce((sum, value) => sum + value, 0); + return Object.freeze({ + ...result, + doubled, + bounded, + boundedTotal, + });` +); +// Distinct surrounding token shapes keep weak-mode clone boundaries on copiedSource. +const twoBoundedCopies = `type BeforeOne = readonly [number, ...string[]]; +${copiedSource} +if (true) { throw new Error('after-one'); } + +class BeforeTwo extends Map {} +${copiedSource} +for (const item of new Set([1, 2])) { console.info(item); } +`; +const thirdBoundedCopy = `const beforeThree = Symbol.for('three') +${copiedSource} +switch (Date.now()) { case 0: break; default: debugger; } +`; +const first = 'src/first.ts'; +const second = 'wrapper/src/second.tsx'; + +async function fixture(files: Record, run: (root: string) => Promise) { + const root = await mkdtemp(join(tmpdir(), 'cloud-agent-duplication-test-')); + try { + for (const [name, content] of Object.entries(files)) { + const file = join(root, name); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, content); + } + await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +function allowance(finding: Finding): DuplicationException { + return { + files: finding.files, + fingerprint: finding.fingerprint, + owner: 'checker-tests', + reason: 'The deliberate fixture copy verifies bounded scanner debt.', + kind: 'intentional', + maxMatches: finding.matches, + maxTokens: finding.tokens, + }; +} + +function firstFinding(findings: readonly Finding[]): Finding { + const finding = findings[0]; + if (!finding) throw new Error('Expected the real scanner to find the copied fixture'); + return finding; +} + +async function writeAllowlist( + allowlistPath: string, + exceptions: readonly unknown[] +): Promise { + await mkdir(dirname(allowlistPath), { recursive: true }); + await writeFile( + allowlistPath, + `${JSON.stringify({ version: 1, scanner, exceptions }, null, 2)}\n` + ); +} + +async function editJson( + file: string, + edit: (value: Record) => void +): Promise { + const value = JSON.parse(await readFile(file, 'utf8')) as Record; + edit(value); + await writeFile(file, JSON.stringify(value)); +} + +const timeout = 90_000; + +describe('real jscpd ratchet', () => { + it( + 'detects cross-file copies, preserves line-shift identity, and rejects a third copy', + async () => { + await fixture({ [first]: copiedSource, [second]: copiedSource }, async root => { + const initial = await scan(root); + expect(initial.pairs).toBe(1); + expect(initial.findings[0]?.files).toEqual([first, second]); + const exceptions = initial.findings.map(allowance); + expect(compare(initial.findings, [])).toContainEqual( + expect.stringContaining('Unclassified') + ); + expect(compare(initial.findings, exceptions)).toEqual([]); + + await writeFile( + join(root, first), + `\n\n// shifted without changing the clone\n${copiedSource}` + ); + const shifted = await scan(root); + expect(shifted.findings[0]?.fingerprint).toBe(initial.findings[0]?.fingerprint); + expect(compare(shifted.findings, exceptions)).toEqual([]); + + await writeFile(join(root, 'src/third.ts'), copiedSource); + const thirdCopy = await scan(root); + expect(thirdCopy.pairs).toBeGreaterThan(shifted.pairs); + expect(thirdCopy.findings.some(finding => finding.files.includes('src/third.ts'))).toBe( + true + ); + expect(compare(thirdCopy.findings, exceptions)).toContainEqual( + expect.stringContaining('Unclassified duplication') + ); + expect(compare(thirdCopy.findings, prune(thirdCopy.findings, exceptions))).toContainEqual( + expect.stringContaining('Unclassified duplication') + ); + }); + }, + timeout + ); + + it( + 'uses a rescanned source mutation and saved exception to enforce match bounds', + async () => { + await fixture({ [first]: twoBoundedCopies }, async root => { + const allowlistPath = join(root, 'scripts/duplication-allowlist.json'); + const initialFinding = firstFinding((await scan(root)).findings); + expect(initialFinding.matches).toBe(1); + await writeAllowlist(allowlistPath, [allowance(initialFinding)]); + const before = await readFile(allowlistPath, 'utf8'); + + await writeFile(join(root, first), `${twoBoundedCopies}\n${thirdBoundedCopy}`); + const rescannedFinding = (await scan(root)).findings.find( + finding => + finding.fingerprint === initialFinding.fingerprint && + finding.files.join('\0') === initialFinding.files.join('\0') + ); + expect(rescannedFinding?.matches).toBe(2); + + await expect(runCli([], { root, allowlistPath })).rejects.toThrow('Duplication grew'); + expect(await readFile(allowlistPath, 'utf8')).toBe(before); + await expect(runCli(['--prune'], { root, allowlistPath })).rejects.toThrow( + 'Duplication grew' + ); + expect(await readFile(allowlistPath, 'utf8')).toBe(before); + }); + }, + timeout + ); + + it( + 'uses a rescanned source mutation and saved exceptions to enforce token bounds', + async () => { + await fixture({ [first]: copiedSource, [second]: copiedSource }, async root => { + const allowlistPath = join(root, 'scripts/duplication-allowlist.json'); + const initialFinding = firstFinding((await scan(root)).findings); + const initialException = allowance(initialFinding); + await writeAllowlist(allowlistPath, [initialException]); + + await writeFile(join(root, first), extendedCopiedSource); + await writeFile(join(root, second), extendedCopiedSource); + const tokenFinding = firstFinding((await scan(root)).findings); + expect(tokenFinding.tokens).toBeGreaterThan(initialFinding.tokens); + const boundedExpandedException = { + ...allowance(tokenFinding), + maxTokens: initialFinding.tokens, + }; + await writeAllowlist(allowlistPath, [initialException, boundedExpandedException]); + const before = await readFile(allowlistPath, 'utf8'); + + await expect(runCli([], { root, allowlistPath })).rejects.toThrow('Duplication grew'); + await expect(runCli(['--prune'], { root, allowlistPath })).rejects.toThrow( + 'Duplication grew' + ); + expect(await readFile(allowlistPath, 'utf8')).toBe(before); + }); + }, + timeout + ); + + it( + 'detects a same-file clone', + async () => { + await fixture( + { [first]: `${copiedSource}\n${copiedSource.replace('summarize', 'describe')}` }, + async root => { + const result = await scan(root); + expect(result.pairs).toBeGreaterThan(0); + expect( + result.findings.some( + finding => finding.files[0] === first && finding.files[1] === first + ) + ).toBe(true); + } + ); + }, + timeout + ); + + it( + 'fails closed for empty scope, inline suppression, scanner failure, and report failures', + async () => { + await fixture({}, async root => { + await expect(scan(root)).rejects.toThrow('No production source files'); + }); + await fixture({ [first]: `/* jscpd:ignore-start */\n${copiedSource}` }, async root => { + await expect(scan(root)).rejects.toThrow('Inline duplication suppression is not allowed'); + }); + await fixture({ [first]: copiedSource }, async root => { + await expect( + scan(root, { scannerCommand: [process.execPath, '-e', 'process.exit(7)', '--'] }) + ).rejects.toThrow('Duplication scanner failed (7)'); + await expect(scan(root, { scannerCommand: ['/missing/jscpd'] })).rejects.toThrow( + 'Duplication scanner failed to start' + ); + await expect( + scan(root, { + afterScanner: output => rm(join(output, 'jscpd-report.json')), + }) + ).rejects.toThrow('jscpd JSON report is missing'); + await expect( + scan(root, { + afterScanner: output => writeFile(join(output, 'jscpd-report.json'), '{'), + }) + ).rejects.toThrow('jscpd JSON report is malformed'); + await expect( + scan(root, { + afterScanner: output => + editJson(join(output, 'jscpd-report.sarif'), value => { + const runs = value.runs as Array>; + const tool = runs[0]?.tool as Record; + const driver = tool.driver as Record; + driver.version = '0.0.0'; + }), + }) + ).rejects.toThrow('Unexpected duplication scanner version'); + await expect( + scan(root, { + afterScanner: output => + editJson(join(output, 'jscpd-report.json'), value => { + const statistics = value.statistics as Record; + const total = statistics.total as Record; + total.clones = 1; + }), + }) + ).rejects.toThrow('Duplication reports disagree'); + }); + }, + timeout + ); + + it( + 'CLI check/report reject malformed or stale debt and prune only removes or lowers it', + async () => { + await fixture({ [first]: copiedSource, [second]: copiedSource }, async root => { + const allowlistPath = join(root, 'scripts/duplication-allowlist.json'); + const initial = await scan(root); + const exceptions = initial.findings.map(allowance); + await writeAllowlist(allowlistPath, exceptions); + const before = await readFile(allowlistPath, 'utf8'); + const output: string[] = []; + await runCli([], { root, allowlistPath, output: value => output.push(value) }); + await runCli(['--', '--report'], { + root, + allowlistPath, + output: value => output.push(value), + }); + expect(await readFile(allowlistPath, 'utf8')).toBe(before); + expect(output.join('\n')).toContain('bounded exceptions; no new duplication'); + expect(output.join('\n')).toContain('"findings"'); + + await writeFile(allowlistPath, '{'); + await expect(runCli([], { root, allowlistPath })).rejects.toThrow( + 'duplication allowlist is malformed' + ); + + const exception = exceptions[0]; + if (!exception) throw new Error('Expected the real scanner exception'); + const malformed = [{ ...exception, extra: true }]; + await writeAllowlist(allowlistPath, malformed); + const malformedBefore = await readFile(allowlistPath, 'utf8'); + await expect(runCli([], { root, allowlistPath })).rejects.toThrow( + 'Malformed duplication exception' + ); + await expect(runCli(['--prune'], { root, allowlistPath })).rejects.toThrow( + 'Malformed duplication exception' + ); + expect(await readFile(allowlistPath, 'utf8')).toBe(malformedBefore); + + await writeAllowlist(allowlistPath, [exception, exception]); + const duplicateBefore = await readFile(allowlistPath, 'utf8'); + await expect(runCli([], { root, allowlistPath })).rejects.toThrow('Duplicate exception'); + await expect(runCli(['--prune'], { root, allowlistPath })).rejects.toThrow( + 'Duplicate exception' + ); + expect(await readFile(allowlistPath, 'utf8')).toBe(duplicateBefore); + + const oversized = exceptions.map(entry => ({ + ...entry, + maxMatches: entry.maxMatches + 1, + maxTokens: entry.maxTokens + 1, + })); + await writeAllowlist(allowlistPath, oversized); + await runCli(['--prune'], { root, allowlistPath, output: () => undefined }); + expect(parseAllowlist(JSON.parse(await readFile(allowlistPath, 'utf8')))).toEqual( + exceptions + ); + const pruned = await readFile(allowlistPath, 'utf8'); + + await writeFile(join(root, 'src/third.ts'), copiedSource); + await expect(runCli(['--prune'], { root, allowlistPath })).rejects.toThrow( + 'Unclassified duplication' + ); + expect(await readFile(allowlistPath, 'utf8')).toBe(pruned); + + await rm(join(root, second)); + await rm(join(root, 'src/third.ts')); + await expect(runCli([], { root, allowlistPath })).rejects.toThrow('Stale exception'); + await runCli(['--prune'], { root, allowlistPath, output: () => undefined }); + expect(parseAllowlist(JSON.parse(await readFile(allowlistPath, 'utf8')))).toEqual([]); + }); + }, + timeout + ); +}); + +describe('allowlist validation', () => { + const entry: DuplicationException = { + files: [first, second], + fingerprint: '0123456789abcdef', + owner: 'checker-tests', + reason: 'Reviewed protocol compatibility copy.', + kind: 'intentional', + maxMatches: 1, + maxTokens: 100, + }; + const data = { version: 1, scanner, exceptions: [entry] }; + + it('rejects stale, malformed, duplicate, unsorted, and scanner-mismatched exceptions', () => { + expect(parseAllowlist(data)).toEqual([entry]); + expect(compare([], [entry])).toContainEqual(expect.stringContaining('Stale exception')); + for (const change of [ + { owner: '' }, + { reason: ' ' }, + { fingerprint: 'bad' }, + { kind: 'ignore' }, + { maxMatches: 0 }, + { maxTokens: 0 }, + { files: [second, first] }, + { files: [first] }, + { files: ['../outside.ts', second] }, + { extra: true }, + ]) { + expect(() => parseAllowlist({ ...data, exceptions: [{ ...entry, ...change }] })).toThrow(); + } + expect(() => parseAllowlist({ ...data, exceptions: [entry, entry] })).toThrow( + 'Duplicate exception' + ); + expect(() => parseAllowlist({ ...data, scanner: 'jscpd@latest' })).toThrow('Unsupported'); + expect(() => parseAllowlist({ ...data, extra: true })).toThrow( + 'Malformed duplication allowlist' + ); + }); +}); diff --git a/services/cloud-agent-next/scripts/check-duplication.ts b/services/cloud-agent-next/scripts/check-duplication.ts new file mode 100644 index 0000000000..d1b78760d6 --- /dev/null +++ b/services/cloud-agent-next/scripts/check-duplication.ts @@ -0,0 +1,508 @@ +import { spawn } from 'node:child_process'; +import { lstat, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath, URL } from 'node:url'; + +export const scanner = 'jscpd@5.0.16'; +const scannerVersion = scanner.slice('jscpd@'.length); +const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const defaultAllowlist = join(serviceRoot, 'scripts/duplication-allowlist.json'); +const sourceExtensions = new Set([ + '.ts', + '.tsx', + '.js', + '.jsx', + '.mjs', + '.cjs', + '.mts', + '.cts', + '.css', +]); +const excludedDirectories = new Set([ + '__fixtures__', + '__mocks__', + '__snapshots__', + '__tests__', + '.wrangler', + 'build', + 'coverage', + 'deps', + 'dist', + 'fixtures', + 'fixture', + 'generated', + 'node_modules', + 'out', + 'recordings', + 'specs', + 'test', + 'testdata', + 'tests', +]); + +export type Finding = { + files: [string, string]; + fingerprint: string; + matches: number; + tokens: number; + locations: { file: string; start: number; end: number }[]; +}; + +export type DuplicationException = { + files: [string, string]; + fingerprint: string; + owner: string; + reason: string; + kind: 'legacy' | 'intentional'; + maxMatches: number; + maxTokens: number; +}; + +export type ScanResult = { + scanner: typeof scanner; + files: number; + lines: number; + pairs: number; + duplicatedLines: number; + duplicatedTokens: number; + findings: Finding[]; +}; + +type ScanOptions = { + scannerCommand?: readonly string[]; + afterScanner?: (outputDirectory: string) => void | Promise; +}; + +type CliOptions = { + root?: string; + allowlistPath?: string; + output?: (text: string) => void; +}; + +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function object(value: unknown): Record { + if (!record(value)) throw new Error('Expected a JSON object'); + return value; +} + +function array(value: unknown): unknown[] { + if (!Array.isArray(value)) throw new Error('Expected a JSON array'); + return value; +} + +function text(value: unknown): string { + if (typeof value !== 'string' || !value.trim()) throw new Error('Expected a non-empty string'); + return value; +} + +function integer(value: unknown, minimum = 0): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum) { + throw new Error(`Expected an integer of at least ${minimum}`); + } + return value; +} + +function repositoryPath(value: unknown): string { + const file = text(value); + if ( + isAbsolute(file) || + file.includes('\\') || + file.split('/').some(part => !part || part === '.' || part === '..') + ) { + throw new Error(`Expected a repository-relative path: ${file}`); + } + return file; +} + +function fingerprint(value: unknown): string { + const hash = text(value); + if (!/^[a-f0-9]{16}$/.test(hash)) { + throw new Error(`Invalid duplication fingerprint: ${hash}`); + } + return hash; +} + +function exactKeys( + value: Record, + expected: readonly string[], + description: string +): void { + const actual = Object.keys(value).toSorted(); + if (actual.join('\0') !== [...expected].sort().join('\0')) { + throw new Error(`Malformed ${description}: expected only ${expected.join(', ')}`); + } +} + +function findingKey(entry: Pick): string { + return JSON.stringify([entry.files, entry.fingerprint]); +} + +export function parseAllowlist(value: unknown): DuplicationException[] { + const data = object(value); + exactKeys(data, ['version', 'scanner', 'exceptions'], 'duplication allowlist'); + if (data.version !== 1 || data.scanner !== scanner) { + throw new Error('Unsupported duplication allowlist version or scanner'); + } + + const seen = new Set(); + return array(data.exceptions).map(value => { + const item = object(value); + exactKeys( + item, + ['files', 'fingerprint', 'owner', 'reason', 'kind', 'maxMatches', 'maxTokens'], + 'duplication exception' + ); + const parsedFiles = array(item.files).map(repositoryPath); + if ( + parsedFiles.length !== 2 || + parsedFiles[0] === undefined || + parsedFiles[1] === undefined || + parsedFiles.join('\0') !== parsedFiles.toSorted().join('\0') + ) { + throw new Error('An exception must contain exactly two sorted file paths'); + } + const kind = item.kind; + if (kind !== 'legacy' && kind !== 'intentional') { + throw new Error('An exception kind must be legacy or intentional'); + } + const entry: DuplicationException = { + files: [parsedFiles[0], parsedFiles[1]], + fingerprint: fingerprint(item.fingerprint), + owner: text(item.owner), + reason: text(item.reason), + kind, + maxMatches: integer(item.maxMatches, 1), + maxTokens: integer(item.maxTokens, 1), + }; + const key = findingKey(entry); + if (seen.has(key)) throw new Error(`Duplicate exception: ${entry.files.join(' and ')}`); + seen.add(key); + return entry; + }); +} + +function extension(file: string): string { + for (const candidate of sourceExtensions) { + if (file.endsWith(candidate)) return candidate; + } + return ''; +} + +function isExcluded(relativePath: string): boolean { + const parts = relativePath.split('/'); + const basename = parts.at(-1) ?? ''; + return ( + parts.some(part => excludedDirectories.has(part)) || + /\.(?:test|spec)\.[^.]+$/.test(basename) || + /(?:^|[-_.])fixtures?(?:[-_.]|$)/.test(basename) || + /(?:^|[-_.])test[-_.]?data(?:[-_.]|$)/.test(basename) || + /\.d\.[cm]?tsx?$/.test(basename) || + /\.(?:gen|generated)\.[cm]?tsx?$/.test(basename) + ); +} + +async function collectFiles(root: string): Promise { + const files: string[] = []; + async function visit(directory: string): Promise { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + for (const entry of entries) { + const absolute = join(directory, entry.name); + const relativePath = relative(root, absolute).replaceAll('\\', '/'); + if (isExcluded(relativePath)) continue; + if (entry.isDirectory()) { + await visit(absolute); + } else if (entry.isFile() && extension(entry.name)) { + const info = await lstat(absolute); + if (info.isSymbolicLink()) continue; + if (info.size > 10 * 1024 * 1024) { + throw new Error(`Source exceeds the duplication scanner size limit: ${relativePath}`); + } + const content = await readFile(absolute, 'utf8'); + if (/jscpd:ignore-(?:start|end)/.test(content)) { + throw new Error( + `Inline duplication suppression is not allowed: ${relativePath}. Use a bounded exception instead.` + ); + } + files.push(absolute); + } + } + } + + await visit(join(root, 'src')); + await visit(join(root, 'wrapper/src')); + return files.toSorted(); +} + +function defaultScannerCommand(): readonly string[] { + const require = createRequire(import.meta.url); + const packageDirectory = dirname(require.resolve('jscpd/package.json')); + return [process.execPath, join(packageDirectory, 'run-jscpd.js')]; +} + +async function run( + command: readonly string[], + args: readonly string[], + cwd: string +): Promise { + if (!command.length) throw new Error('Duplication scanner command is missing'); + await new Promise((resolvePromise, reject) => { + const child = spawn(command[0], [...command.slice(1), ...args], { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout?.setEncoding('utf8').on('data', chunk => (stdout += chunk)); + child.stderr?.setEncoding('utf8').on('data', chunk => (stderr += chunk)); + child.once('error', error => + reject(new Error(`Duplication scanner failed to start: ${error.message}`)) + ); + child.once('close', code => { + if (code === 0) resolvePromise(); + else reject(new Error(`Duplication scanner failed (${String(code)}):\n${stderr || stdout}`)); + }); + }); +} + +async function readJson(file: string, description: string): Promise { + let content: string; + try { + content = await readFile(file, 'utf8'); + } catch (error) { + throw new Error(`${description} is missing: ${(error as Error).message}`); + } + try { + return JSON.parse(content) as unknown; + } catch (error) { + throw new Error(`${description} is malformed: ${(error as Error).message}`); + } +} + +export async function scan(rootInput: string, options: ScanOptions = {}): Promise { + const root = await realpath(rootInput); + const files = await collectFiles(root); + if (!files.length) throw new Error('No production source files found for duplication analysis'); + + const outputDirectory = await mkdtemp(join(tmpdir(), 'cloud-agent-duplication-')); + try { + const configPath = join(outputDirectory, 'config.json'); + await writeFile( + configPath, + JSON.stringify({ + path: files, + mode: 'weak', + minLines: 10, + minTokens: 100, + format: ['typescript', 'tsx', 'javascript', 'jsx', 'css'], + crossFormats: [['typescript', 'tsx']], + maxSize: '10mb', + absolute: true, + noColors: true, + noTips: true, + reporters: ['json', 'sarif'], + output: outputDirectory, + }) + ); + const command = options.scannerCommand ?? defaultScannerCommand(); + await run(command, ['--config', configPath, '--workers', '1', '--no-gitignore'], root); + await options.afterScanner?.(outputDirectory); + + const json = object( + await readJson(join(outputDirectory, 'jscpd-report.json'), 'jscpd JSON report') + ); + const total = object(object(json.statistics).total); + const sarif = object( + await readJson(join(outputDirectory, 'jscpd-report.sarif'), 'jscpd SARIF report') + ); + const runs = array(sarif.runs); + if (runs.length !== 1) throw new Error('Expected one duplication scanner run'); + const scannerRun = object(runs[0]); + if (object(object(scannerRun.tool).driver).version !== scannerVersion) { + throw new Error('Unexpected duplication scanner version'); + } + const results = array(scannerRun.results); + if (integer(total.clones) !== results.length) { + throw new Error('Duplication reports disagree on the number of findings'); + } + + const findings = new Map(); + for (const value of results) { + const result = object(value); + if (result.ruleId !== 'jscpd/duplicate-code') { + throw new Error('Unexpected duplication scanner result'); + } + const locations = [...array(result.locations), ...array(result.relatedLocations)].map( + value => { + const physicalLocation = object(object(value).physicalLocation); + const artifact = object(physicalLocation.artifactLocation); + const uri = text(artifact.uri); + let absolute: string; + if (isAbsolute(uri)) { + absolute = uri; + } else { + const baseId = text(artifact.uriBaseId); + const base = text(object(object(scannerRun.originalUriBaseIds)[baseId]).uri); + absolute = fileURLToPath(new URL(uri, base)); + } + const region = object(physicalLocation.region); + return { + file: repositoryPath(relative(root, absolute).replaceAll('\\', '/')), + start: integer(region.startLine, 1), + end: integer(region.endLine, 1), + }; + } + ); + if (locations.length !== 2 || locations[0] === undefined || locations[1] === undefined) { + throw new Error('Expected two locations for a duplicated block'); + } + const sortedFiles = locations.map(location => location.file).toSorted(); + const hash = fingerprint(object(result.partialFingerprints)['jscpdCloneHash/v1']); + const tokens = integer(object(result.properties).token_count, 1); + const filesTuple: [string, string] = [sortedFiles[0], sortedFiles[1]]; + const key = findingKey({ files: filesTuple, fingerprint: hash }); + const previous = findings.get(key); + findings.set(key, { + files: filesTuple, + fingerprint: hash, + matches: (previous?.matches ?? 0) + 1, + tokens: Math.max(previous?.tokens ?? 0, tokens), + locations: [...(previous?.locations ?? []), locations[0], locations[1]], + }); + } + + return { + scanner, + files: integer(total.sources, 1), + lines: integer(total.lines, 1), + pairs: results.length, + duplicatedLines: integer(total.duplicatedLines), + duplicatedTokens: integer(total.duplicatedTokens), + findings: [...findings.values()].toSorted((left, right) => + findingKey(left).localeCompare(findingKey(right)) + ), + }; + } finally { + await rm(outputDirectory, { recursive: true, force: true }); + } +} + +export function compare( + findings: readonly Finding[], + exceptions: readonly DuplicationException[] +): string[] { + const allowed = new Map(exceptions.map(entry => [findingKey(entry), entry])); + const current = new Set(findings.map(findingKey)); + const failures: string[] = []; + for (const finding of findings) { + const entry = allowed.get(findingKey(finding)); + const locations = finding.locations + .map(location => `${location.file}:${location.start}-${location.end}`) + .join(' and '); + if (!entry) { + failures.push( + `Unclassified duplication (${finding.tokens} tokens, ${finding.matches} match(es)): ${locations}` + ); + } else if (finding.matches > entry.maxMatches || finding.tokens > entry.maxTokens) { + failures.push( + `Duplication grew: ${locations}. Matches ${finding.matches}/${entry.maxMatches}, ` + + `tokens ${finding.tokens}/${entry.maxTokens}. ${entry.reason}` + ); + } + } + for (const entry of exceptions) { + if (!current.has(findingKey(entry))) { + failures.push( + `Stale exception: ${entry.files.join(' and ')} (${entry.fingerprint}). Remove it to lock in cleanup.` + ); + } + } + return failures; +} + +export function prune( + findings: readonly Finding[], + exceptions: readonly DuplicationException[] +): DuplicationException[] { + const current = new Map(findings.map(finding => [findingKey(finding), finding])); + return exceptions.flatMap(entry => { + const finding = current.get(findingKey(entry)); + return finding + ? [ + { + ...entry, + maxMatches: Math.min(entry.maxMatches, finding.matches), + maxTokens: Math.min(entry.maxTokens, finding.tokens), + }, + ] + : []; + }); +} + +function serializeAllowlist(exceptions: readonly DuplicationException[]): string { + return `${JSON.stringify({ version: 1, scanner, exceptions }, null, 2)}\n`; +} + +export async function runCli(args: readonly string[], options: CliOptions = {}): Promise { + const normalizedArgs = args[0] === '--' ? args.slice(1) : args; + const mode = normalizedArgs[0]; + if (normalizedArgs.length > 1 || (mode && !['--help', '--report', '--prune'].includes(mode))) { + throw new Error('Usage: pnpm run check:duplication [-- --report | --prune | --help]'); + } + const output = options.output ?? console.log; + if (mode === '--help') { + output( + 'Check cloud-agent-next production code for copied blocks of at least 10 lines and 100 tokens.\n' + + '--report prints findings without changing the allowlist.\n' + + '--prune only removes stale exceptions and lowers existing limits; it cannot admit new findings.' + ); + return; + } + + const root = options.root ?? serviceRoot; + const allowlistPath = options.allowlistPath ?? defaultAllowlist; + const result = await scan(root); + if (mode === '--report') { + output(JSON.stringify(result, null, 2)); + return; + } + + const allowlistValue = await readJson(allowlistPath, 'duplication allowlist'); + const previous = parseAllowlist(allowlistValue); + const exceptions = mode === '--prune' ? prune(result.findings, previous) : previous; + const failures = compare(result.findings, exceptions); + if (failures.length) { + throw new Error( + `${failures.join('\n')}\nRefactor new copies or review a bounded exception in ${relative(root, allowlistPath)}.` + ); + } + if (mode === '--prune') { + if (JSON.stringify(previous) !== JSON.stringify(exceptions)) { + await writeFile(allowlistPath, serializeAllowlist(exceptions)); + } + } + const percentage = ((100 * result.duplicatedLines) / result.lines).toFixed(2); + output( + `check:duplication: ${result.pairs} block pairs, ${result.duplicatedLines} duplicated lines ` + + `(${percentage}%), ${result.files} eligible files.\n` + + `${exceptions.length} bounded exceptions; no new duplication.` + ); +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : undefined; +if (invokedPath === fileURLToPath(import.meta.url)) { + runCli(process.argv.slice(2)).catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/services/cloud-agent-next/scripts/duplication-allowlist.json b/services/cloud-agent-next/scripts/duplication-allowlist.json new file mode 100644 index 0000000000..d52073f35e --- /dev/null +++ b/services/cloud-agent-next/scripts/duplication-allowlist.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "scanner": "jscpd@5.0.16", + "exceptions": [ + { + "files": ["src/session-service.ts", "src/session-service.ts"], + "fingerprint": "a3bbedbe1e52f04e", + "owner": "session-service workspace preparation", + "reason": "Legacy sandbox preparation and control-plane attachment intentionally project the same workspace execution context while retaining distinct lifecycle flows.", + "kind": "intentional", + "maxMatches": 1, + "maxTokens": 101 + }, + { + "files": [ + "wrapper/src/control/sandbox-control-client.ts", + "wrapper/src/control/sandbox-control-client.ts" + ], + "fingerprint": "cddc940139a221f0", + "owner": "sandbox control transport", + "reason": "Event publication and native-runtime retirement intentionally use the same request-receipt timeout protocol while retaining operation-specific payloads and errors.", + "kind": "intentional", + "maxMatches": 1, + "maxTokens": 109 + } + ] +} diff --git a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts index b567e6aae9..38ebf3753a 100644 --- a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts +++ b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts @@ -697,7 +697,7 @@ export class CloudAgentSession extends DurableObject { parentMessageId ), ensureTerminalMessageEvent: event => { - this.ensureTerminalMessageEvent({ + this.ensureUniqueMessageEvent({ executionId: '' as EventSourceId, ...event, }); @@ -1152,7 +1152,7 @@ export class CloudAgentSession extends DurableObject { isWrapperRunFinalizing(await getWrapperRuntimeState(this.ctx.storage)), checkBillingAdmission: () => this.containerBillingAdmissionFailure(), ensureQueuedMessageEvent: event => { - this.ensureQueuedMessageEvent({ + this.ensureUniqueMessageEvent({ executionId: '' as EventSourceId, ...event, }); @@ -1518,34 +1518,7 @@ export class CloudAgentSession extends DurableObject { }); } - private ensureTerminalMessageEvent(params: { - executionId: EventSourceId; - sessionId: string; - streamEventType: string; - payload: string; - timestamp: number; - entityId: string; - }): void { - const eventId = this.eventQueries.insertUnique({ - executionId: params.executionId, - sessionId: params.sessionId, - streamEventType: params.streamEventType, - payload: params.payload, - timestamp: params.timestamp, - entityId: params.entityId, - }); - if (eventId === null) return; - this.broadcastEvent({ - id: eventId, - execution_id: params.executionId, - session_id: params.sessionId, - stream_event_type: params.streamEventType, - payload: params.payload, - timestamp: params.timestamp, - }); - } - - private ensureQueuedMessageEvent(params: { + private ensureUniqueMessageEvent(params: { executionId: EventSourceId; sessionId: string; streamEventType: string; diff --git a/services/cloud-agent-next/src/persistence/schemas.test.ts b/services/cloud-agent-next/src/persistence/schemas.test.ts index 2c3e97cd3a..d1ec167e91 100644 --- a/services/cloud-agent-next/src/persistence/schemas.test.ts +++ b/services/cloud-agent-next/src/persistence/schemas.test.ts @@ -6,6 +6,7 @@ import { MetadataSchema, modelIdSchema, RuntimeAgentSchema, + SessionProfileBundleSchema, RuntimeSkillSchema, RuntimeSkillsSchema, } from './schemas.js'; @@ -418,6 +419,75 @@ describe('MCPServerConfigSchema', () => { }); describe('MetadataSchema', () => { + const legacyMetadata = { + version: 1, + sessionId: 'session123', + userId: 'user123', + timestamp: 1, + }; + + it.each([ + ['envVars', { FEATURE: 'enabled' }, { ['A'.repeat(257)]: 'value' }], + [ + 'encryptedSecrets', + { + API_TOKEN: { + encryptedData: 'ciphertext', + encryptedDEK: 'encrypted-key', + algorithm: 'rsa-aes-256-gcm', + version: 1, + }, + }, + { + API_TOKEN: { + encryptedData: 'ciphertext', + encryptedDEK: 'encrypted-key', + algorithm: 'rsa-aes-256-gcm', + version: 2, + }, + }, + ], + ['setupCommands', ['pnpm install'], ['x'.repeat(501)]], + [ + 'mcpServers', + { docs: { type: 'remote', url: 'https://example.com/mcp' } }, + { docs: { type: 'remote', url: 'not-a-url' } }, + ], + [ + 'runtimeSkills', + [{ name: 'review', rawMarkdown: '# Review' }], + [{ name: 'Not-A-Slug', rawMarkdown: '# Review' }], + ], + [ + 'runtimeAgents', + [{ slug: 'reviewer', name: 'Reviewer', config: { prompt: 'Review the diff' } }], + [{ slug: 'code', name: 'Code override', config: {} }], + ], + ] as const)( + 'keeps nested and legacy-flat %s validation equivalent for representative values', + (field, valid, invalid) => { + expect(SessionProfileBundleSchema.safeParse({ [field]: valid }).success).toBe(true); + expect(MetadataSchema.safeParse({ ...legacyMetadata, [field]: valid }).success).toBe(true); + expect(SessionProfileBundleSchema.safeParse({ [field]: invalid }).success).toBe(false); + expect(MetadataSchema.safeParse({ ...legacyMetadata, [field]: invalid }).success).toBe(false); + } + ); + + it('keeps kilo commands in nested profiles only', () => { + const kiloCommands = [{ name: 'review', template: 'Review $ARGUMENTS' }]; + + expect(SessionProfileBundleSchema.parse({ kiloCommands })).toMatchObject({ kiloCommands }); + expect(MetadataSchema.parse({ ...legacyMetadata, profile: { kiloCommands } })).toMatchObject({ + profile: { kiloCommands }, + }); + expect( + MetadataSchema.parse({ + ...legacyMetadata, + kiloCommands, + }) + ).not.toHaveProperty('kiloCommands'); + }); + describe('valid envVars', () => { it('should accept valid environment variables within limits', () => { const metadata = { diff --git a/services/cloud-agent-next/src/persistence/schemas.ts b/services/cloud-agent-next/src/persistence/schemas.ts index 6f169d5a49..b2005d45b0 100644 --- a/services/cloud-agent-next/src/persistence/schemas.ts +++ b/services/cloud-agent-next/src/persistence/schemas.ts @@ -394,7 +394,7 @@ export type InitialExecutionPayload = z.infer Object.keys(obj).length <= 50, { @@ -411,6 +411,10 @@ export const SessionProfileBundleSchema = z.object({ .optional(), runtimeSkills: RuntimeSkillsSchema.optional(), runtimeAgents: RuntimeAgentsSchema.optional(), +}; + +export const SessionProfileBundleSchema = z.object({ + ...SharedSessionProfileFields, kiloCommands: RuntimeKiloCommandsSchema.optional(), }); @@ -446,22 +450,7 @@ export const MetadataSchema = z.object({ */ profile: SessionProfileBundleSchema.optional(), // --- Legacy flat profile fields (read-only fallback, no longer written) --- - envVars: z - .record(z.string().max(256), z.string().max(256)) - .refine(obj => Object.keys(obj).length <= 50, { - message: 'Maximum 50 environment variables allowed', - }) - .optional(), - encryptedSecrets: EncryptedSecretsSchema.optional(), - setupCommands: z.array(z.string().max(500)).max(Limits.MAX_SETUP_COMMANDS).optional(), - mcpServers: z - .record(z.string().max(100), MCPServerConfigSchema) - .refine(obj => Object.keys(obj).length <= Limits.MAX_MCP_SERVERS, { - message: `Maximum ${Limits.MAX_MCP_SERVERS} MCP servers allowed`, - }) - .optional(), - runtimeSkills: RuntimeSkillsSchema.optional(), - runtimeAgents: RuntimeAgentsSchema.optional(), + ...SharedSessionProfileFields, upstreamBranch: branchNameSchema.optional(), kiloSessionId: z.string().optional(), createdOnPlatform: z.string().max(100).optional(), diff --git a/services/cloud-agent-next/src/router/handlers/session-creation-preflight.test.ts b/services/cloud-agent-next/src/router/handlers/session-creation-preflight.test.ts new file mode 100644 index 0000000000..48237acf91 --- /dev/null +++ b/services/cloud-agent-next/src/router/handlers/session-creation-preflight.test.ts @@ -0,0 +1,307 @@ +import type * as CloudAgentProfile from '@kilocode/cloud-agent-profile'; +import { TRPCError } from '@trpc/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SessionCreateRequest } from '../../session/session-requests.js'; +import type { TRPCContext } from '../../types.js'; + +const { + db, + mergeProfileConfigurationMock, + assertKiloModelAvailableMock, + assertRepositoryAccessMock, + assertOrganizationMembershipMock, +} = vi.hoisted(() => ({ + db: { mockedDb: true }, + mergeProfileConfigurationMock: vi.fn(), + assertKiloModelAvailableMock: vi.fn(), + assertRepositoryAccessMock: vi.fn(), + assertOrganizationMembershipMock: vi.fn(), +})); + +vi.mock('@kilocode/cloud-agent-profile', async importActual => { + const actual = await importActual(); + return { + ...actual, + mergeProfileConfiguration: mergeProfileConfigurationMock, + }; +}); + +vi.mock('../../db/pg.js', () => ({ + getPgDb: vi.fn(() => db), +})); + +vi.mock('../../model-validation.js', () => ({ + assertKiloModelAvailable: assertKiloModelAvailableMock, +})); + +vi.mock('../../session/validate-repository-access.js', () => ({ + assertRepositoryAccessBeforeSessionCreation: assertRepositoryAccessMock, +})); + +vi.mock('./organization-membership.js', () => ({ + assertOrganizationMembership: assertOrganizationMembershipMock, +})); + +import { + preflightSessionCreation, + profileResolutionPolicyForSessionCreateOrigin, +} from './session-creation-preflight.js'; + +const context = { + env: { + HYPERDRIVE: { connectionString: 'postgres://preflight-test' }, + } as TRPCContext['env'], + userId: 'user-123', + authToken: 'test-auth-token', +}; + +function request(overrides: Partial = {}): SessionCreateRequest { + return { + initialTurn: { type: 'prompt', id: 'message-123', prompt: 'Test prompt' }, + agent: { mode: 'code', model: 'kilo/test-model' }, + repository: { type: 'github', repo: 'acme/repo' }, + ...overrides, + }; +} + +describe('session creation preflight', () => { + beforeEach(() => { + vi.clearAllMocks(); + mergeProfileConfigurationMock.mockResolvedValue({}); + assertKiloModelAvailableMock.mockResolvedValue(undefined); + assertRepositoryAccessMock.mockResolvedValue(undefined); + assertOrganizationMembershipMock.mockResolvedValue(undefined); + }); + + it('owns the ordered admission path and returns profile-resolved configuration', async () => { + const steps: string[] = []; + const organizationId = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; + const runtimeAgent = { + slug: 'reviewer', + name: 'Reviewer', + config: { prompt: 'Review the diff', mode: 'subagent' }, + }; + assertOrganizationMembershipMock.mockImplementationOnce(async () => { + steps.push('organization'); + }); + assertRepositoryAccessMock.mockImplementationOnce(async () => { + steps.push('repository'); + }); + mergeProfileConfigurationMock.mockImplementationOnce(async () => { + steps.push('profile'); + return { envVars: { RESOLVED: 'true' }, agents: [runtimeAgent] }; + }); + assertKiloModelAvailableMock.mockImplementationOnce(async () => { + steps.push('model'); + }); + const endpointValidator = vi.fn(() => { + steps.push('endpoint'); + }); + const input = request({ + agent: { mode: 'reviewer', model: 'kilo/test-model' }, + profile: { id: '123e4567-e89b-12d3-a456-426614174011' }, + options: { + kilocodeOrganizationId: organizationId, + createdOnPlatform: 'cloud-agent-web', + }, + }); + + const resolved = await preflightSessionCreation( + input, + context, + 'prepareSession', + endpointValidator + ); + + expect(steps).toEqual(['organization', 'repository', 'profile', 'endpoint', 'model']); + expect(assertOrganizationMembershipMock).toHaveBeenCalledWith(db, 'user-123', organizationId); + expect(mergeProfileConfigurationMock).toHaveBeenCalledWith( + db, + expect.objectContaining({ + profileId: '123e4567-e89b-12d3-a456-426614174011', + owner: { type: 'organization', id: organizationId }, + userId: 'user-123', + repoFullName: 'acme/repo', + platform: 'github', + }) + ); + expect(endpointValidator).toHaveBeenCalledWith(resolved); + expect(resolved).toEqual({ + ...input, + profile: { + ...input.profile, + resolved: { + envVars: { RESOLVED: 'true' }, + setupCommands: undefined, + encryptedSecrets: undefined, + mcpServers: undefined, + runtimeSkills: undefined, + runtimeAgents: [runtimeAgent], + kiloCommands: undefined, + }, + }, + }); + expect(assertKiloModelAvailableMock).toHaveBeenCalledWith({ + env: context.env, + submittedModel: 'kilo/test-model', + originalToken: 'test-auth-token', + originalOrganizationId: organizationId, + createdOnPlatform: 'cloud-agent-web', + procedure: 'prepareSession', + }); + }); + + it('stops at rejected organization authorization', async () => { + assertOrganizationMembershipMock.mockRejectedValueOnce( + new TRPCError({ code: 'FORBIDDEN', message: 'Membership rejected' }) + ); + + await expect( + preflightSessionCreation( + request({ + options: { kilocodeOrganizationId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479' }, + }), + context, + 'start' + ) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + + expect(assertRepositoryAccessMock).not.toHaveBeenCalled(); + expect(mergeProfileConfigurationMock).not.toHaveBeenCalled(); + expect(assertKiloModelAvailableMock).not.toHaveBeenCalled(); + }); + + it('stops at rejected repository authorization', async () => { + const endpointValidator = vi.fn(); + assertRepositoryAccessMock.mockRejectedValueOnce( + new TRPCError({ code: 'BAD_REQUEST', message: 'Repository rejected' }) + ); + + await expect( + preflightSessionCreation(request(), context, 'start', endpointValidator) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + + expect(mergeProfileConfigurationMock).not.toHaveBeenCalled(); + expect(endpointValidator).not.toHaveBeenCalled(); + expect(assertKiloModelAvailableMock).not.toHaveBeenCalled(); + }); + + it.each(['cloud-agent-web', 'slack', 'github', 'linear', 'discord', 'app-builder'])( + 'resolves implicit profiles for approved %s creation', + async createdOnPlatform => { + await preflightSessionCreation( + request({ options: { createdOnPlatform } }), + context, + 'prepareSession' + ); + + expect(mergeProfileConfigurationMock).toHaveBeenCalledOnce(); + } + ); + + it.each([undefined, '', 'unknown', 'code-review'])( + 'does not resolve implicit profiles for non-approved origin %s', + async createdOnPlatform => { + await preflightSessionCreation( + request({ options: { createdOnPlatform } }), + context, + 'prepareSession' + ); + + expect(mergeProfileConfigurationMock).not.toHaveBeenCalled(); + } + ); + + it('resolves an explicit profile for a non-approved origin', async () => { + await preflightSessionCreation( + request({ + profile: { id: '123e4567-e89b-12d3-a456-426614174011' }, + options: { createdOnPlatform: 'code-review' }, + }), + context, + 'prepareSession' + ); + + expect(mergeProfileConfigurationMock).toHaveBeenCalledOnce(); + }); + + it('accepts a built-in mode without resolved runtime agents', async () => { + await expect( + preflightSessionCreation( + request({ agent: { mode: 'code', model: 'kilo/test-model' } }), + context, + 'start' + ) + ).resolves.toBeDefined(); + }); + + it('accepts a custom mode from the resolved profile', async () => { + mergeProfileConfigurationMock.mockResolvedValueOnce({ + agents: [ + { + slug: 'reviewer', + name: 'Reviewer', + config: { prompt: 'Review the diff', mode: 'subagent' }, + }, + ], + }); + + await expect( + preflightSessionCreation( + request({ + agent: { mode: 'reviewer', model: 'kilo/test-model' }, + options: { createdOnPlatform: 'cloud-agent-web' }, + }), + context, + 'start' + ) + ).resolves.toBeDefined(); + }); + + it('rejects a missing custom mode before endpoint or model validation', async () => { + const endpointValidator = vi.fn(); + + await expect( + preflightSessionCreation( + request({ agent: { mode: 'missing-agent', model: 'kilo/test-model' } }), + context, + 'start', + endpointValidator + ) + ).rejects.toThrow('does not match any runtimeAgents'); + + expect(endpointValidator).not.toHaveBeenCalled(); + expect(assertKiloModelAvailableMock).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: 'command', + initialTurn: { + type: 'command' as const, + id: 'message-command', + command: 'compact', + arguments: '--aggressive', + }, + }, + { name: 'clone-only', initialTurn: undefined }, + ])('skips model validation for a $name request', async ({ initialTurn }) => { + await preflightSessionCreation(request({ initialTurn }), context, 'prepareSession'); + + expect(assertKiloModelAvailableMock).not.toHaveBeenCalled(); + }); +}); + +describe('profile resolution policy', () => { + it.each([ + { origin: 'cloud-agent-web', expected: 'include-web-defaults' }, + { origin: 'webhook', expected: 'include-web-defaults' }, + { origin: 'scheduled', expected: 'include-web-defaults' }, + { origin: undefined, expected: 'explicit-profile-only' }, + { origin: 'unknown', expected: 'explicit-profile-only' }, + ])('selects $expected for origin $origin', ({ origin, expected }) => { + expect(profileResolutionPolicyForSessionCreateOrigin(origin)).toEqual({ + defaultProfileResolution: expected, + }); + }); +}); diff --git a/services/cloud-agent-next/src/router/handlers/session-creation-preflight.ts b/services/cloud-agent-next/src/router/handlers/session-creation-preflight.ts new file mode 100644 index 0000000000..118cb4c312 --- /dev/null +++ b/services/cloud-agent-next/src/router/handlers/session-creation-preflight.ts @@ -0,0 +1,203 @@ +import { TRPCError } from '@trpc/server'; +import type { WorkerDb } from '@kilocode/db/client'; +import { + mergeProfileConfiguration, + profileMcpServersToClientRecord, + ProfileNotFoundError, + type ClientMcpServerValue, + type InlineAgentInput, + type MergeProfileConfigurationResult, + type ProfileOwner, +} from '@kilocode/cloud-agent-profile'; +import { repoFullNameFromGitUrl } from '@kilocode/worker-utils/git-url'; +import { getPgDb } from '../../db/pg.js'; +import { assertKiloModelAvailable } from '../../model-validation.js'; +import type { SessionProfileBundle } from '../../session-profile.js'; +import type { SessionCreateRequest } from '../../session/session-requests.js'; +import { assertRepositoryAccessBeforeSessionCreation } from '../../session/validate-repository-access.js'; +import type { TRPCContext } from '../../types.js'; +import { isBuiltinMode } from '../schemas.js'; +import { assertOrganizationMembership } from './organization-membership.js'; + +const IMPLICIT_PROFILE_RESOLUTION_ORIGINS: ReadonlySet = new Set([ + 'cloud-agent-web', + 'slack', + 'github', + 'linear', + 'discord', + 'app-builder', + 'webhook', + 'scheduled', +]); + +export type ProfileResolutionPolicy = { + defaultProfileResolution: 'explicit-profile-only' | 'include-web-defaults'; +}; + +export function profileResolutionPolicyForSessionCreateOrigin( + createdOnPlatform: string | undefined +): ProfileResolutionPolicy { + return { + defaultProfileResolution: + createdOnPlatform !== undefined && IMPLICIT_PROFILE_RESOLUTION_ORIGINS.has(createdOnPlatform) + ? 'include-web-defaults' + : 'explicit-profile-only', + }; +} + +function repoFullNameForBindingLookup(input: SessionCreateRequest): string | undefined { + if (input.repository.type === 'github') return input.repository.repo; + if (input.repository.type === 'gitlab') { + // The repository discriminator establishes this host as GitLab, including self-hosted instances. + return repoFullNameFromGitUrl(input.repository.url, input.repository.url); + } + return undefined; +} + +async function resolveProfileForSessionCreateRequest( + ctx: Pick, + input: SessionCreateRequest, + policy: ProfileResolutionPolicy, + db?: WorkerDb +): Promise { + const shouldResolve = + input.profile?.id !== undefined || policy.defaultProfileResolution === 'include-web-defaults'; + if (!shouldResolve) return null; + + const owner: ProfileOwner = input.options?.kilocodeOrganizationId + ? { type: 'organization', id: input.options.kilocodeOrganizationId } + : { type: 'user', id: ctx.userId }; + const userId = input.options?.kilocodeOrganizationId ? ctx.userId : undefined; + const overrides = input.profile?.overrides; + + try { + return await mergeProfileConfiguration(db ?? getPgDb(ctx.env), { + profileId: input.profile?.id, + owner, + userId, + repoFullName: repoFullNameForBindingLookup(input), + platform: + input.repository.type === 'gitlab' + ? 'gitlab' + : input.repository.type === 'github' + ? 'github' + : undefined, + envVars: overrides?.envVars, + setupCommands: overrides?.setupCommands, + encryptedSecrets: overrides?.encryptedSecrets, + mcpServers: overrides?.mcpServers as Record | undefined, + runtimeSkills: overrides?.runtimeSkills, + runtimeAgents: overrides?.runtimeAgents as InlineAgentInput[] | undefined, + }); + } catch (err) { + if (err instanceof ProfileNotFoundError) { + throw new TRPCError({ code: 'NOT_FOUND', message: err.message }); + } + throw err; + } +} + +function applyProfileResolution( + input: SessionCreateRequest, + resolved: MergeProfileConfigurationResult | null +): SessionCreateRequest { + if (!resolved) { + return { + ...input, + profile: { + ...input.profile, + resolved: { + ...input.profile?.resolved, + envVars: input.profile?.overrides?.envVars, + encryptedSecrets: input.profile?.overrides?.encryptedSecrets, + setupCommands: input.profile?.overrides?.setupCommands, + mcpServers: input.profile?.overrides?.mcpServers, + runtimeSkills: input.profile?.overrides?.runtimeSkills, + runtimeAgents: input.profile?.overrides?.runtimeAgents, + }, + }, + }; + } + + return { + ...input, + profile: { + ...input.profile, + resolved: { + envVars: resolved.envVars, + setupCommands: resolved.setupCommands, + encryptedSecrets: resolved.encryptedSecrets, + mcpServers: profileMcpServersToClientRecord(resolved.mcpServers), + runtimeSkills: resolved.skills, + runtimeAgents: resolved.agents, + kiloCommands: resolved.kiloCommands ?? input.profile?.resolved?.kiloCommands, + }, + }, + }; +} + +export async function resolveEffectiveSessionConfiguration( + ctx: Pick, + input: SessionCreateRequest, + policy: ProfileResolutionPolicy, + db?: WorkerDb +): Promise { + const resolved = await resolveProfileForSessionCreateRequest(ctx, input, policy, db); + return applyProfileResolution(input, resolved); +} + +export function assertModeAvailableForProfile(mode: string, profile: SessionProfileBundle): void { + if (isBuiltinMode(mode)) return; + const slugs = new Set((profile.runtimeAgents ?? []).map(a => a.slug)); + if (slugs.has(mode)) return; + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Mode "${mode}" is not a built-in slug and does not match any runtimeAgents on this session`, + }); +} + +type SessionCreationContext = Pick; +type EndpointValidator = (request: SessionCreateRequest) => void | Promise; + +export async function preflightSessionCreation( + request: SessionCreateRequest, + ctx: SessionCreationContext, + procedure: string, + validateEndpoint?: EndpointValidator +): Promise { + const organizationId = request.options?.kilocodeOrganizationId; + let db: WorkerDb | undefined; + if (organizationId) { + db = getPgDb(ctx.env); + await assertOrganizationMembership(db, ctx.userId, organizationId); + } + + await assertRepositoryAccessBeforeSessionCreation({ + env: ctx.env, + userId: ctx.userId, + orgId: organizationId, + repository: request.repository, + }); + + const policy = profileResolutionPolicyForSessionCreateOrigin(request.options?.createdOnPlatform); + const resolvedRequest = await resolveEffectiveSessionConfiguration(ctx, request, policy, db); + assertModeAvailableForProfile( + resolvedRequest.agent.mode, + resolvedRequest.profile?.resolved ?? {} + ); + await validateEndpoint?.(resolvedRequest); + + if (resolvedRequest.initialTurn?.type === 'prompt') { + await assertKiloModelAvailable({ + env: ctx.env, + submittedModel: resolvedRequest.agent.model, + originalToken: ctx.authToken, + originalOrganizationId: resolvedRequest.options?.kilocodeOrganizationId, + createdOnPlatform: resolvedRequest.options?.createdOnPlatform, + procedure, + }); + } + + return resolvedRequest; +} diff --git a/services/cloud-agent-next/src/router/handlers/session-prepare.test.ts b/services/cloud-agent-next/src/router/handlers/session-prepare.test.ts index d852a445cb..6ec77e66bb 100644 --- a/services/cloud-agent-next/src/router/handlers/session-prepare.test.ts +++ b/services/cloud-agent-next/src/router/handlers/session-prepare.test.ts @@ -13,8 +13,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type * as CloudAgentProfile from '@kilocode/cloud-agent-profile'; import { t } from '../auth.js'; +import { PrepareSessionInput } from '../schemas.js'; import type { TRPCContext } from '../../types.js'; -import { createSessionPrepareHandlers } from './session-prepare.js'; +import { + createSessionPrepareHandlers, + prepareInputToSessionCreateRequest, +} from './session-prepare.js'; const { mergeProfileConfigurationMock, @@ -319,6 +323,30 @@ describe('prepareSession operation-ledger admission gate', () => { expect(startNewSessionMock).not.toHaveBeenCalled(); }); + it('does not reach registration or the ledger when repository access is rejected', async () => { + const caller = router.createCaller(createContext()); + assertBitbucketRepositoryAccessMock.mockRejectedValueOnce( + new TRPCError({ code: 'BAD_REQUEST', message: 'Repository access rejected' }) + ); + + await expect( + caller.prepareSession({ + prompt: 'Attempt repository access', + mode: 'code', + model: 'claude-3', + githubRepo: 'acme/repo', + autoInitiate: true, + operationKey: OPERATION_KEY, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + + expect(mergeProfileConfigurationMock).not.toHaveBeenCalled(); + expect(assertKiloModelAvailableMock).not.toHaveBeenCalled(); + expect(createSessionWithLedgerMock).not.toHaveBeenCalled(); + expect(registerNewSessionMock).not.toHaveBeenCalled(); + expect(startNewSessionMock).not.toHaveBeenCalled(); + }); + it('does not reach the ledger when the model preflight rejects', async () => { const caller = router.createCaller(createContext()); assertKiloModelAvailableMock.mockRejectedValue( @@ -337,6 +365,73 @@ describe('prepareSession operation-ledger admission gate', () => { ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); expect(createSessionWithLedgerMock).not.toHaveBeenCalled(); + expect(registerNewSessionMock).not.toHaveBeenCalled(); + expect(startNewSessionMock).not.toHaveBeenCalled(); + }); + + it('passes the exact profile-resolved request to registration', async () => { + const caller = router.createCaller(createContext()); + const profileId = '123e4567-e89b-12d3-a456-426614174011'; + const runtimeAgent = { + slug: 'reviewer', + name: 'Reviewer', + config: { prompt: 'Review the diff', mode: 'subagent' as const }, + }; + mergeProfileConfigurationMock.mockResolvedValueOnce({ + envVars: { PROFILE_VALUE: 'resolved' }, + agents: [runtimeAgent], + }); + const input = { + prompt: 'Use the resolved profile', + mode: 'reviewer', + model: 'claude-3', + githubRepo: 'acme/repo', + profileId, + createdOnPlatform: 'cloud-agent-web', + autoInitiate: false, + shallow: false, + } as const; + + await caller.prepareSession(input); + + const unresolved = prepareInputToSessionCreateRequest(PrepareSessionInput.parse(input)); + expect(registerNewSessionMock.mock.calls[0]?.[0]).toEqual({ + ...unresolved, + profile: { + ...unresolved.profile, + resolved: { + envVars: { PROFILE_VALUE: 'resolved' }, + setupCommands: undefined, + encryptedSecrets: undefined, + mcpServers: undefined, + runtimeSkills: undefined, + runtimeAgents: [runtimeAgent], + kiloCommands: undefined, + }, + }, + }); + }); + + it('registers command creation without model validation', async () => { + const caller = router.createCaller(createContext()); + + await caller.prepareSession({ + prompt: '/compact --aggressive', + mode: 'code', + model: 'claude-3', + githubRepo: 'acme/repo', + autoInitiate: true, + initialPayload: { + type: 'command', + command: 'compact', + arguments: '--aggressive', + }, + }); + + expect(assertKiloModelAvailableMock).not.toHaveBeenCalled(); + expect(startNewSessionMock).toHaveBeenCalledOnce(); + expect(createSessionWithLedgerMock).not.toHaveBeenCalled(); + expect(registerNewSessionMock).not.toHaveBeenCalled(); }); it('returns replayed true only when the ledger replays a settled create', async () => { @@ -385,15 +480,30 @@ describe('prepareSession operation-ledger admission gate', () => { it('propagates the clone source through createSessionWithLedger and replays on a same-key retry', async () => { const caller = router.createCaller(createContext()); const sourceKiloSessionId = 'ses_aaaaaaaaaaaaaaaaaaaaaaaaaa'; - - await caller.prepareSession({ - mode: 'code', + const organizationId = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; + const profileId = '123e4567-e89b-12d3-a456-426614174011'; + mergeProfileConfigurationMock.mockResolvedValue({ + agents: [ + { + slug: 'reviewer', + name: 'Reviewer', + config: { prompt: 'Review the diff', mode: 'subagent' }, + }, + ], + }); + const input = { + mode: 'reviewer', model: 'claude-3', githubRepo: 'acme/repo', autoInitiate: true, operationKey: OPERATION_KEY, cloneFromKiloSessionId: sourceKiloSessionId, - }); + kilocodeOrganizationId: organizationId, + profileId, + createdOnPlatform: 'cloud-agent-web', + } as const; + + await caller.prepareSession(input); expect(createSessionWithLedgerMock).toHaveBeenCalledTimes(1); expect(createSessionWithLedgerMock).toHaveBeenCalledWith( @@ -406,6 +516,7 @@ describe('prepareSession operation-ledger admission gate', () => { ); expect(startNewSessionMock).not.toHaveBeenCalled(); expect(registerNewSessionMock).not.toHaveBeenCalled(); + expect(assertKiloModelAvailableMock).not.toHaveBeenCalled(); // Same-key retry resumes: the ledger replays the settled clone create. createSessionWithLedgerMock.mockResolvedValueOnce({ @@ -413,20 +524,131 @@ describe('prepareSession operation-ledger admission gate', () => { kiloSessionId: 'cli-session-abc123', replayed: true, }); - const retry = await caller.prepareSession({ - mode: 'code', + const retry = await caller.prepareSession(input); + + expect(createSessionWithLedgerMock).toHaveBeenCalledTimes(2); + expect(assertOrganizationMembershipMock).toHaveBeenCalledTimes(2); + expect(assertBitbucketRepositoryAccessMock).toHaveBeenCalledTimes(2); + expect(mergeProfileConfigurationMock).toHaveBeenCalledTimes(2); + expect(assertKiloModelAvailableMock).not.toHaveBeenCalled(); + for (let call = 0; call < 2; call += 1) { + expect(assertOrganizationMembershipMock.mock.invocationCallOrder[call]).toBeLessThan( + assertBitbucketRepositoryAccessMock.mock.invocationCallOrder[call] ?? + Number.POSITIVE_INFINITY + ); + expect(assertBitbucketRepositoryAccessMock.mock.invocationCallOrder[call]).toBeLessThan( + mergeProfileConfigurationMock.mock.invocationCallOrder[call] ?? Number.POSITIVE_INFINITY + ); + expect(mergeProfileConfigurationMock.mock.invocationCallOrder[call]).toBeLessThan( + createSessionWithLedgerMock.mock.invocationCallOrder[call] ?? Number.POSITIVE_INFINITY + ); + } + expect(retry).toEqual({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + kiloSessionId: 'cli-session-abc123', + replayed: true, + }); + + mergeProfileConfigurationMock.mockResolvedValueOnce({}); + await expect(caller.prepareSession(input)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: expect.stringContaining('does not match any runtimeAgents'), + }); + expect(mergeProfileConfigurationMock).toHaveBeenCalledTimes(3); + expect(createSessionWithLedgerMock).toHaveBeenCalledTimes(2); + expect(assertKiloModelAvailableMock).not.toHaveBeenCalled(); + }); + + it('repeats prompt preflight before ledger replay and blocks a mode removed from the profile', async () => { + const caller = router.createCaller(createContext()); + const input = { + prompt: 'Replay prompt creation', + mode: 'reviewer', model: 'claude-3', githubRepo: 'acme/repo', autoInitiate: true, operationKey: OPERATION_KEY, - cloneFromKiloSessionId: sourceKiloSessionId, + kilocodeOrganizationId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + profileId: '123e4567-e89b-12d3-a456-426614174011', + createdOnPlatform: 'cloud-agent-web', + } as const; + mergeProfileConfigurationMock.mockResolvedValue({ + agents: [ + { + slug: 'reviewer', + name: 'Reviewer', + config: { prompt: 'Review the diff', mode: 'subagent' }, + }, + ], + }); + + await caller.prepareSession(input); + createSessionWithLedgerMock.mockResolvedValueOnce({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + kiloSessionId: 'cli-session-abc123', + replayed: true, + }); + await expect(caller.prepareSession(input)).resolves.toMatchObject({ replayed: true }); + + mergeProfileConfigurationMock.mockResolvedValueOnce({}); + await expect(caller.prepareSession(input)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: expect.stringContaining('does not match any runtimeAgents'), }); + expect(assertOrganizationMembershipMock).toHaveBeenCalledTimes(3); + expect(assertBitbucketRepositoryAccessMock).toHaveBeenCalledTimes(3); + expect(mergeProfileConfigurationMock).toHaveBeenCalledTimes(3); + expect(assertKiloModelAvailableMock).toHaveBeenCalledTimes(2); expect(createSessionWithLedgerMock).toHaveBeenCalledTimes(2); - expect(retry).toEqual({ + for (let call = 0; call < 2; call += 1) { + expect(assertOrganizationMembershipMock.mock.invocationCallOrder[call]).toBeLessThan( + assertBitbucketRepositoryAccessMock.mock.invocationCallOrder[call] ?? + Number.POSITIVE_INFINITY + ); + expect(assertBitbucketRepositoryAccessMock.mock.invocationCallOrder[call]).toBeLessThan( + mergeProfileConfigurationMock.mock.invocationCallOrder[call] ?? Number.POSITIVE_INFINITY + ); + expect(mergeProfileConfigurationMock.mock.invocationCallOrder[call]).toBeLessThan( + assertKiloModelAvailableMock.mock.invocationCallOrder[call] ?? Number.POSITIVE_INFINITY + ); + expect(assertKiloModelAvailableMock.mock.invocationCallOrder[call]).toBeLessThan( + createSessionWithLedgerMock.mock.invocationCallOrder[call] ?? Number.POSITIVE_INFINITY + ); + } + }); + + it('repeats authorization before prompt ledger replay and rejects later repository access', async () => { + const caller = router.createCaller(createContext()); + const input = { + prompt: 'Replay authorization', + mode: 'code', + model: 'claude-3', + githubRepo: 'acme/repo', + autoInitiate: true, + operationKey: OPERATION_KEY, + kilocodeOrganizationId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + profileId: '123e4567-e89b-12d3-a456-426614174011', + createdOnPlatform: 'cloud-agent-web', + } as const; + + await caller.prepareSession(input); + createSessionWithLedgerMock.mockResolvedValueOnce({ cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', kiloSessionId: 'cli-session-abc123', replayed: true, }); + await expect(caller.prepareSession(input)).resolves.toMatchObject({ replayed: true }); + + assertBitbucketRepositoryAccessMock.mockRejectedValueOnce( + new TRPCError({ code: 'BAD_REQUEST', message: 'Repository access rejected' }) + ); + await expect(caller.prepareSession(input)).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + + expect(assertOrganizationMembershipMock).toHaveBeenCalledTimes(3); + expect(assertBitbucketRepositoryAccessMock).toHaveBeenCalledTimes(3); + expect(mergeProfileConfigurationMock).toHaveBeenCalledTimes(2); + expect(assertKiloModelAvailableMock).toHaveBeenCalledTimes(2); + expect(createSessionWithLedgerMock).toHaveBeenCalledTimes(2); }); }); diff --git a/services/cloud-agent-next/src/router/handlers/session-prepare.ts b/services/cloud-agent-next/src/router/handlers/session-prepare.ts index 007ab40850..8e4c690ba2 100644 --- a/services/cloud-agent-next/src/router/handlers/session-prepare.ts +++ b/services/cloud-agent-next/src/router/handlers/session-prepare.ts @@ -14,18 +14,7 @@ * execution-scoped callback target override on `send`. */ import { TRPCError } from '@trpc/server'; -import type { WorkerDb } from '@kilocode/db/client'; import type * as z from 'zod'; -import { - mergeProfileConfiguration, - profileMcpServersToClientRecord, - ProfileNotFoundError, - type ClientMcpServerValue, - type InlineAgentInput, - type MergeProfileConfigurationResult, - type ProfileOwner, -} from '@kilocode/cloud-agent-profile'; -import { repoFullNameFromGitUrl } from '@kilocode/worker-utils/git-url'; import { logger, withLogTags } from '../../logger.js'; import { resolveSessionStub } from '../../sandbox-session/session-stub.js'; @@ -35,166 +24,22 @@ import { PrepareSessionOutput, UpdateSessionInput, UpdateSessionOutput, - isBuiltinMode, } from '../schemas.js'; import { registerNewSession, startNewSession, createSessionWithLedger, } from '../../session/session-registration.js'; -import { getPgDb } from '../../db/pg.js'; -import type { Env } from '../../types.js'; -import type { SessionProfileBundle } from '../../session-profile.js'; import type { SessionCreateRequest } from '../../session/session-requests.js'; -import { assertKiloModelAvailable } from '../../model-validation.js'; -import { assertRepositoryAccessBeforeSessionCreation } from '../../session/validate-repository-access.js'; -import { assertOrganizationMembership } from './organization-membership.js'; +import { preflightSessionCreation } from './session-creation-preflight.js'; type SessionPrepareHandlers = { prepareSession: typeof prepareSessionHandler; updateSession: typeof updateSessionHandler; }; -const IMPLICIT_PROFILE_RESOLUTION_ORIGINS: ReadonlySet = new Set([ - 'cloud-agent-web', - 'slack', - 'github', - 'linear', - 'discord', - 'app-builder', - 'webhook', - 'scheduled', -]); - -export type ProfileResolutionPolicy = { - defaultProfileResolution: 'explicit-profile-only' | 'include-web-defaults'; -}; - -export function profileResolutionPolicyForSessionCreateOrigin( - createdOnPlatform: string | undefined -): ProfileResolutionPolicy { - return { - defaultProfileResolution: - createdOnPlatform !== undefined && IMPLICIT_PROFILE_RESOLUTION_ORIGINS.has(createdOnPlatform) - ? 'include-web-defaults' - : 'explicit-profile-only', - }; -} - type PrepareInput = z.infer; -function repoFullNameForBindingLookup(input: SessionCreateRequest): string | undefined { - if (input.repository.type === 'github') return input.repository.repo; - if (input.repository.type === 'gitlab') { - // The repository discriminator establishes this host as GitLab, including self-hosted instances. - return repoFullNameFromGitUrl(input.repository.url, input.repository.url); - } - return undefined; -} - -async function resolveProfileForSessionCreateRequest( - ctx: { env: Pick; userId: string }, - input: SessionCreateRequest, - policy: ProfileResolutionPolicy, - db?: WorkerDb -): Promise { - const shouldResolve = - input.profile?.id !== undefined || policy.defaultProfileResolution === 'include-web-defaults'; - if (!shouldResolve) return null; - - const owner: ProfileOwner = input.options?.kilocodeOrganizationId - ? { type: 'organization', id: input.options.kilocodeOrganizationId } - : { type: 'user', id: ctx.userId }; - const userId = input.options?.kilocodeOrganizationId ? ctx.userId : undefined; - const overrides = input.profile?.overrides; - - try { - return await mergeProfileConfiguration(db ?? getPgDb(ctx.env), { - profileId: input.profile?.id, - owner, - userId, - repoFullName: repoFullNameForBindingLookup(input), - platform: - input.repository.type === 'gitlab' - ? 'gitlab' - : input.repository.type === 'github' - ? 'github' - : undefined, - envVars: overrides?.envVars, - setupCommands: overrides?.setupCommands, - encryptedSecrets: overrides?.encryptedSecrets, - mcpServers: overrides?.mcpServers as Record | undefined, - runtimeSkills: overrides?.runtimeSkills, - runtimeAgents: overrides?.runtimeAgents as InlineAgentInput[] | undefined, - }); - } catch (err) { - if (err instanceof ProfileNotFoundError) { - throw new TRPCError({ code: 'NOT_FOUND', message: err.message }); - } - throw err; - } -} - -function applyProfileResolution( - input: SessionCreateRequest, - resolved: MergeProfileConfigurationResult | null -): SessionCreateRequest { - if (!resolved) { - return { - ...input, - profile: { - ...input.profile, - resolved: { - ...input.profile?.resolved, - envVars: input.profile?.overrides?.envVars, - encryptedSecrets: input.profile?.overrides?.encryptedSecrets, - setupCommands: input.profile?.overrides?.setupCommands, - mcpServers: input.profile?.overrides?.mcpServers, - runtimeSkills: input.profile?.overrides?.runtimeSkills, - runtimeAgents: input.profile?.overrides?.runtimeAgents, - }, - }, - }; - } - - return { - ...input, - profile: { - ...input.profile, - resolved: { - envVars: resolved.envVars, - setupCommands: resolved.setupCommands, - encryptedSecrets: resolved.encryptedSecrets, - mcpServers: profileMcpServersToClientRecord(resolved.mcpServers), - runtimeSkills: resolved.skills, - runtimeAgents: resolved.agents, - kiloCommands: resolved.kiloCommands ?? input.profile?.resolved?.kiloCommands, - }, - }, - }; -} - -export async function resolveEffectiveSessionConfiguration( - ctx: { env: Pick; userId: string }, - input: SessionCreateRequest, - policy: ProfileResolutionPolicy, - db?: WorkerDb -): Promise { - const resolved = await resolveProfileForSessionCreateRequest(ctx, input, policy, db); - return applyProfileResolution(input, resolved); -} - -export function assertModeAvailableForProfile(mode: string, profile: SessionProfileBundle): void { - if (isBuiltinMode(mode)) return; - const slugs = new Set((profile.runtimeAgents ?? []).map(a => a.slug)); - if (slugs.has(mode)) return; - - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `Mode "${mode}" is not a built-in slug and does not match any runtimeAgents on this session`, - }); -} - export function prepareInputToSessionCreateRequest(input: PrepareInput): SessionCreateRequest { const gitUrl = input.gitUrl; if (!input.githubRepo && !gitUrl) { @@ -332,55 +177,30 @@ const prepareSessionHandler = internalApiProtectedProcedure .output(PrepareSessionOutput) .mutation(async ({ input, ctx }) => { return withLogTags({ source: 'prepareSession' }, async () => { - const request = prepareInputToSessionCreateRequest(input); - if (input.kilocodeOrganizationId) { - await assertOrganizationMembership( - getPgDb(ctx.env), - ctx.userId, - input.kilocodeOrganizationId - ); - } - await assertRepositoryAccessBeforeSessionCreation({ - env: ctx.env, - userId: ctx.userId, - orgId: input.kilocodeOrganizationId, - repository: request.repository, - }); - const policy = profileResolutionPolicyForSessionCreateOrigin(input.createdOnPlatform); - const requestWithProfile = await resolveEffectiveSessionConfiguration(ctx, request, policy); - assertModeAvailableForProfile( - requestWithProfile.agent.mode, - requestWithProfile.profile?.resolved ?? {} - ); - - if ( - requestWithProfile.initialTurn !== undefined && - requestWithProfile.initialTurn.type === 'command' && - requestWithProfile.initialTurn.attachments !== undefined - ) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'Attachments cannot be attached to slash commands', - }); - } - - if (input.devcontainer && !input.autoInitiate) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'devcontainer sessions must use autoInitiate', - }); - } + const requestWithProfile = await preflightSessionCreation( + prepareInputToSessionCreateRequest(input), + ctx, + 'prepareSession', + resolvedRequest => { + if ( + resolvedRequest.initialTurn !== undefined && + resolvedRequest.initialTurn.type === 'command' && + resolvedRequest.initialTurn.attachments !== undefined + ) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Attachments cannot be attached to slash commands', + }); + } - if (requestWithProfile.initialTurn?.type === 'prompt') { - await assertKiloModelAvailable({ - env: ctx.env, - submittedModel: requestWithProfile.agent.model, - originalToken: ctx.authToken, - originalOrganizationId: requestWithProfile.options?.kilocodeOrganizationId, - createdOnPlatform: requestWithProfile.options?.createdOnPlatform, - procedure: 'prepareSession', - }); - } + if (input.devcontainer && !input.autoInitiate) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'devcontainer sessions must use autoInitiate', + }); + } + } + ); const operationKey = requestWithProfile.options?.operationKey; const registrationContext = { diff --git a/services/cloud-agent-next/src/router/handlers/session-start.ts b/services/cloud-agent-next/src/router/handlers/session-start.ts index 109f2b6d87..267b3c549b 100644 --- a/services/cloud-agent-next/src/router/handlers/session-start.ts +++ b/services/cloud-agent-next/src/router/handlers/session-start.ts @@ -11,21 +11,12 @@ * session ownership state is created. */ import { protectedProcedure } from '../auth.js'; -import type { WorkerDb } from '@kilocode/db/client'; import { logger, withLogTags } from '../../logger.js'; -import { getPgDb } from '../../db/pg.js'; import type * as z from 'zod'; import { StartSessionInput, StartSessionOutput } from '../schemas.js'; import { startNewSession } from '../../session/session-registration.js'; -import { - assertModeAvailableForProfile, - profileResolutionPolicyForSessionCreateOrigin, - resolveEffectiveSessionConfiguration, -} from './session-prepare.js'; import type { SessionCreateRequest } from '../../session/session-requests.js'; -import { assertKiloModelAvailable } from '../../model-validation.js'; -import { assertRepositoryAccessBeforeSessionCreation } from '../../session/validate-repository-access.js'; -import { assertOrganizationMembership } from './organization-membership.js'; +import { preflightSessionCreation } from './session-creation-preflight.js'; type SessionStartHandlers = { start: typeof startSessionHandler; @@ -99,44 +90,14 @@ const startSessionHandler = protectedProcedure .output(StartSessionOutput) .mutation(async ({ input, ctx }) => { return withLogTags({ source: 'start' }, async () => { - const request = startInputToSessionCreateRequest(input); - const organizationId = request.options?.kilocodeOrganizationId; - let db: WorkerDb | undefined; - if (organizationId) { - db = getPgDb(ctx.env); - await assertOrganizationMembership(db, ctx.userId, organizationId); - } - await assertRepositoryAccessBeforeSessionCreation({ - env: ctx.env, - userId: ctx.userId, - orgId: organizationId, - repository: request.repository, - }); - - const policy = profileResolutionPolicyForSessionCreateOrigin( - input.options?.createdOnPlatform - ); - const requestWithProfile = await resolveEffectiveSessionConfiguration( + const request = await preflightSessionCreation( + startInputToSessionCreateRequest(input), ctx, - request, - policy, - db + 'start' ); - assertModeAvailableForProfile( - requestWithProfile.agent.mode, - requestWithProfile.profile?.resolved ?? {} - ); - await assertKiloModelAvailable({ - env: ctx.env, - submittedModel: requestWithProfile.agent.model, - originalToken: ctx.authToken, - originalOrganizationId: requestWithProfile.options?.kilocodeOrganizationId, - createdOnPlatform: requestWithProfile.options?.createdOnPlatform, - procedure: 'start', - }); const registration = await startNewSession( - requestWithProfile, + request, { env: ctx.env, userId: ctx.userId, diff --git a/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.ts b/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.ts index dbc2313c43..b3ce7d0433 100644 --- a/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.ts +++ b/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { logger } from '../logger.js'; +import { isRecord, kiloEventSessionId } from '../shared/kilo-event.js'; import { cloudAgentSessionScopeHeaders, cloudAgentSessionScopeProtocolVersion, @@ -29,24 +30,11 @@ export function childSessionLineage( return { sessionId: parsed.data.id, parentSessionId: parsed.data.parentID }; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - export function ingestKiloSessionId( _type: string, properties: Record ): string | undefined { - if (typeof properties.sessionID === 'string') return properties.sessionID; - if (typeof properties.sessionId === 'string') return properties.sessionId; - if (isRecord(properties.info)) { - if (typeof properties.info.sessionID === 'string') return properties.info.sessionID; - if (typeof properties.info.id === 'string') return properties.info.id; - } - if (isRecord(properties.part) && typeof properties.part.sessionID === 'string') { - return properties.part.sessionID; - } - return undefined; + return kiloEventSessionId(properties); } export function controlEventToIngestItems( diff --git a/services/cloud-agent-next/src/session-prepare.test.ts b/services/cloud-agent-next/src/session-prepare.test.ts index 0e3e1fa5d6..79f4a86857 100644 --- a/services/cloud-agent-next/src/session-prepare.test.ts +++ b/services/cloud-agent-next/src/session-prepare.test.ts @@ -123,7 +123,7 @@ vi.mock('./session-service.js', () => ({ })); import { appRouter } from './router.js'; -import { profileResolutionPolicyForSessionCreateOrigin } from './router/handlers/session-prepare.js'; +import { profileResolutionPolicyForSessionCreateOrigin } from './router/handlers/session-creation-preflight.js'; import type { TRPCContext, SessionId } from './types.js'; function createMockDOStub( @@ -1022,6 +1022,7 @@ describe('prepareSession endpoint', () => { ).rejects.toThrow('devcontainer sessions must use autoInitiate'); expect(doStub.registerSession).not.toHaveBeenCalled(); + expect(assertKiloModelAvailableMock).not.toHaveBeenCalled(); }); it('auto-initiates command-valued initialPayload through grouped canonical admission', async () => { @@ -1091,6 +1092,7 @@ describe('prepareSession endpoint', () => { expect(doStub.registerSession).not.toHaveBeenCalled(); expect(doStub.admitSubmittedMessage).not.toHaveBeenCalled(); + expect(assertKiloModelAvailableMock).not.toHaveBeenCalled(); }); it('returns a prepared session when post-registration fact persistence fails', async () => { @@ -1472,6 +1474,38 @@ describe('start endpoint', () => { } ); + it('passes resolved profile output into grouped start registration', async () => { + const runtimeAgent = { + slug: 'reviewer', + name: 'Reviewer', + config: { prompt: 'Review the diff', mode: 'subagent' as const }, + }; + mergeProfileConfigurationMock.mockResolvedValueOnce({ + envVars: { PROFILE_VALUE: 'resolved' }, + agents: [runtimeAgent], + }); + const doStub = createMockDOStub(); + const caller = appRouter.createCaller(createInternalApiContext({ doStub })); + + await caller.start({ + message: { prompt: 'Use the resolved profile' }, + agent: { mode: 'reviewer', model: 'anthropic/claude-sonnet-4-20250514' }, + repository: { type: 'github', repo: 'acme/repo' }, + profile: { id: '123e4567-e89b-12d3-a456-426614174011' }, + options: { createdOnPlatform: 'cloud-agent-web' }, + }); + + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( + expect.objectContaining({ + agent: expect.objectContaining({ mode: 'reviewer' }), + profile: expect.objectContaining({ + envVars: { PROFILE_VALUE: 'resolved' }, + runtimeAgents: [runtimeAgent], + }), + }) + ); + }); + it('returns an admitted session without persisting setup success milestones', async () => { const caller = appRouter.createCaller(createInternalApiContext({})); diff --git a/services/cloud-agent-next/src/session-service.test.ts b/services/cloud-agent-next/src/session-service.test.ts index 004483e548..7e4d6fad37 100644 --- a/services/cloud-agent-next/src/session-service.test.ts +++ b/services/cloud-agent-next/src/session-service.test.ts @@ -1775,6 +1775,116 @@ describe('SessionService.prepareWorkspace', () => { expect(restoreCommand).not.toContain('KILOCODE_TOKEN='); }); + it('cleans up the restore token when devcontainer restore execution fails', async () => { + const session = createSession(false); + session.exec.mockImplementation(async (command: string) => { + if (command.includes('kilo-restore-session.js')) { + throw new Error('devcontainer restore execution failed'); + } + return { exitCode: 0, stdout: '', stderr: '' }; + }); + const sandbox = createSandbox(session); + const metadata = { + ...createMetadata({ preparedAt: 1 }), + workspace: { + sandboxId: 'dind-abcdef' as const, + devcontainerRequested: true, + }, + } satisfies CloudAgentSessionState; + const devcontainerHandle = { + containerId: 'container-dev', + innerWorkspaceFolder: '/workspaces/repo', + workspacePath: '/workspace/user/sessions/agent_test', + agentSessionId: 'agent_test', + overrideConfigPath: '/tmp/devcontainer-override-agent_test/devcontainer.json', + teardown: vi.fn().mockResolvedValue(undefined), + }; + devcontainerMocks.detectDevContainer.mockResolvedValue({ + configPath: '.devcontainer/devcontainer.json', + }); + devcontainerMocks.bringUpDevContainer.mockResolvedValue(devcontainerHandle); + + await expect( + new SessionService().prepareWorkspace({ + sandbox, + sandboxId: 'dind-abcdef', + userId: 'user_test', + sessionId: 'agent_test' as SessionId, + env: createEnv(), + metadata, + kilocodeModel: 'test-model', + }) + ).rejects.toThrow('devcontainer restore execution failed'); + + expect( + session.exec.mock.calls.some( + ([command]) => + typeof command === 'string' && + command.includes('rm -f') && + command.includes('/home/agent_test/.local/share/kilo/session-restore-token') + ) + ).toBe(true); + }); + + it('cleans up the restore token and preserves a chmod failure after writing it', async () => { + const session = createSession(false); + session.exec.mockImplementation(async (command: string) => { + if (command.includes('chmod 600')) { + throw new Error('restore token chmod failed'); + } + return { exitCode: 0, stdout: '', stderr: '' }; + }); + const writeFile = vi.fn().mockResolvedValue(undefined); + const sandbox = createSandbox(session, false, writeFile); + const metadata = { + ...createMetadata({ preparedAt: 1 }), + workspace: { + sandboxId: 'dind-abcdef' as const, + devcontainerRequested: true, + }, + } satisfies CloudAgentSessionState; + const devcontainerHandle = { + containerId: 'container-dev', + innerWorkspaceFolder: '/workspaces/repo', + workspacePath: '/workspace/user/sessions/agent_test', + agentSessionId: 'agent_test', + overrideConfigPath: '/tmp/devcontainer-override-agent_test/devcontainer.json', + teardown: vi.fn().mockResolvedValue(undefined), + }; + devcontainerMocks.detectDevContainer.mockResolvedValue({ + configPath: '.devcontainer/devcontainer.json', + }); + devcontainerMocks.bringUpDevContainer.mockResolvedValue(devcontainerHandle); + + await expect( + new SessionService().prepareWorkspace({ + sandbox, + sandboxId: 'dind-abcdef', + userId: 'user_test', + sessionId: 'agent_test' as SessionId, + env: createEnv(), + metadata, + kilocodeModel: 'test-model', + }) + ).rejects.toThrow('restore token chmod failed'); + + expect(writeFile).toHaveBeenCalledWith( + '/home/agent_test/.local/share/kilo/session-restore-token', + expect.any(String) + ); + const chmodCall = session.exec.mock.calls.findIndex( + ([command]) => typeof command === 'string' && command.includes('chmod 600') + ); + const cleanupCall = session.exec.mock.calls.findIndex( + ([command]) => typeof command === 'string' && command.includes('rm -f') + ); + expect(chmodCall).toBeGreaterThanOrEqual(0); + expect(cleanupCall).toBeGreaterThan(chmodCall); + expect(session.exec.mock.calls[cleanupCall]?.[0]).toContain( + '/home/agent_test/.local/share/kilo/session-restore-token' + ); + }); + it('replaces a warm Bitbucket review origin with the credential-free canonical URL', async () => { const session = createSession(true); const sandbox = createSandbox(session, true); diff --git a/services/cloud-agent-next/src/session-service.ts b/services/cloud-agent-next/src/session-service.ts index 07d1e1e3f1..4e0d86d6cc 100644 --- a/services/cloud-agent-next/src/session-service.ts +++ b/services/cloud-agent-next/src/session-service.ts @@ -2822,6 +2822,47 @@ export class SessionService { }; } + private async executeRestoreCommand( + sandbox: SandboxInstance, + session: ExecutionSession, + kiloSessionId: string, + workspacePath: string, + options: RestoreRuntimeOptions, + operation: string, + importFilePath?: string + ) { + const restoreTokenFilePath = options.devcontainer + ? getRestoreTokenFilePath(options.sessionHome) + : undefined; + try { + if (restoreTokenFilePath) { + await writeRestoreTokenFile(sandbox, session, options.sessionHome, options.kiloCapability); + } + const restoreCommand = buildRestoreCommand({ + kiloSessionId, + importFilePath, + runtimeWorkspacePath: options.devcontainer?.innerWorkspaceFolder ?? workspacePath, + runtimeEnv: options.devcontainer + ? this.getDevContainerRestoreEnv(options, restoreTokenFilePath) + : undefined, + devContainer: options.devcontainer, + }); + return await timedExec(session, restoreCommand, operation, { + timeoutMs: GIT_COMMAND_TIMEOUT_MS, + cwd: dirname(workspacePath), + env: options.devcontainer ? options.dockerEnv : undefined, + }); + } finally { + if (restoreTokenFilePath) { + await cleanupRestoreTokenFile( + session, + restoreTokenFilePath, + options.devcontainer?.agentSessionId ?? '' + ); + } + } + } + private async tryRestoreKiloSessionFromSnapshot( sandbox: SandboxInstance, session: ExecutionSession, @@ -2829,34 +2870,14 @@ export class SessionService { workspacePath: string, options: RestoreRuntimeOptions ): Promise { - const restoreTokenFilePath = options.devcontainer - ? await writeRestoreTokenFile(sandbox, session, options.sessionHome, options.kiloCapability) - : undefined; - const restoreCommand = buildRestoreCommand({ + const restoreResult = await this.executeRestoreCommand( + sandbox, + session, kiloSessionId, - runtimeWorkspacePath: options.devcontainer?.innerWorkspaceFolder ?? workspacePath, - runtimeEnv: options.devcontainer - ? this.getDevContainerRestoreEnv(options, restoreTokenFilePath) - : undefined, - devContainer: options.devcontainer, - }); - const restoreResult = await (async () => { - try { - return await timedExec(session, restoreCommand, 'session.prepareWorkspace.restore', { - timeoutMs: GIT_COMMAND_TIMEOUT_MS, - cwd: dirname(workspacePath), - env: options.devcontainer ? options.dockerEnv : undefined, - }); - } finally { - if (restoreTokenFilePath) { - await cleanupRestoreTokenFile( - session, - restoreTokenFilePath, - options.devcontainer?.agentSessionId ?? '' - ); - } - } - })(); + workspacePath, + options, + 'session.prepareWorkspace.restore' + ); if (restoreResult.exitCode === 0) { logger.info('Session snapshot restore completed'); @@ -2906,35 +2927,15 @@ export class SessionService { ? `${options.sessionHome}/tmp/kilo-empty-session-${kiloSessionId}.json` : `/tmp/kilo-empty-session-${kiloSessionId}.json`; await sandbox.writeFile(importFilePath, minimalSessionJson); - const restoreTokenFilePath = options.devcontainer - ? await writeRestoreTokenFile(sandbox, session, options.sessionHome, options.kiloCapability) - : undefined; - const restoreCommand = buildRestoreCommand({ + const restoreResult = await this.executeRestoreCommand( + sandbox, + session, kiloSessionId, - importFilePath, - runtimeWorkspacePath: options.devcontainer?.innerWorkspaceFolder ?? workspacePath, - runtimeEnv: options.devcontainer - ? this.getDevContainerRestoreEnv(options, restoreTokenFilePath) - : undefined, - devContainer: options.devcontainer, - }); - const restoreResult = await (async () => { - try { - return await timedExec(session, restoreCommand, 'session.prepareWorkspace.bootstrap', { - timeoutMs: GIT_COMMAND_TIMEOUT_MS, - cwd: dirname(workspacePath), - env: options.devcontainer ? options.dockerEnv : undefined, - }); - } finally { - if (restoreTokenFilePath) { - await cleanupRestoreTokenFile( - session, - restoreTokenFilePath, - options.devcontainer?.agentSessionId ?? '' - ); - } - } - })(); + workspacePath, + options, + 'session.prepareWorkspace.bootstrap', + importFilePath + ); if (restoreResult.exitCode !== 0) { const parsed = parseRestoreScriptOutput(restoreResult.stdout); const detail = [ diff --git a/services/cloud-agent-next/src/session/safe-failure-projection.test.ts b/services/cloud-agent-next/src/session/safe-failure-projection.test.ts index 5e3489cd43..20dde32c23 100644 --- a/services/cloud-agent-next/src/session/safe-failure-projection.test.ts +++ b/services/cloud-agent-next/src/session/safe-failure-projection.test.ts @@ -5,6 +5,7 @@ import { CloudAgentSafeFailureSchema, } from '@kilocode/worker-utils/cloud-agent-failure'; import { describe, expect, it } from 'vitest'; +import { assistantErrorDetail } from '../shared/assistant-failure.js'; import { SAFE_FAILURE_MESSAGE_MAX_LENGTH, SafeFailureProjectionSchema, @@ -18,6 +19,46 @@ import { } from './safe-failure-projection.js'; describe('projectSafeAssistantError', () => { + it.each([ + [ + 'string', + 'Rate limit exceeded', + 'Rate limit exceeded', + 'rate_limited', + 'Assistant request was rate limited', + ], + [ + 'nested message ahead of top-level message', + { data: { message: 'deadline exceeded' }, message: 'Unknown model' }, + 'deadline exceeded', + 'timeout', + 'Assistant request timed out', + ], + [ + 'top-level message', + { message: 'Unknown model' }, + 'Unknown model', + 'model_unavailable', + 'Assistant request failed: model not found', + ], + ['null', null, undefined, 'unknown', undefined], + ['undefined', undefined, undefined, 'unknown', undefined], + [ + 'unrecognized non-null value', + { code: 'future-error' }, + 'Assistant message failed', + 'unknown', + 'Assistant request failed', + ], + ] as const)( + 'keeps recognition and classification/raw fallbacks distinct for %s', + (_name, source, rawDetail, reason, safeProjection) => { + expect(assistantErrorDetail(source)).toBe(rawDetail); + expect(classifyAssistantFailure(source).reason).toBe(reason); + expect(projectSafeAssistantError(source)).toBe(safeProjection); + } + ); + it.each([ 'Payment required: insufficient credits', 'Unknown model', diff --git a/services/cloud-agent-next/src/session/session-message-state.test.ts b/services/cloud-agent-next/src/session/session-message-state.test.ts index faf59d9e4e..655ee1b283 100644 --- a/services/cloud-agent-next/src/session/session-message-state.test.ts +++ b/services/cloud-agent-next/src/session/session-message-state.test.ts @@ -216,6 +216,95 @@ describe('getSessionMessageState / putSessionMessageState', () => { }); }); + it('applies the shared prompt and command turn contract at every stored turn location', async () => { + const attachments = { + path: '123e4567-e89b-12d3-a456-426614174000', + files: ['123e4567-e89b-12d3-a456-426614174001.pdf'], + }; + const validTurns = [ + { + type: 'prompt', + messageId: VALID_MESSAGE_ID, + prompt: 'stored document', + attachments, + }, + { + type: 'command', + messageId: VALID_MESSAGE_ID, + command: 'compact', + arguments: '--aggressive', + }, + ] as const; + const invalidTurns = [ + { + type: 'prompt', + messageId: VALID_MESSAGE_ID, + prompt: 'legacy image prompt', + images: attachments, + }, + { + type: 'command', + messageId: VALID_MESSAGE_ID, + command: 'compact', + }, + ] as const; + + for (const location of [ + 'admissionSnapshot', + 'legacyAdmissionConstraints', + 'predecessor', + ] as const) { + for (const turn of validTurns) { + const storage = createFakeStorage(); + const state = { + messageId: VALID_MESSAGE_ID, + status: 'accepted', + prompt: 'stored turn', + createdAt: 1000, + acceptedAt: 2000, + ...(location === 'admissionSnapshot' + ? { admissionSnapshot: { turn, agent: { mode: 'code', model: 'default-model' } } } + : location === 'legacyAdmissionConstraints' + ? { legacyAdmissionConstraints: { turn } } + : { turn }), + }; + await storage.put(`session_message:${VALID_MESSAGE_ID}`, state); + + const loaded = await getSessionMessageState(storage, VALID_MESSAGE_ID); + const loadedTurn = + location === 'admissionSnapshot' + ? loaded?.admissionSnapshot?.turn + : loaded?.legacyAdmissionConstraints?.turn; + expect(loadedTurn, `${location} should accept ${turn.type}`).toEqual(turn); + } + + for (const turn of invalidTurns) { + const storage = createFakeStorage(); + const state = { + messageId: VALID_MESSAGE_ID, + status: 'accepted', + prompt: 'stored turn', + createdAt: 1000, + acceptedAt: 2000, + ...(location === 'admissionSnapshot' + ? { admissionSnapshot: { turn, agent: { mode: 'code', model: 'default-model' } } } + : location === 'legacyAdmissionConstraints' + ? { legacyAdmissionConstraints: { turn } } + : { turn }), + }; + await storage.put(`session_message:${VALID_MESSAGE_ID}`, state); + + const loaded = await getSessionMessageState(storage, VALID_MESSAGE_ID); + if (location === 'predecessor') { + expect(loaded, `predecessor should discard invalid ${turn.type}`).toBeDefined(); + expect(loaded?.legacyAdmissionConstraints?.turn).toBeUndefined(); + } else { + expect(loaded, `${location} should reject invalid ${turn.type}`).toBeUndefined(); + } + } + } + }); + it('prefers a current admission snapshot over conflicting legacy copied fields', async () => { const storage = createFakeStorage(); const current = createIntent(VALID_MESSAGE_ID, 'current prompt'); diff --git a/services/cloud-agent-next/src/session/session-message-state.ts b/services/cloud-agent-next/src/session/session-message-state.ts index 3507df9ae1..c465375e94 100644 --- a/services/cloud-agent-next/src/session/session-message-state.ts +++ b/services/cloud-agent-next/src/session/session-message-state.ts @@ -141,6 +141,23 @@ export type SessionMessageState = { }; }; +const SessionMessageTurnSchema = z.discriminatedUnion('type', [ + z + .object({ + type: z.literal('prompt'), + messageId: z.string(), + prompt: z.string(), + attachments: AttachmentsSchema.optional(), + }) + .strict(), + z.object({ + type: z.literal('command'), + messageId: z.string(), + command: z.string(), + arguments: z.string(), + }), +]); + export const SessionMessageStateSchema = z .object({ messageId: z.string().regex(MESSAGE_ID_PATTERN, MESSAGE_ID_FORMAT_DESCRIPTION), @@ -148,22 +165,7 @@ export const SessionMessageStateSchema = z prompt: z.string(), admissionSnapshot: z .object({ - turn: z.discriminatedUnion('type', [ - z - .object({ - type: z.literal('prompt'), - messageId: z.string(), - prompt: z.string(), - attachments: AttachmentsSchema.optional(), - }) - .strict(), - z.object({ - type: z.literal('command'), - messageId: z.string(), - command: z.string(), - arguments: z.string(), - }), - ]), + turn: SessionMessageTurnSchema, agent: z.object({ mode: z.string(), model: z.string(), variant: z.string().optional() }), finalization: z .object({ @@ -175,24 +177,7 @@ export const SessionMessageStateSchema = z .optional(), legacyAdmissionConstraints: z .object({ - turn: z - .discriminatedUnion('type', [ - z - .object({ - type: z.literal('prompt'), - messageId: z.string(), - prompt: z.string(), - attachments: AttachmentsSchema.optional(), - }) - .strict(), - z.object({ - type: z.literal('command'), - messageId: z.string(), - command: z.string(), - arguments: z.string(), - }), - ]) - .optional(), + turn: SessionMessageTurnSchema.optional(), agent: z .object({ mode: z.string().optional(), @@ -347,24 +332,7 @@ function normalizeParsedSessionMessageState( }, }; } - const parsedTurn = z - .discriminatedUnion('type', [ - z - .object({ - type: z.literal('prompt'), - messageId: z.string(), - prompt: z.string(), - attachments: AttachmentsSchema.optional(), - }) - .strict(), - z.object({ - type: z.literal('command'), - messageId: z.string(), - command: z.string(), - arguments: z.string(), - }), - ]) - .safeParse(state.turn); + const parsedTurn = SessionMessageTurnSchema.safeParse(state.turn); const constraints: LegacyAdmissionConstraints = { turn: parsedTurn.success ? parsedTurn.data.type === 'prompt' diff --git a/services/cloud-agent-next/src/session/wrapper-supervisor.ts b/services/cloud-agent-next/src/session/wrapper-supervisor.ts index a1cfeb8045..25b3a203ca 100644 --- a/services/cloud-agent-next/src/session/wrapper-supervisor.ts +++ b/services/cloud-agent-next/src/session/wrapper-supervisor.ts @@ -10,6 +10,7 @@ import type { import type { AgentRuntime } from './agent-runtime.js'; import { WRAPPER_NO_OUTPUT_TIMEOUT_MS, WRAPPER_PING_INTERVAL_MS } from './agent-runtime.js'; import type { MessageSettlementOutbox } from './message-settlement-outbox.js'; +import { assistantErrorDetail } from '../shared/assistant-failure.js'; import { assistantFailureMessage, classifyAssistantFailure, @@ -281,34 +282,18 @@ function matchesDisconnectGraceFence( return true; } -function getAssistantErrorMessage(error: unknown): string | undefined { - if (error === undefined || error === null) return undefined; - if (typeof error === 'string') return error; - if (typeof error === 'object') { - if ('data' in error && error.data && typeof error.data === 'object') { - if ('message' in error.data && typeof error.data.message === 'string') { - return error.data.message; - } - } - if ('message' in error && typeof error.message === 'string') { - return error.message; - } - } - return 'Assistant message failed'; -} - function assistantErrorTerminalizeParams(info: LatestAssistantMessage['info']): TerminalizeParams { const assistantError = info.error; if (isAssistantInterrupt(assistantError)) { return { kind: 'interrupted', - error: getAssistantErrorMessage(assistantError) ?? 'The message was interrupted by the user', + error: assistantErrorDetail(assistantError) ?? 'The message was interrupted by the user', completionSource: 'interrupt', failureStage: 'interruption', failureCode: 'user_interrupt', }; } - const errorMessage = getAssistantErrorMessage(assistantError) ?? 'Assistant request failed'; + const errorMessage = assistantErrorDetail(assistantError) ?? 'Assistant request failed'; const assistantFailure = classifyAssistantFailure(assistantError); return { kind: 'failed', diff --git a/services/cloud-agent-next/src/shared/assistant-failure.ts b/services/cloud-agent-next/src/shared/assistant-failure.ts index 58d97ec13f..61ebebf1e8 100644 --- a/services/cloud-agent-next/src/shared/assistant-failure.ts +++ b/services/cloud-agent-next/src/shared/assistant-failure.ts @@ -45,7 +45,7 @@ export function isAssistantInterrupt(source: unknown): boolean { if (source.name === 'MessageAbortedError') return true; } return /messageabortederror|user[_ -]?interrupt|interrupted by the user/.test( - extractErrorMessage(source).toLocaleLowerCase() + assistantFailureText(source).toLocaleLowerCase() ); } @@ -53,7 +53,7 @@ export function classifyAssistantFailure( source: unknown, defaultProviderOwnership: CloudAgentProviderOwnership = 'unknown' ): AssistantFailureClassification { - const message = extractErrorMessage(source).toLocaleLowerCase(); + const message = assistantFailureText(source).toLocaleLowerCase(); const providerOwnership = /\[byok\]/i.test(message) ? 'byok' : defaultProviderOwnership; const messageReason = classifyAssistantFailureText(message); const specificMessageReason = @@ -162,14 +162,23 @@ export function classifyAssistantFailureMessage(source: unknown): string { return classifyAssistantFailure(source).safeMessage; } -function extractErrorMessage(source: unknown): string { +function assistantFailureText(source: unknown): string { + return recognizedAssistantErrorMessage(source) ?? ''; +} + +function recognizedAssistantErrorMessage(source: unknown): string | undefined { if (typeof source === 'string') return source; - if (typeof source !== 'object' || source === null) return ''; + if (typeof source !== 'object' || source === null) return undefined; if ('data' in source && typeof source.data === 'object' && source.data !== null) { if ('message' in source.data && typeof source.data.message === 'string') { return source.data.message; } } if ('message' in source && typeof source.message === 'string') return source.message; - return ''; + return undefined; +} + +export function assistantErrorDetail(source: unknown): string | undefined { + if (source === undefined || source === null) return undefined; + return recognizedAssistantErrorMessage(source) ?? 'Assistant message failed'; } diff --git a/services/cloud-agent-next/src/shared/kilo-event.ts b/services/cloud-agent-next/src/shared/kilo-event.ts new file mode 100644 index 0000000000..ce5ca98f57 --- /dev/null +++ b/services/cloud-agent-next/src/shared/kilo-event.ts @@ -0,0 +1,16 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function kiloEventSessionId(properties: Record): string | undefined { + if (typeof properties.sessionID === 'string') return properties.sessionID; + if (typeof properties.sessionId === 'string') return properties.sessionId; + if (isRecord(properties.info)) { + if (typeof properties.info.sessionID === 'string') return properties.info.sessionID; + if (typeof properties.info.id === 'string') return properties.info.id; + } + if (isRecord(properties.part) && typeof properties.part.sessionID === 'string') { + return properties.part.sessionID; + } + return undefined; +} diff --git a/services/cloud-agent-next/src/websocket/ingest.ts b/services/cloud-agent-next/src/websocket/ingest.ts index 6f8c5470e3..3c9419a124 100644 --- a/services/cloud-agent-next/src/websocket/ingest.ts +++ b/services/cloud-agent-next/src/websocket/ingest.ts @@ -34,6 +34,7 @@ import type { SlashCommandInfo } from '../shared/slash-commands.js'; import { logger } from '../logger.js'; import type { WrapperSupervisor, WrapperTerminalEvent } from '../session/wrapper-supervisor.js'; import type { TerminalizeParams } from '../session/session-message-state.js'; +import { assistantErrorDetail } from '../shared/assistant-failure.js'; import { classifyAssistantFailure, classifyAssistantFailureMessage, @@ -105,22 +106,6 @@ const wrapperEventTruncatedSchema = z.object({ const wrapperGenerationParamSchema = z.coerce.number().int().nonnegative(); -function getAssistantErrorMessage(error: unknown): string | undefined { - if (error === undefined || error === null) return undefined; - if (typeof error === 'string') return error; - if (typeof error === 'object') { - if ('data' in error && error.data && typeof error.data === 'object') { - if ('message' in error.data && typeof error.data.message === 'string') { - return error.data.message; - } - } - if ('message' in error && typeof error.message === 'string') { - return error.message; - } - } - return 'Assistant message failed'; -} - function sanitizeKilocodeEventData(data: unknown): unknown { if (typeof data !== 'object' || data === null) return data; const eventData = data as Record; @@ -795,7 +780,7 @@ export function createIngestHandler( const properties = data.properties as Record | undefined; const info = properties?.info as Record | undefined; const assistantError = info?.error; - const assistantErrorMessage = getAssistantErrorMessage(assistantError); + const assistantErrorMessage = assistantErrorDetail(assistantError); const parentMessageId = info?.role === 'assistant' && typeof info.parentID === 'string' ? info.parentID diff --git a/services/cloud-agent-next/test/integration/session/message-terminalization.test.ts b/services/cloud-agent-next/test/integration/session/message-terminalization.test.ts index 699ace665c..3aa9de2655 100644 --- a/services/cloud-agent-next/test/integration/session/message-terminalization.test.ts +++ b/services/cloud-agent-next/test/integration/session/message-terminalization.test.ts @@ -569,7 +569,7 @@ describe('message terminalization and stream events', () => { completionSource: 'assistant_message_event', terminalEffects: { event: 'pending', callback: { disposition: 'not-required' } }, }); - instance['ensureTerminalMessageEvent'] = () => { + instance['ensureUniqueMessageEvent'] = () => { throw new Error('terminal effect remains unavailable'); }; const startedAt = Date.now(); diff --git a/services/cloud-agent-next/test/integration/session/pending-messages.test.ts b/services/cloud-agent-next/test/integration/session/pending-messages.test.ts index f7201c7077..95aba33c77 100644 --- a/services/cloud-agent-next/test/integration/session/pending-messages.test.ts +++ b/services/cloud-agent-next/test/integration/session/pending-messages.test.ts @@ -1489,8 +1489,8 @@ describe('pending session messages', () => { callbackTarget: { url: 'https://example.com/repair-interrupt' }, }); let failTerminalEffect = true; - const originalEnsure = (instance as any).ensureTerminalMessageEvent.bind(instance); - (instance as any).ensureTerminalMessageEvent = (params: unknown) => { + const originalEnsure = (instance as any).ensureUniqueMessageEvent.bind(instance); + (instance as any).ensureUniqueMessageEvent = (params: unknown) => { if (failTerminalEffect) { failTerminalEffect = false; throw new Error('interrupt terminal event failed'); @@ -2467,9 +2467,19 @@ describe('pending session messages', () => { nextFlushAttemptAt: Date.now() - 1, }) ); - const originalEnsure = (instance as any).ensureTerminalMessageEvent.bind(instance); + const originalEnsure = (instance as any).ensureUniqueMessageEvent.bind(instance); let failTerminalEvent = true; - (instance as any).ensureTerminalMessageEvent = (params: unknown) => { + (instance as any).ensureUniqueMessageEvent = (params: { + entityId: string; + streamEventType: string; + }) => { + if ( + params.entityId !== `terminal-message/${messageId}` || + params.streamEventType !== 'cloud.message.failed' + ) { + originalEnsure(params); + return; + } if (failTerminalEvent) { failTerminalEvent = false; throw new Error('terminal effects unavailable'); diff --git a/services/cloud-agent-next/wrapper/src/control/feed.ts b/services/cloud-agent-next/wrapper/src/control/feed.ts index 6b2f499fee..22cd90ed36 100644 --- a/services/cloud-agent-next/wrapper/src/control/feed.ts +++ b/services/cloud-agent-next/wrapper/src/control/feed.ts @@ -1,22 +1,10 @@ import type { SessionEventIdentity } from '../../../src/shared/sandbox-control-protocol.js'; +import { isRecord, kiloEventSessionId } from '../../../src/shared/kilo-event.js'; import type { HandlerSessionSnapshot } from './sandbox-control-handlers'; import { directoryForSession, rememberChildSession, rootForSession } from './session-directories'; -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - export function eventKiloSessionId(properties: Record): string | undefined { - if (typeof properties.sessionID === 'string') return properties.sessionID; - if (typeof properties.sessionId === 'string') return properties.sessionId; - if (isRecord(properties.info)) { - if (typeof properties.info.sessionID === 'string') return properties.info.sessionID; - if (typeof properties.info.id === 'string') return properties.info.id; - } - if (isRecord(properties.part) && typeof properties.part.sessionID === 'string') { - return properties.part.sessionID; - } - return undefined; + return kiloEventSessionId(properties); } export function childFromSessionCreated( From a2cae7ea52fdf151f583a464aaa6a993e3cdc287 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 8 Sep 2026 16:34:19 +0200 Subject: [PATCH 2/2] chore(cloud-agent-next): remove architecture ownership checker Drop the handler-shape AST check. Keep the duplication ratchet. --- .github/workflows/ci.yml | 3 - services/cloud-agent-next/AGENTS.md | 3 +- services/cloud-agent-next/package.json | 3 +- .../scripts/check-architecture.test.ts | 472 ----------- .../scripts/check-architecture.ts | 781 ------------------ 5 files changed, 2 insertions(+), 1260 deletions(-) delete mode 100644 services/cloud-agent-next/scripts/check-architecture.test.ts delete mode 100644 services/cloud-agent-next/scripts/check-architecture.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 124d7132ce..f2d41c67f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -400,9 +400,6 @@ jobs: - name: Test cloud-agent-next checkers run: pnpm --filter cloud-agent-next test:checkers - - name: Check cloud-agent-next architecture - run: pnpm --filter cloud-agent-next check:architecture - - name: Check cloud-agent-next duplication run: pnpm --filter cloud-agent-next check:duplication diff --git a/services/cloud-agent-next/AGENTS.md b/services/cloud-agent-next/AGENTS.md index 41ffd53a6f..8b201600ac 100644 --- a/services/cloud-agent-next/AGENTS.md +++ b/services/cloud-agent-next/AGENTS.md @@ -43,8 +43,7 @@ Git tokens (GitHub App installation tokens, managed GitLab tokens) are resolved - `pnpm run format` - oxfmt write (src only) - `pnpm run format:check` - oxfmt check (src only) - `pnpm run typecheck` - TypeScript (tsgo) + wrapper typecheck -- `pnpm run test:checkers` - Duplication and architecture checker fixtures -- `pnpm run check:architecture` - Production ownership boundaries +- `pnpm run test:checkers` - Duplication checker fixtures - `pnpm run check:duplication` - Production duplicate-code ratchet ### Deployment diff --git a/services/cloud-agent-next/package.json b/services/cloud-agent-next/package.json index 8993458eb5..574c57d57b 100644 --- a/services/cloud-agent-next/package.json +++ b/services/cloud-agent-next/package.json @@ -20,10 +20,9 @@ "format": "oxfmt src scripts", "format:check": "oxfmt --list-different src scripts", "build:wrapper": "bun run --cwd wrapper build", - "check:architecture": "tsx scripts/check-architecture.ts", "check:duplication": "tsx scripts/check-duplication.ts", "test": "vitest run", - "test:checkers": "vitest run scripts/check-duplication.test.ts scripts/check-architecture.test.ts", + "test:checkers": "vitest run scripts/check-duplication.test.ts", "test:watch": "vitest", "test:integration": "vitest run --config vitest.workers.config.ts", "test:integration:watch": "vitest --config vitest.workers.config.ts", diff --git a/services/cloud-agent-next/scripts/check-architecture.test.ts b/services/cloud-agent-next/scripts/check-architecture.test.ts deleted file mode 100644 index 97a8d123a9..0000000000 --- a/services/cloud-agent-next/scripts/check-architecture.test.ts +++ /dev/null @@ -1,472 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { analyzeArchitecture, type ArchitectureViolation } from './check-architecture.js'; - -const owner = `export async function preflightSessionCreation(request: unknown) { return request; } -export function profileResolutionPolicyForSessionCreateOrigin() { return {}; } -export function resolveEffectiveSessionConfiguration(request: unknown) { return request; } -export function assertModeAvailableForProfile() {} -`; -const registration = `export async function registerNewSession(request: unknown) { return request; } -export async function startNewSession(request: unknown) { return request; } -export async function createSessionWithLedger(request: unknown) { - return startNewSession(request); -} -`; -const startHandler = `import { preflightSessionCreation as admit } from './preflight-barrel.js'; -import { startNewSession as register } from '../../session/session-registration.js'; -function withLogTags(_tags: unknown, callback: () => unknown) { return callback(); } -export async function start(request: unknown) { - return withLogTags({}, async () => { - const admitted = await admit(request); - return register(admitted); - }); -} -`; -const prepareHandler = `import { preflightSessionCreation } from './session-creation-preflight.js'; -import { createSessionWithLedger, registerNewSession, startNewSession } from '../../session/session-registration.js'; -function withLogTags(_tags: unknown, callback: () => unknown) { return callback(); } -export async function prepare(request: unknown, autoInitiate: boolean, operationKey?: string) { - return withLogTags({}, async () => { - const admitted = await preflightSessionCreation(request); - return autoInitiate && operationKey - ? createSessionWithLedger(admitted) - : autoInitiate - ? startNewSession(admitted) - : registerNewSession(admitted); - }); -} -`; - -function baseFiles(): Record { - return { - 'src/router/handlers/session-creation-preflight.ts': owner, - 'src/router/handlers/preflight-barrel.ts': - "export { preflightSessionCreation } from './session-creation-preflight.js';\n", - 'src/router/handlers/session-start.ts': startHandler, - 'src/router/handlers/session-prepare.ts': prepareHandler, - 'src/session/session-registration.ts': registration, - 'src/model-validation.ts': 'export function assertKiloModelAvailable() {}\n', - 'src/session/validate-repository-access.ts': - 'export function assertRepositoryAccessBeforeSessionCreation() {}\n', - 'src/router/handlers/organization-membership.ts': - 'export function assertOrganizationMembership() {}\n', - 'src/shared/protocol.ts': 'export type Protocol = string;\n', - }; -} - -async function fixture( - changes: Record, - run: (violations: ArchitectureViolation[], root: string) => void | Promise -) { - const root = await mkdtemp(join(tmpdir(), 'cloud-agent-architecture-test-')); - try { - const files = { ...baseFiles(), ...changes }; - for (const [name, content] of Object.entries(files)) { - const file = join(root, name); - await mkdir(dirname(file), { recursive: true }); - await writeFile(file, content); - } - await run(await analyzeArchitecture(root), root); - } finally { - await rm(root, { recursive: true, force: true }); - } -} - -function messages(violations: readonly ArchitectureViolation[]): string { - return violations.map(violation => `${violation.rule}: ${violation.message}`).join('\n'); -} - -describe('creation ownership architecture', () => { - it('accepts aliased/re-exported owner use and the production withLogTags prepare ternary', async () => { - await fixture({}, violations => expect(violations).toEqual([])); - }); - - it.each([ - [ - 'skipped preflight', - `import { startNewSession } from '../../session/session-registration.js'; - export async function start(request: unknown) { return startNewSession(request); }`, - 'must call preflightSessionCreation', - ], - [ - 'unused owner import', - `import { preflightSessionCreation } from './session-creation-preflight.js'; - import { startNewSession } from '../../session/session-registration.js'; - export async function start(request: unknown) { return startNewSession(request); }`, - 'import alone is not sufficient', - ], - [ - 'unresolved request registration', - `import { preflightSessionCreation } from './session-creation-preflight.js'; - import { startNewSession } from '../../session/session-registration.js'; - export async function start(request: unknown) { - const admitted = await preflightSessionCreation(request); - return startNewSession(request); - }`, - 'exact unshadowed preflight result', - ], - [ - 'conditional preflight', - `import { preflightSessionCreation } from './session-creation-preflight.js'; - import { startNewSession } from '../../session/session-registration.js'; - export async function start(request: unknown, condition: boolean) { - let admitted = request; - if (condition) { admitted = await preflightSessionCreation(request); } - return startNewSession(admitted); - }`, - 'unconditional const binding', - ], - [ - 'preflight in deeper callback', - `import { preflightSessionCreation } from './session-creation-preflight.js'; - import { startNewSession } from '../../session/session-registration.js'; - export async function start(request: unknown) { - const admitted = await Promise.resolve().then(() => preflightSessionCreation(request)); - return startNewSession(admitted); - }`, - 'unconditional const binding', - ], - [ - 'preflight outside the executing body', - `import { preflightSessionCreation } from './session-creation-preflight.js'; - import { startNewSession } from '../../session/session-registration.js'; - function withLogTags(callback: () => unknown) { return callback(); } - export async function start(request: unknown) { - const admitted = await preflightSessionCreation(request); - return withLogTags(async () => startNewSession(admitted)); - }`, - 'unconditional preflight result', - ], - [ - 'overwritten admitted result', - `import { preflightSessionCreation } from './session-creation-preflight.js'; - import { startNewSession } from '../../session/session-registration.js'; - export async function start(request: unknown) { - const admitted = await preflightSessionCreation(request); - admitted = request; - return startNewSession(admitted); - }`, - 'must not be overwritten', - ], - [ - 'restored original profile after preflight', - `import { preflightSessionCreation } from './session-creation-preflight.js'; - import { startNewSession } from '../../session/session-registration.js'; - export async function start(request: any) { - const admitted = await preflightSessionCreation(request); - admitted.profile = request.profile; - return startNewSession(admitted); - }`, - 'must not be overwritten', - ], - [ - 'restored original agent through a nested element write', - `import { preflightSessionCreation } from './session-creation-preflight.js'; - import { startNewSession } from '../../session/session-registration.js'; - export async function start(request: any) { - const admitted = await preflightSessionCreation(request); - admitted['profile'].resolved.runtimeAgents[0] = request.profile.resolved.runtimeAgents[0]; - return startNewSession(admitted); - }`, - 'must not be overwritten', - ], - [ - 'deleted admitted profile data', - `import { preflightSessionCreation } from './session-creation-preflight.js'; - import { startNewSession } from '../../session/session-registration.js'; - export async function start(request: any) { - const admitted = await preflightSessionCreation(request); - delete admitted.profile.resolved; - return startNewSession(admitted); - }`, - 'must not be overwritten', - ], - [ - 'shadowed admitted result', - `import { preflightSessionCreation } from './session-creation-preflight.js'; - import { startNewSession } from '../../session/session-registration.js'; - export async function start(request: unknown) { - const admitted = await preflightSessionCreation(request); - { const admitted = request; return startNewSession(admitted); } - }`, - 'exact unshadowed preflight result', - ], - ])('rejects %s', async (_name, replacement, expected) => { - await fixture({ 'src/router/handlers/session-start.ts': replacement }, violations => { - expect(messages(violations)).toContain(expected); - }); - }); - - it('rejects direct and re-exported low-level admission imports', async () => { - const direct = `import { preflightSessionCreation } from './session-creation-preflight.js'; -import { assertKiloModelAvailable } from '../../model-validation.js'; -import { startNewSession } from '../../session/session-registration.js'; -export async function start(request: unknown) { - assertKiloModelAvailable(); - const admitted = await preflightSessionCreation(request); - return startNewSession(admitted); -}`; - await fixture({ 'src/router/handlers/session-start.ts': direct }, violations => { - expect(messages(violations)).toContain( - 'imports low-level admission symbol assertKiloModelAvailable' - ); - }); - - const reexported = `import { preflightSessionCreation } from './session-creation-preflight.js'; -import { checkModel } from './low-level-barrel.js'; -import { startNewSession } from '../../session/session-registration.js'; -export async function start(request: unknown) { - checkModel(); - const admitted = await preflightSessionCreation(request); - return startNewSession(admitted); -}`; - await fixture( - { - 'src/router/handlers/session-start.ts': reexported, - 'src/router/handlers/low-level-barrel.ts': - "export { assertKiloModelAvailable as checkModel } from '../../model-validation.js';\n", - }, - violations => { - expect(messages(violations)).toContain( - 'imports low-level admission symbol assertKiloModelAvailable' - ); - } - ); - - const profileResolver = `import { preflightSessionCreation } from './session-creation-preflight.js'; -import { mergeProfileConfiguration as resolveProfile } from '@kilocode/cloud-agent-profile'; -import { startNewSession } from '../../session/session-registration.js'; -export async function start(request: unknown) { - resolveProfile; - const admitted = await preflightSessionCreation(request); - return startNewSession(admitted); -}`; - await fixture({ 'src/router/handlers/session-start.ts': profileResolver }, violations => { - expect(messages(violations)).toContain( - 'imports low-level profile resolver mergeProfileConfiguration' - ); - }); - - const packageReexport = `import { preflightSessionCreation } from './session-creation-preflight.js'; -import { resolveProfile } from './profile-package-barrel.js'; -import { startNewSession } from '../../session/session-registration.js'; -export async function start(request: unknown) { - resolveProfile; - const admitted = await preflightSessionCreation(request); - return startNewSession(admitted); -}`; - await fixture( - { - 'src/router/handlers/session-start.ts': packageReexport, - 'src/router/handlers/profile-package-barrel.ts': - "export { mergeProfileConfiguration as resolveProfile } from '@kilocode/cloud-agent-profile';\n", - 'node_modules/@kilocode/cloud-agent-profile/index.d.ts': - 'export declare function mergeProfileConfiguration(): unknown;\n', - }, - violations => { - expect(messages(violations)).toContain( - 'imports low-level profile resolver mergeProfileConfiguration' - ); - } - ); - }); - - it('rejects direct-package and local-barrel dynamic low-level admission imports', async () => { - const packageImport = `import { preflightSessionCreation } from './session-creation-preflight.js'; -import { startNewSession } from '../../session/session-registration.js'; -export async function start(request: unknown) { - const profile = await import('@kilocode/cloud-agent-profile'); - await profile.mergeProfileConfiguration(); - const admitted = await preflightSessionCreation(request); - return startNewSession(admitted); -}`; - await fixture( - { - 'src/router/handlers/session-start.ts': packageImport, - 'node_modules/@kilocode/cloud-agent-profile/index.d.ts': - 'export declare function mergeProfileConfiguration(): unknown;\n', - }, - violations => { - expect(messages(violations)).toContain( - 'imports low-level profile resolver mergeProfileConfiguration' - ); - } - ); - - const profileBarrelImport = `import { preflightSessionCreation } from './session-creation-preflight.js'; -import { startNewSession } from '../../session/session-registration.js'; -export async function start(request: unknown) { - const profile = await import('./profile-package-barrel.js'); - await profile.resolveProfile(); - const admitted = await preflightSessionCreation(request); - return startNewSession(admitted); -}`; - await fixture( - { - 'src/router/handlers/session-start.ts': profileBarrelImport, - 'src/router/handlers/profile-package-barrel.ts': - "export { mergeProfileConfiguration as resolveProfile } from '@kilocode/cloud-agent-profile';\n", - 'node_modules/@kilocode/cloud-agent-profile/index.d.ts': - 'export declare function mergeProfileConfiguration(): unknown;\n', - }, - violations => { - expect(messages(violations)).toContain( - 'imports low-level profile resolver mergeProfileConfiguration' - ); - } - ); - - const admissionBarrelImport = `import { preflightSessionCreation } from './session-creation-preflight.js'; -import { startNewSession } from '../../session/session-registration.js'; -export async function start(request: unknown) { - const checks = await import('./all-low-level-barrel.js'); - checks.checkOrganization(); - const admitted = await preflightSessionCreation(request); - return startNewSession(admitted); -}`; - await fixture( - { - 'src/router/handlers/session-start.ts': admissionBarrelImport, - 'src/router/handlers/all-low-level-barrel.ts': `export { assertKiloModelAvailable as checkModel } from '../../model-validation.js'; -export { assertRepositoryAccessBeforeSessionCreation as checkRepository } from '../../session/validate-repository-access.js'; -export { assertOrganizationMembership as checkOrganization } from './organization-membership.js';\n`, - }, - violations => { - const output = messages(violations); - expect(output).toContain('imports low-level admission symbol assertKiloModelAvailable'); - expect(output).toContain( - 'imports low-level admission symbol assertRepositoryAccessBeforeSessionCreation' - ); - expect(output).toContain('imports low-level admission symbol assertOrganizationMembership'); - } - ); - }); - - it('rejects indirect and aliased registration callers outside the two handlers', async () => { - await fixture( - { - 'src/helper.ts': `import { startNewSession as bypass } from './session/session-registration.js'; -export function helper(request: unknown) { return bypass(request); }`, - }, - violations => { - expect(messages(violations)).toContain('may be called only by the creation handlers'); - } - ); - }); - - it.each([ - [ - 'namespace property access', - `import * as registration from './session/session-registration.js'; -export function helper(request: unknown) { return registration.startNewSession(request); }`, - 'may be called only by the creation handlers', - ], - [ - 'literal namespace element access', - `import * as registration from './session/session-registration.js'; -export function helper(request: unknown) { return registration['startNewSession'](request); }`, - 'may be called only by the creation handlers', - ], - [ - 'shorthand namespace destructuring', - `import * as registration from './session/session-registration.js'; -const { startNewSession } = registration; -export function helper(request: unknown) { return startNewSession(request); }`, - 'must not be destructured, aliased, or passed', - ], - [ - 'computed namespace element access', - `import * as registration from './session/session-registration.js'; -export function helper(name: string, request: unknown) { return registration[name](request); }`, - 'must not be destructured, aliased, or passed', - ], - ])('rejects registration through %s', async (_name, source, expected) => { - await fixture({ 'src/helper.ts': source }, violations => { - expect(messages(violations)).toContain(expected); - }); - }); - - it.each([ - [ - 'a local alias', - `import { startNewSession } from './session/session-registration.js'; -const create = startNewSession; -export function helper(request: unknown) { return create(request); }`, - ], - [ - 'a passed reference', - `import { startNewSession } from './session/session-registration.js'; -function invoke(callback: (request: unknown) => unknown, request: unknown) { return callback(request); } -export function helper(request: unknown) { return invoke(startNewSession, request); }`, - ], - ])('rejects registration functions used through %s', async (_name, source) => { - await fixture({ 'src/helper.ts': source }, violations => { - expect(messages(violations)).toContain('local aliases and passed references are not allowed'); - }); - }); -}); - -describe('worker-wrapper source ownership', () => { - it('accepts wrapper imports from src/shared', async () => { - await fixture( - { 'wrapper/src/valid.ts': "import type { Protocol } from '../../src/shared/protocol.js';\n" }, - violations => expect(violations).toEqual([]) - ); - }); - - it('rejects wrapper-to-worker and worker-to-wrapper production imports', async () => { - await fixture( - { - 'src/worker-bypass.ts': "import '../wrapper/src/wrapper-only.js';\n", - 'wrapper/src/wrapper-only.ts': 'export const wrapperOnly = true;\n', - 'wrapper/src/wrapper-bypass.ts': "import '../../src/model-validation.js';\n", - }, - violations => { - const output = messages(violations); - expect(output).toContain( - 'Wrapper production code may import Worker code only from src/shared' - ); - expect(output).toContain('Worker production code must not import wrapper code'); - } - ); - }); - - it('rejects literal dynamic imports in both worker-wrapper directions', async () => { - await fixture( - { - 'src/dynamic-worker-bypass.ts': - "export const wrapper = import('../wrapper/src/wrapper-only.js');\n", - 'wrapper/src/wrapper-only.ts': 'export const wrapperOnly = true;\n', - 'wrapper/src/dynamic-wrapper-bypass.ts': - "export const worker = import('../../src/model-validation.js');\n", - }, - violations => { - const output = messages(violations); - expect(output).toContain( - 'Wrapper production code may import Worker code only from src/shared' - ); - expect(output).toContain('Worker production code must not import wrapper code'); - } - ); - }); - - it('excludes tests, specs, and fixture paths from the production scan', async () => { - const forbidden = `import { startNewSession } from './session-registration.js'; -startNewSession({});`; - await fixture( - { - 'src/session/bypass.test.ts': forbidden, - 'src/session/bypass.spec.ts': forbidden, - 'src/session/bypass-fixture.ts': forbidden, - 'src/fixtures/bypass.ts': forbidden, - 'src/__snapshots__/bypass.ts': forbidden, - 'src/recordings/bypass.ts': forbidden, - 'wrapper/src/bypass.test.ts': "import '../../src/model-validation.js';\n", - }, - violations => expect(violations).toEqual([]) - ); - }); -}); diff --git a/services/cloud-agent-next/scripts/check-architecture.ts b/services/cloud-agent-next/scripts/check-architecture.ts deleted file mode 100644 index 5008ff05a1..0000000000 --- a/services/cloud-agent-next/scripts/check-architecture.ts +++ /dev/null @@ -1,781 +0,0 @@ -import { readdir, realpath } from 'node:fs/promises'; -import { dirname, join, relative, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import ts from 'typescript'; - -const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const handlerFiles = new Set([ - 'src/router/handlers/session-start.ts', - 'src/router/handlers/session-prepare.ts', -]); -const registrationFile = 'src/session/session-registration.ts'; -const preflightFile = 'src/router/handlers/session-creation-preflight.ts'; -const registrationNames = new Set([ - 'registerNewSession', - 'startNewSession', - 'createSessionWithLedger', -]); -const forbiddenHandlerSymbols = new Set([ - 'src/model-validation.ts\0assertKiloModelAvailable', - 'src/session/validate-repository-access.ts\0assertRepositoryAccessBeforeSessionCreation', - 'src/router/handlers/organization-membership.ts\0assertOrganizationMembership', - `${preflightFile}\0profileResolutionPolicyForSessionCreateOrigin`, - `${preflightFile}\0resolveEffectiveSessionConfiguration`, - `${preflightFile}\0assertModeAvailableForProfile`, -]); -const excludedDirectories = new Set([ - '__fixtures__', - '__mocks__', - '__snapshots__', - '__tests__', - '.wrangler', - 'build', - 'coverage', - 'deps', - 'dist', - 'fixture', - 'fixtures', - 'generated', - 'node_modules', - 'out', - 'recordings', - 'specs', - 'test', - 'testdata', - 'tests', -]); - -export type ArchitectureViolation = { - file: string; - rule: 'creation-preflight' | 'registration-owner' | 'worker-wrapper-boundary'; - message: string; - line?: number; -}; - -function normalizePath(path: string): string { - return path.replaceAll('\\', '/'); -} - -function isExcluded(path: string): boolean { - const parts = path.split('/'); - const basename = parts.at(-1) ?? ''; - return ( - parts.some(part => excludedDirectories.has(part)) || - /\.(?:test|spec)\.[^.]+$/.test(basename) || - /(?:^|[-_.])fixtures?(?:[-_.]|$)/.test(basename) || - /(?:^|[-_.])test[-_.]?data(?:[-_.]|$)/.test(basename) || - /\.d\.[cm]?tsx?$/.test(basename) || - /\.(?:gen|generated)\.[cm]?tsx?$/.test(basename) - ); -} - -async function collectProductionFiles(root: string): Promise { - const files: string[] = []; - async function visit(directory: string): Promise { - let entries; - try { - entries = await readdir(directory, { withFileTypes: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; - throw error; - } - for (const entry of entries) { - const absolute = join(directory, entry.name); - const relativePath = normalizePath(relative(root, absolute)); - if (isExcluded(relativePath)) continue; - if (entry.isDirectory()) await visit(absolute); - else if (entry.isFile() && /\.[cm]?tsx?$/.test(entry.name)) files.push(absolute); - } - } - await visit(join(root, 'src')); - await visit(join(root, 'wrapper/src')); - return files.toSorted(); -} - -function sourceFilePath(root: string, sourceFile: ts.SourceFile): string { - return normalizePath(relative(root, sourceFile.fileName)); -} - -function lineOf(sourceFile: ts.SourceFile, node: ts.Node): number { - return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; -} - -function resolveLocalModule( - sourceFile: string, - specifier: string, - files: ReadonlySet -): string | undefined { - if (!specifier.startsWith('.')) return undefined; - const requested = normalizePath(resolve(dirname(sourceFile), specifier)); - const withoutScriptExtension = requested.replace(/\.(?:mjs|cjs|js|jsx|mts|cts|ts|tsx)$/, ''); - const candidates = [ - requested, - `${withoutScriptExtension}.ts`, - `${withoutScriptExtension}.tsx`, - `${withoutScriptExtension}.mts`, - `${withoutScriptExtension}.cts`, - `${requested}/index.ts`, - `${requested}/index.tsx`, - ]; - return candidates.find(candidate => files.has(candidate)); -} - -function moduleSpecifiers(sourceFile: ts.SourceFile): ts.StringLiteralLike[] { - const specifiers: ts.StringLiteralLike[] = []; - function visit(node: ts.Node): void { - if ( - (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && - node.moduleSpecifier && - ts.isStringLiteralLike(node.moduleSpecifier) - ) { - specifiers.push(node.moduleSpecifier); - } - if ( - ts.isCallExpression(node) && - node.expression.kind === ts.SyntaxKind.ImportKeyword && - node.arguments.length === 1 && - ts.isStringLiteralLike(node.arguments[0]) - ) { - specifiers.push(node.arguments[0]); - } - ts.forEachChild(node, visit); - } - visit(sourceFile); - return specifiers; -} - -function canonicalSymbol( - checker: ts.TypeChecker, - symbol: ts.Symbol | undefined -): ts.Symbol | undefined { - const seen = new Set(); - let current = symbol; - while (current && (current.flags & ts.SymbolFlags.Alias) !== 0 && !seen.has(current)) { - seen.add(current); - const next = checker.getAliasedSymbol(current); - if (next === current) break; - current = next; - } - return current; -} - -function symbolAtExpression( - checker: ts.TypeChecker, - expression: ts.Expression -): ts.Symbol | undefined { - let current = expression; - while (ts.isParenthesizedExpression(current)) current = current.expression; - if (ts.isPropertyAccessExpression(current)) { - return canonicalSymbol(checker, checker.getSymbolAtLocation(current.name)); - } - if (ts.isElementAccessExpression(current) && ts.isStringLiteralLike(current.argumentExpression)) { - return canonicalSymbol(checker, checker.getSymbolAtLocation(current.argumentExpression)); - } - return canonicalSymbol(checker, checker.getSymbolAtLocation(current)); -} - -function symbolIdentity( - root: string, - checker: ts.TypeChecker, - symbol: ts.Symbol | undefined -): string | undefined { - const resolvedSymbol = canonicalSymbol(checker, symbol); - const declaration = resolvedSymbol?.declarations?.find(candidate => - sourceFilePath(root, candidate.getSourceFile()).startsWith('src/') - ); - if (!resolvedSymbol || !declaration) return undefined; - return `${sourceFilePath(root, declaration.getSourceFile())}\0${resolvedSymbol.getName()}`; -} - -function callIdentity( - root: string, - checker: ts.TypeChecker, - call: ts.CallExpression -): string | undefined { - return symbolIdentity(root, checker, symbolAtExpression(checker, call.expression)); -} - -function collectCalls(sourceFile: ts.SourceFile): ts.CallExpression[] { - const calls: ts.CallExpression[] = []; - function visit(node: ts.Node): void { - if (ts.isCallExpression(node)) calls.push(node); - ts.forEachChild(node, visit); - } - visit(sourceFile); - return calls; -} - -function containingFunction(node: ts.Node): ts.FunctionLikeDeclaration | undefined { - let current: ts.Node | undefined = node.parent; - while (current) { - if ( - ts.isFunctionDeclaration(current) || - ts.isFunctionExpression(current) || - ts.isArrowFunction(current) || - ts.isMethodDeclaration(current) || - ts.isGetAccessorDeclaration(current) || - ts.isSetAccessorDeclaration(current) || - ts.isConstructorDeclaration(current) - ) { - return current; - } - current = current.parent; - } - return undefined; -} - -function preflightDeclaration( - root: string, - checker: ts.TypeChecker, - body: ts.Block -): ts.VariableDeclaration | undefined { - const candidates: ts.VariableDeclaration[] = []; - for (const statement of body.statements) { - if ( - !ts.isVariableStatement(statement) || - (statement.declarationList.flags & ts.NodeFlags.Const) === 0 || - statement.declarationList.declarations.length !== 1 - ) { - continue; - } - const declaration = statement.declarationList.declarations[0]; - if ( - declaration && - ts.isIdentifier(declaration.name) && - declaration.initializer && - ts.isAwaitExpression(declaration.initializer) && - ts.isCallExpression(declaration.initializer.expression) && - callIdentity(root, checker, declaration.initializer.expression) === - `${preflightFile}\0preflightSessionCreation` - ) { - candidates.push(declaration); - } - } - return candidates.length === 1 ? candidates[0] : undefined; -} - -const assignmentOperators = new Set([ - ts.SyntaxKind.EqualsToken, - ts.SyntaxKind.PlusEqualsToken, - ts.SyntaxKind.MinusEqualsToken, - ts.SyntaxKind.AsteriskEqualsToken, - ts.SyntaxKind.AsteriskAsteriskEqualsToken, - ts.SyntaxKind.SlashEqualsToken, - ts.SyntaxKind.PercentEqualsToken, - ts.SyntaxKind.LessThanLessThanEqualsToken, - ts.SyntaxKind.GreaterThanGreaterThanEqualsToken, - ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, - ts.SyntaxKind.AmpersandEqualsToken, - ts.SyntaxKind.BarEqualsToken, - ts.SyntaxKind.CaretEqualsToken, - ts.SyntaxKind.BarBarEqualsToken, - ts.SyntaxKind.AmpersandAmpersandEqualsToken, - ts.SyntaxKind.QuestionQuestionEqualsToken, -]); - -function bindingIsMutated( - checker: ts.TypeChecker, - body: ts.Block, - declaration: ts.VariableDeclaration, - before: number -): boolean { - const declarationSymbol = canonicalSymbol(checker, checker.getSymbolAtLocation(declaration.name)); - let mutated = false; - function isRootedAtDeclaration(expression: ts.Expression): boolean { - let current = expression; - while ( - ts.isParenthesizedExpression(current) || - ts.isAsExpression(current) || - ts.isSatisfiesExpression(current) || - ts.isNonNullExpression(current) - ) { - current = current.expression; - } - while (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current)) { - current = current.expression; - while ( - ts.isParenthesizedExpression(current) || - ts.isAsExpression(current) || - ts.isSatisfiesExpression(current) || - ts.isNonNullExpression(current) - ) { - current = current.expression; - } - } - return ( - ts.isIdentifier(current) && - canonicalSymbol(checker, checker.getSymbolAtLocation(current)) === declarationSymbol - ); - } - function visit(node: ts.Node): void { - if (mutated || node.getStart() >= before) return; - if (ts.isBinaryExpression(node) && assignmentOperators.has(node.operatorToken.kind)) { - if (isRootedAtDeclaration(node.left)) { - mutated = true; - return; - } - } - if ( - (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && - (node.operator === ts.SyntaxKind.PlusPlusToken || - node.operator === ts.SyntaxKind.MinusMinusToken) && - isRootedAtDeclaration(node.operand) - ) { - mutated = true; - return; - } - if (ts.isDeleteExpression(node) && isRootedAtDeclaration(node.expression)) { - mutated = true; - return; - } - ts.forEachChild(node, visit); - } - visit(body); - return mutated; -} - -function importedSymbols(checker: ts.TypeChecker, sourceFile: ts.SourceFile): ts.Symbol[] { - const symbols: ts.Symbol[] = []; - for (const statement of sourceFile.statements) { - if (!ts.isImportDeclaration(statement) || !statement.importClause) continue; - const clause = statement.importClause; - if (clause.name) { - const symbol = canonicalSymbol(checker, checker.getSymbolAtLocation(clause.name)); - if (symbol) symbols.push(symbol); - } - if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) { - for (const element of clause.namedBindings.elements) { - const symbol = canonicalSymbol(checker, checker.getSymbolAtLocation(element.name)); - if (symbol) symbols.push(symbol); - } - } else if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) { - const moduleSymbol = checker.getSymbolAtLocation(statement.moduleSpecifier); - if (moduleSymbol) { - for (const exported of checker.getExportsOfModule(moduleSymbol)) { - const symbol = canonicalSymbol(checker, exported); - if (symbol) symbols.push(symbol); - } - } - } - } - return symbols; -} - -function dynamicImportedSymbols(checker: ts.TypeChecker, sourceFile: ts.SourceFile): ts.Symbol[] { - const symbols: ts.Symbol[] = []; - function visit(node: ts.Node): void { - if ( - ts.isCallExpression(node) && - node.expression.kind === ts.SyntaxKind.ImportKeyword && - node.arguments.length === 1 && - ts.isStringLiteralLike(node.arguments[0]) - ) { - const moduleSymbol = checker.getSymbolAtLocation(node.arguments[0]); - if (moduleSymbol) { - for (const exported of checker.getExportsOfModule(moduleSymbol)) { - const symbol = canonicalSymbol(checker, exported); - if (symbol) symbols.push(symbol); - } - } - } - ts.forEachChild(node, visit); - } - visit(sourceFile); - return symbols; -} - -function importsProfileResolverDirectly(sourceFile: ts.SourceFile): boolean { - for (const statement of sourceFile.statements) { - if ( - !ts.isImportDeclaration(statement) || - !ts.isStringLiteralLike(statement.moduleSpecifier) || - statement.moduleSpecifier.text !== '@kilocode/cloud-agent-profile' || - !statement.importClause?.namedBindings - ) { - continue; - } - if (ts.isNamespaceImport(statement.importClause.namedBindings)) return true; - if ( - statement.importClause.namedBindings.elements.some( - element => (element.propertyName ?? element.name).text === 'mergeProfileConfiguration' - ) - ) { - return true; - } - } - return false; -} - -function dynamicallyImportsProfileResolverDirectly(sourceFile: ts.SourceFile): boolean { - return moduleSpecifiers(sourceFile).some( - specifier => - ts.isCallExpression(specifier.parent) && - specifier.parent.expression.kind === ts.SyntaxKind.ImportKeyword && - specifier.text === '@kilocode/cloud-agent-profile' - ); -} - -function isProfileResolverSymbol(checker: ts.TypeChecker, symbol: ts.Symbol): boolean { - const resolvedSymbol = canonicalSymbol(checker, symbol); - if (resolvedSymbol?.getName() !== 'mergeProfileConfiguration') return false; - return Boolean( - resolvedSymbol.declarations?.some(declaration => { - const file = normalizePath(declaration.getSourceFile().fileName); - return ( - file.includes('/node_modules/@kilocode/cloud-agent-profile/') || - file.includes('/packages/cloud-agent-profile/') - ); - }) - ); -} - -function isSupportedRegistrationReference(node: ts.Identifier): boolean { - if ( - ts.isImportSpecifier(node.parent) || - ts.isExportSpecifier(node.parent) || - (ts.isFunctionDeclaration(node.parent) && node.parent.name === node) - ) { - return true; - } - - let expression: ts.Expression = node; - if (ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) { - expression = node.parent; - } - while (ts.isParenthesizedExpression(expression.parent)) expression = expression.parent; - return ts.isCallExpression(expression.parent) && expression.parent.expression === expression; -} - -function isDirectCallExpression(expression: ts.Expression): boolean { - let current = expression; - while (ts.isParenthesizedExpression(current.parent)) current = current.parent; - return ts.isCallExpression(current.parent) && current.parent.expression === current; -} - -function registrationNamespaceSymbols( - root: string, - checker: ts.TypeChecker, - sourceFile: ts.SourceFile -): Set { - const symbols = new Set(); - for (const statement of sourceFile.statements) { - if ( - !ts.isImportDeclaration(statement) || - !statement.importClause?.namedBindings || - !ts.isNamespaceImport(statement.importClause.namedBindings) - ) { - continue; - } - const moduleSymbol = checker.getSymbolAtLocation(statement.moduleSpecifier); - if ( - !moduleSymbol || - !checker - .getExportsOfModule(moduleSymbol) - .some( - exported => - symbolIdentity(root, checker, exported)?.startsWith(`${registrationFile}\0`) ?? false - ) - ) { - continue; - } - const symbol = checker.getSymbolAtLocation(statement.importClause.namedBindings.name); - if (symbol) symbols.add(symbol); - } - return symbols; -} - -function addViolation( - violations: ArchitectureViolation[], - sourceFile: ts.SourceFile, - root: string, - rule: ArchitectureViolation['rule'], - message: string, - node?: ts.Node -): void { - violations.push({ - file: sourceFilePath(root, sourceFile), - rule, - message, - ...(node ? { line: lineOf(sourceFile, node) } : {}), - }); -} - -export async function analyzeArchitecture(rootInput: string): Promise { - const root = await realpath(rootInput); - const files = await collectProductionFiles(root); - if (!files.length) - throw new Error('No production TypeScript files found for architecture analysis'); - const fileSet = new Set(files.map(file => resolve(file))); - const program = ts.createProgram({ - rootNames: files, - options: { - target: ts.ScriptTarget.ESNext, - module: ts.ModuleKind.ESNext, - moduleResolution: ts.ModuleResolutionKind.Bundler, - allowJs: false, - skipLibCheck: true, - noEmit: true, - }, - }); - const checker = program.getTypeChecker(); - const violations: ArchitectureViolation[] = []; - - for (const sourceFile of program.getSourceFiles()) { - const file = sourceFilePath(root, sourceFile); - if (!fileSet.has(resolve(sourceFile.fileName)) || isExcluded(file)) continue; - - for (const specifier of moduleSpecifiers(sourceFile)) { - if (!specifier.text.startsWith('.')) continue; - const target = resolveLocalModule(sourceFile.fileName, specifier.text, fileSet); - if (!target) { - continue; - } - const targetFile = normalizePath(relative(root, target)); - if ( - file.startsWith('wrapper/src/') && - targetFile.startsWith('src/') && - !targetFile.startsWith('src/shared/') - ) { - addViolation( - violations, - sourceFile, - root, - 'worker-wrapper-boundary', - `Wrapper production code may import Worker code only from src/shared (resolved ${targetFile})`, - specifier - ); - } - if (file.startsWith('src/') && targetFile.startsWith('wrapper/')) { - addViolation( - violations, - sourceFile, - root, - 'worker-wrapper-boundary', - `Worker production code must not import wrapper code (resolved ${targetFile})`, - specifier - ); - } - } - - if (handlerFiles.has(file)) { - const imports = importedSymbols(checker, sourceFile); - const dynamicImports = dynamicImportedSymbols(checker, sourceFile); - const lowLevelImports = [...imports, ...dynamicImports]; - if ( - importsProfileResolverDirectly(sourceFile) || - dynamicallyImportsProfileResolverDirectly(sourceFile) || - lowLevelImports.some(symbol => isProfileResolverSymbol(checker, symbol)) - ) { - addViolation( - violations, - sourceFile, - root, - 'creation-preflight', - 'Creation handler imports low-level profile resolver mergeProfileConfiguration' - ); - } - for (const symbol of lowLevelImports) { - const identity = symbolIdentity(root, checker, symbol); - if (identity && forbiddenHandlerSymbols.has(identity)) { - addViolation( - violations, - sourceFile, - root, - 'creation-preflight', - `Creation handler imports low-level admission symbol ${identity.split('\0')[1]}` - ); - } - } - } - - const calls = collectCalls(sourceFile); - const registrationNamespaces = registrationNamespaceSymbols(root, checker, sourceFile); - - function checkRegistrationReferences(node: ts.Node): void { - if (ts.isIdentifier(node)) { - const localSymbol = checker.getSymbolAtLocation(node); - if ( - localSymbol && - registrationNamespaces.has(localSymbol) && - !ts.isNamespaceImport(node.parent) && - !( - (ts.isPropertyAccessExpression(node.parent) && node.parent.expression === node) || - (ts.isElementAccessExpression(node.parent) && - node.parent.expression === node && - ts.isStringLiteralLike(node.parent.argumentExpression)) - ) - ) { - addViolation( - violations, - sourceFile, - root, - 'registration-owner', - 'Registration namespaces must not be destructured, aliased, or passed as references', - node - ); - } - const identity = symbolIdentity(root, checker, checker.getSymbolAtLocation(node)); - if (identity) { - const separator = identity.lastIndexOf('\0'); - const originFile = identity.slice(0, separator); - const name = identity.slice(separator + 1); - if ( - originFile === registrationFile && - registrationNames.has(name) && - !isSupportedRegistrationReference(node) - ) { - addViolation( - violations, - sourceFile, - root, - 'registration-owner', - `${name} must be called directly; local aliases and passed references are not allowed`, - node - ); - } - } - } - if (ts.isElementAccessExpression(node)) { - const identity = symbolIdentity(root, checker, symbolAtExpression(checker, node)); - if (identity) { - const separator = identity.lastIndexOf('\0'); - const originFile = identity.slice(0, separator); - const name = identity.slice(separator + 1); - if ( - originFile === registrationFile && - registrationNames.has(name) && - !isDirectCallExpression(node) - ) { - addViolation( - violations, - sourceFile, - root, - 'registration-owner', - `${name} must be called directly; local aliases and passed references are not allowed`, - node - ); - } - } - } - ts.forEachChild(node, checkRegistrationReferences); - } - checkRegistrationReferences(sourceFile); - - if ( - handlerFiles.has(file) && - !calls.some( - call => callIdentity(root, checker, call) === `${preflightFile}\0preflightSessionCreation` - ) - ) { - addViolation( - violations, - sourceFile, - root, - 'creation-preflight', - 'Creation handler must call preflightSessionCreation; an import alone is not sufficient' - ); - } - - for (const call of calls) { - const identity = callIdentity(root, checker, call); - if (!identity) continue; - const separator = identity.lastIndexOf('\0'); - const originFile = separator >= 0 ? identity.slice(0, separator) : undefined; - const name = separator >= 0 ? identity.slice(separator + 1) : undefined; - if (originFile !== registrationFile || !name || !registrationNames.has(name)) continue; - - if (file !== registrationFile && !handlerFiles.has(file)) { - addViolation( - violations, - sourceFile, - root, - 'registration-owner', - `${name} may be called only by the creation handlers or session-registration.ts`, - call - ); - continue; - } - if (file === registrationFile) continue; - - const owner = containingFunction(call); - if (!owner?.body || !ts.isBlock(owner.body)) { - addViolation( - violations, - sourceFile, - root, - 'creation-preflight', - `${name} must execute in a block with an unconditional preflight result`, - call - ); - continue; - } - const admitted = preflightDeclaration(root, checker, owner.body); - if (!admitted || admitted.getStart(sourceFile) >= call.getStart(sourceFile)) { - addViolation( - violations, - sourceFile, - root, - 'creation-preflight', - `${name} must follow one unconditional const binding awaited from preflightSessionCreation in the same function body`, - call - ); - continue; - } - if (bindingIsMutated(checker, owner.body, admitted, call.getStart(sourceFile))) { - addViolation( - violations, - sourceFile, - root, - 'creation-preflight', - 'The admitted preflight result must not be overwritten or updated', - call - ); - continue; - } - const firstArgument = call.arguments[0]; - const admittedSymbol = canonicalSymbol(checker, checker.getSymbolAtLocation(admitted.name)); - const argumentSymbol = - firstArgument && ts.isIdentifier(firstArgument) - ? canonicalSymbol(checker, checker.getSymbolAtLocation(firstArgument)) - : undefined; - if (!firstArgument || !ts.isIdentifier(firstArgument) || argumentSymbol !== admittedSymbol) { - addViolation( - violations, - sourceFile, - root, - 'creation-preflight', - `${name} must receive the exact unshadowed preflight result as its first argument`, - call - ); - } - } - } - - return violations.toSorted((left, right) => - `${left.file}\0${String(left.line ?? 0)}\0${left.rule}\0${left.message}`.localeCompare( - `${right.file}\0${String(right.line ?? 0)}\0${right.rule}\0${right.message}` - ) - ); -} - -export async function runArchitectureCheck(root = serviceRoot): Promise { - const violations = await analyzeArchitecture(root); - if (violations.length) { - const details = violations - .map( - violation => - `[${violation.rule}] ${violation.file}${violation.line ? `:${violation.line}` : ''}\n ${violation.message}` - ) - .join('\n'); - throw new Error(`Found ${violations.length} architecture boundary violation(s):\n${details}`); - } - console.log('check:architecture: production ownership boundaries are valid.'); -} - -const invokedPath = process.argv[1] ? resolve(process.argv[1]) : undefined; -if (invokedPath === fileURLToPath(import.meta.url)) { - runArchitectureCheck().catch((error: unknown) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - }); -}