From fbcf656082638fe212af642831e21c54d00b04f8 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Wed, 7 Oct 2026 21:23:49 +0200 Subject: [PATCH] fix(cloud-agent-next): deliver full profiles to control-plane sessions - Deliver profile secrets to control-plane sessions. - Deliver complete profiles to the control plane. - Isolate secret-bearing runtimes and gate frame failures. - Address profile delivery review gaps. - Skip overlong legacy skill paths. --- .../cloud-agent-next/docs/control-plane.md | 32 ++- .../control-plane/sandbox/frame-env.test.ts | 121 ++++++++ .../src/control-plane/sandbox/frame-env.ts | 64 +++++ .../control-plane/sandbox/repo-key.test.ts | 9 + .../src/control-plane/sandbox/repo-key.ts | 22 +- .../src/control-plane/sandbox/sandbox-do.ts | 189 ++++++++++--- .../session/registration.test.ts | 169 +++++++++++ .../src/control-plane/session/registration.ts | 37 ++- .../src/persistence/model-utils.ts | 17 +- .../src/persistence/schemas.ts | 153 +--------- .../sandbox-control/session-credentials.ts | 24 ++ .../src/sandbox-session/attach-payload.ts | 20 +- services/cloud-agent-next/src/schema.ts | 81 +----- .../cloud-agent-next/src/session-service.ts | 45 +-- .../src/shared/agent-modes.ts | 61 ++++ .../src/shared/control-plane-protocol.ts | 34 ++- .../src/shared/model-utils.ts | 16 ++ .../src/shared/runtime-profile-config.ts | 37 +++ .../src/shared/runtime-profile.ts | 183 ++++++++++++ .../test/integration/helpers/fake-wrapper.ts | 2 + .../sandbox-control-v2-repo-snapshots.test.ts | 16 ++ .../sandbox-control-v2-runtime-proxy.test.ts | 211 +++++++++++++- .../integration/sandbox-control-v2.test.ts | 262 ++++++++++++++++++ .../wrapper/src/control-plane/prepare.test.ts | 105 ++++++- .../wrapper/src/control-plane/prepare.ts | 44 +-- .../runtime-profile.real.test.ts | 127 +++++++++ .../src/control-plane/runtime-profile.test.ts | 87 ++++++ .../src/control-plane/runtime-profile.ts | 53 ++++ .../wrapper/src/runtime-skills.ts | 41 +++ .../wrapper/src/session-bootstrap.ts | 32 +-- 30 files changed, 1896 insertions(+), 398 deletions(-) create mode 100644 services/cloud-agent-next/src/control-plane/sandbox/frame-env.test.ts create mode 100644 services/cloud-agent-next/src/control-plane/sandbox/frame-env.ts create mode 100644 services/cloud-agent-next/src/shared/agent-modes.ts create mode 100644 services/cloud-agent-next/src/shared/model-utils.ts create mode 100644 services/cloud-agent-next/src/shared/runtime-profile-config.ts create mode 100644 services/cloud-agent-next/src/shared/runtime-profile.ts create mode 100644 services/cloud-agent-next/wrapper/src/control-plane/runtime-profile.real.test.ts create mode 100644 services/cloud-agent-next/wrapper/src/control-plane/runtime-profile.test.ts create mode 100644 services/cloud-agent-next/wrapper/src/control-plane/runtime-profile.ts create mode 100644 services/cloud-agent-next/wrapper/src/runtime-skills.ts diff --git a/services/cloud-agent-next/docs/control-plane.md b/services/cloud-agent-next/docs/control-plane.md index aa3ed368956..732afc69965 100644 --- a/services/cloud-agent-next/docs/control-plane.md +++ b/services/cloud-agent-next/docs/control-plane.md @@ -381,7 +381,9 @@ path `/workspace/app`. A snapshot holds one repository at one path, so any other stopped it), the next launch discards the entry and starts from the image. A broken snapshot costs one attempt. - **Capture.** `session.prepare` carries `capture: true` when the route has a key and the provider - can capture. The wrapper decides whether to capture (a fresh clone, or an adopted snapshot that is + can capture. A route with setup commands has no key: capture runs after setup, so the snapshot + would publish the whole container under a key that names only the user and the repository. The + wrapper decides whether to capture (a fresh clone, or an adopted snapshot that is due for a refresh) and then sends `workspace.capture` after setup, with origin bare. The Sandbox DO calls the provider off its serial queue, bounded at 5 min 5 s, and answers `workspace.captured` (`ok: false` at once when nothing can be saved). `SandboxContainers` snapshots the running @@ -413,11 +415,16 @@ path `/workspace/app`. A snapshot holds one repository at one path, so any other the wrapper sends the original hello on the same socket. A legacy welcome enables periodic heartbeats without an acknowledgement deadline; old wrappers receive neither the optional field nor acknowledgement frames. Duplicate hello on a bound socket is ignored. -- A wrapper that redacts named secrets advertises optional `redactsNamedSecrets: true` in `hello`. A - `session.prepare` frame may carry frame-only `secretEnvKeys`, the names of `env` entries whose - values the wrapper redacts in setup output and auto-commit diagnostics even when the name does not - look secret. The field is never stored in a route row, and a frame without it stays valid for a - version-3 wrapper. +- A wrapper that redacts named secrets advertises optional `redactsNamedSecrets: true` in `hello`; the + Sandbox DO stores it on the socket attachment like `heartbeatAck`. Encrypted secrets ride only the + DO-private credential source (`encryptedSecrets`) and are decrypted into the frame. A secret-bearing + `session.prepare` carries the names as frame-only `secretEnvKeys`, never written to the route row; a + frame with no secrets omits the field and stays valid for a version-3 wrapper. A secret-bearing + prepare is never sent to a wrapper that did not advertise the capability: the attempt fails + `workspace_setup_failed`. A secret-bearing spec forces `runtimeIsolation: 'per-session'` so a + sibling sharing its worktree directory cannot reuse a runtime whose env already holds another + session's secrets; read-only Bitbucket reviews carry no profile secrets, like the withheld MCP + snapshot. - Connect, send `hello` (`wrapperId`, `allocationId`, protocol version), wait for `welcome` or `shutdown`. On `shutdown`, permanently close the connection and exit with code 0, even when the @@ -486,6 +493,15 @@ Any failure empties the directory and clones. The Kilo session step is unchanged no Kilo home, so nothing stale shadows the restore from session-ingest. `session.ready` reports `workspace: 'cloned' | 'same' | 'adopted'`. +Before starting Kilo, materialize the session's profile skills (including companion files) into +`HOME/.kilocode/skills`. Custom agents and Kilo commands use a session-owned config file selected +by `KILO_CONFIG`, so large prompts and templates do not enlarge the process environment. +Registration requires per-session runtime isolation for these collections and for plain +profile environment variables, so a sibling cannot inherit another session's configuration. +Preparation replaces stale profile artifacts from restored homes, and credential refresh retains +the same profile config path. Read-only Bitbucket reviews withhold these collections, matching +the legacy profile restrictions. + #### Capture A route captures only when `session.prepare` carries `capture: true`, and then when it cloned @@ -501,8 +517,8 @@ setup the wrapper writes the stamp (before the snapshot, so a restored container allocation's stamp), sets `origin` to the bare URL, clears the reflogs and `FETCH_HEAD`, sends `workspace.capture` and waits for `workspace.captured`. It then restores the authenticated URL, whatever the outcome, and continues to the Kilo runtime. A snapshot therefore holds no agent edits, -no Kilo home and no git credential. Process env never reaches the disk. Files setup wrote from env stay in -the snapshot; setup re-runs on every start and rewrites them. +no Kilo home and no git credential. Process env never reaches the disk. A route with setup commands +has no key, so it restores and captures nothing: setup output is never published to a snapshot. ### Prompts and turn outcome diff --git a/services/cloud-agent-next/src/control-plane/sandbox/frame-env.test.ts b/services/cloud-agent-next/src/control-plane/sandbox/frame-env.test.ts new file mode 100644 index 00000000000..d4503dbd276 --- /dev/null +++ b/services/cloud-agent-next/src/control-plane/sandbox/frame-env.test.ts @@ -0,0 +1,121 @@ +import { generateKeyPairSync } from 'node:crypto'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { encryptWithPublicKey } from '../../utils/encryption.js'; +import { buildFrameEnv } from './frame-env.js'; + +let publicKey: string; +let privateKey: string; + +beforeAll(() => { + ({ publicKey, privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + })); +}); + +function secret(value: string) { + return encryptWithPublicKey(value, publicKey); +} + +describe('buildFrameEnv', () => { + it('returns the same env and no secret keys when there are no secrets', () => { + const specEnv = { PLAIN: 'value', KILOCODE_TOKEN: 'grant-token' }; + const result = buildFrameEnv({ specEnv, encryptedSecrets: undefined, privateKey }); + expect(result.env).toEqual(specEnv); + expect(result.secretEnvKeys).toEqual([]); + }); + + it('overlays a secret over plaintext of the same key', () => { + const result = buildFrameEnv({ + specEnv: { DATABASE_URL: 'plaintext', KEEP: 'kept' }, + encryptedSecrets: { DATABASE_URL: secret('encrypted-value') }, + privateKey, + }); + expect(result.env).toEqual({ DATABASE_URL: 'encrypted-value', KEEP: 'kept' }); + expect(result.secretEnvKeys).toEqual(['DATABASE_URL']); + }); + + it.each([ + 'KILOCODE_TOKEN', + 'GH_TOKEN', + 'GITHUB_TOKEN', + 'GITLAB_TOKEN', + 'GLAB_IS_OAUTH2', + 'GITLAB_HOST', + 'GITLAB_SUBFOLDER', + 'BITBUCKET_TOKEN', + 'KILO_BITBUCKET_WORKSPACE_SLUG', + 'KILO_BITBUCKET_REPOSITORY_SLUG', + 'KILO_BITBUCKET_WORKSPACE_UUID', + 'KILO_BITBUCKET_REPOSITORY_UUID', + ])('restores the issued %s the spec already carried over a same-named secret', key => { + const result = buildFrameEnv({ + specEnv: { [key]: 'grant-value' }, + encryptedSecrets: { [key]: secret('secret-value') }, + privateKey, + }); + expect(result.env?.[key]).toBe('grant-value'); + // The grant value survived, not the decrypted secret, so nothing to redact. + expect(result.secretEnvKeys).toEqual([]); + }); + + it('keeps a plaintext GH_TOKEN the grant left in place when a different secret overlays', () => { + const result = buildFrameEnv({ + specEnv: { GH_TOKEN: 'grant-gh', GITHUB_TOKEN: 'grant-gh' }, + encryptedSecrets: { DATABASE_URL: secret('db') }, + privateKey, + }); + expect(result.env?.GH_TOKEN).toBe('grant-gh'); + expect(result.env?.GITHUB_TOKEN).toBe('grant-gh'); + expect(result.secretEnvKeys).toEqual(['DATABASE_URL']); + }); + + it('deletes an org id a secret added when the uncontained spec did not carry it', () => { + const result = buildFrameEnv({ + specEnv: {}, + encryptedSecrets: { KILOCODE_ORGANIZATION_ID: secret('org-from-secret') }, + privateKey, + }); + expect(Object.hasOwn(result.env ?? {}, 'KILOCODE_ORGANIZATION_ID')).toBe(false); + expect(result.secretEnvKeys).toEqual([]); + }); + + it('keeps a contained spec org id, including a value the grant did not touch', () => { + const result = buildFrameEnv({ + specEnv: { KILOCODE_ORGANIZATION_ID: 'org-from-grant' }, + encryptedSecrets: { KILOCODE_ORGANIZATION_ID: secret('org-from-secret') }, + privateKey, + }); + expect(result.env?.KILOCODE_ORGANIZATION_ID).toBe('org-from-grant'); + }); + + it('deletes a control-runtime name a secret added when the spec did not carry it', () => { + const result = buildFrameEnv({ + specEnv: {}, + encryptedSecrets: { SANDBOX_CONTROL_CREDENTIAL: secret('forged') }, + privateKey, + }); + expect(Object.hasOwn(result.env ?? {}, 'SANDBOX_CONTROL_CREDENTIAL')).toBe(false); + expect(result.secretEnvKeys).toEqual([]); + }); + + it('keeps a secret name the grant did not issue and the spec did not carry', () => { + const result = buildFrameEnv({ + specEnv: {}, + encryptedSecrets: { GH_TOKEN: secret('user-gh') }, + privateKey, + }); + expect(result.env?.GH_TOKEN).toBe('user-gh'); + }); + + it('throws when secrets are present and no private key is configured', () => { + expect(() => + buildFrameEnv({ + specEnv: {}, + encryptedSecrets: { DATABASE_URL: secret('value') }, + privateKey: undefined, + }) + ).toThrow(/Private key is required/); + }); +}); diff --git a/services/cloud-agent-next/src/control-plane/sandbox/frame-env.ts b/services/cloud-agent-next/src/control-plane/sandbox/frame-env.ts new file mode 100644 index 00000000000..366e8e7f039 --- /dev/null +++ b/services/cloud-agent-next/src/control-plane/sandbox/frame-env.ts @@ -0,0 +1,64 @@ +import { CONTROL_RUNTIME_RESERVED_ENV_VARS } from '../../shared/runtime-environment.js'; +import { ISSUED_CREDENTIAL_ENV_KEYS } from '../../sandbox-control/session-credentials.js'; +import { mergeEnvVarsWithSecrets, type EncryptedSecrets } from '../../utils/encryption.js'; + +/** + * Names a secret must never reintroduce when the pre-overlay spec did not carry + * them: the grant clears the org id (`!contained`) and the control-runtime names + * belong to the platform. SCM names are deliberately excluded — a plaintext var + * of the same name survives today, so a secret may set one the grant did not + * issue. + */ +const DELETE_WHEN_UNOWNED_ENV_KEYS: readonly string[] = [ + 'KILOCODE_ORGANIZATION_ID', + ...CONTROL_RUNTIME_RESERVED_ENV_VARS, +]; + +export type FrameEnvInput = { + /** The grant-projected route spec env, before the secret overlay. */ + specEnv: Record | undefined; + /** The DO-private encrypted-secrets snapshot, opaque to the shared schema. */ + encryptedSecrets: Record | undefined; + privateKey: string | undefined; +}; + +export type FrameEnvResult = { + /** The merged env; the same object when there are no secrets. */ + env: Record | undefined; + /** Names the wrapper should redact: only keys whose value is the decrypted secret. */ + secretEnvKeys: string[]; +}; + +/** + * Copies `specEnv`, overlays the decrypted profile secrets, then restores the + * credential keys the grant already projected. Decryption throws when the + * snapshot is non-empty and no private key is configured. Presence is + * `Object.hasOwn`, not truthiness, so a key projected with an empty string is + * still restored. The merged env is returned, never written back to the route. + */ +export function buildFrameEnv(input: FrameEnvInput): FrameEnvResult { + const encrypted = input.encryptedSecrets; + if (encrypted === undefined || Object.keys(encrypted).length === 0) { + return { env: input.specEnv, secretEnvKeys: [] }; + } + const preOverlay = input.specEnv ?? {}; + const merged = mergeEnvVarsWithSecrets( + preOverlay, + encrypted as EncryptedSecrets, + input.privateKey + ); + const restored = new Set(); + for (const key of ISSUED_CREDENTIAL_ENV_KEYS) { + if (Object.hasOwn(preOverlay, key)) { + merged[key] = preOverlay[key]; + restored.add(key); + } + } + for (const key of DELETE_WHEN_UNOWNED_ENV_KEYS) { + if (!Object.hasOwn(preOverlay, key) && Object.hasOwn(encrypted, key)) delete merged[key]; + } + const secretEnvKeys = Object.keys(encrypted).filter( + key => Object.hasOwn(merged, key) && !restored.has(key) + ); + return { env: merged, secretEnvKeys }; +} diff --git a/services/cloud-agent-next/src/control-plane/sandbox/repo-key.test.ts b/services/cloud-agent-next/src/control-plane/sandbox/repo-key.test.ts index 58b0a79b55e..b4eb004c869 100644 --- a/services/cloud-agent-next/src/control-plane/sandbox/repo-key.test.ts +++ b/services/cloud-agent-next/src/control-plane/sandbox/repo-key.test.ts @@ -54,6 +54,15 @@ describe('repoSnapshotEligible', () => { }) ).toBe(false); }); + + it('excludes a route with setup commands and treats empty or absent the same', () => { + const enrolled = gate({ enrolledIds: '*' }); + expect(repoSnapshotEligible(enrolled, { ...route, setupCommands: ['pnpm install'] })).toBe( + false + ); + expect(repoSnapshotEligible(enrolled, { ...route, setupCommands: [] })).toBe(true); + expect(repoSnapshotEligible(enrolled, route)).toBe(true); + }); }); describe('repoSnapshotEligibility', () => { diff --git a/services/cloud-agent-next/src/control-plane/sandbox/repo-key.ts b/services/cloud-agent-next/src/control-plane/sandbox/repo-key.ts index 236a4b24802..223083d985c 100644 --- a/services/cloud-agent-next/src/control-plane/sandbox/repo-key.ts +++ b/services/cloud-agent-next/src/control-plane/sandbox/repo-key.ts @@ -22,7 +22,8 @@ export type RepoSnapshotGate = { export type RepoSnapshotIneligibilityReason = | 'not_enrolled' | 'no_repo_url' - | 'directory_not_isolated'; + | 'directory_not_isolated' + | 'has_setup_commands'; export type RepoSnapshotEligibility = | { eligible: true } @@ -37,15 +38,20 @@ export type RepoSnapshotEligibility = * enrolled, and the route must have a repository at the constant path the * snapshots are taken at. A snapshot holds one repository at one path, so a * per-session path or a shared sandbox could only capture something no later - * session can reuse. + * session can reuse. A route with any setup command is ineligible: capture runs + * after setup, so the snapshot would carry setup output under a key that names + * only the user and the repository. * * Enrollment follows the `CONTROL_PLANE_IDS`/`SANDBOX_SELECTION_IDS` convention: * one list matched against the user ID and then the org ID. */ export function repoSnapshotEligibility( gate: RepoSnapshotGate, - route: Pick + route: Pick & { setupCommands?: string[] } ): RepoSnapshotEligibility { + if ((route.setupCommands?.length ?? 0) > 0) { + return { eligible: false, reason: 'has_setup_commands' }; + } if (!(isOrgInList(gate.enrolledIds, gate.userId) || isOrgInList(gate.enrolledIds, gate.orgId))) { return { eligible: false, reason: 'not_enrolled' }; } @@ -58,16 +64,18 @@ export function repoSnapshotEligibility( export function repoSnapshotEligible( gate: RepoSnapshotGate, - route: Pick + route: Pick & { setupCommands?: string[] } ): boolean { return repoSnapshotEligibility(gate, route).eligible; } /** * The keyed hash that names a repository snapshot: scope and repository. Env is - * not part of it: setup re-runs on every start and rewrites what it derives from - * the env. It is hashed under a Worker secret so the digest cannot be guessed to - * address another user's snapshot. + * not part of it on purpose: setup re-runs on every start and rewrites what it + * derives from the env. Setup-command output is kept out by not computing a key + * for a route that has setup commands, rather than by narrowing the hash. It is + * hashed under a Worker secret so the digest cannot be guessed to address + * another user's snapshot. */ export async function computeRepoKey(input: RepoKeyInput): Promise { if (input.secret === null || input.repoUrl === undefined) return null; diff --git a/services/cloud-agent-next/src/control-plane/sandbox/sandbox-do.ts b/services/cloud-agent-next/src/control-plane/sandbox/sandbox-do.ts index e368cbd1098..1b62ea9541c 100644 --- a/services/cloud-agent-next/src/control-plane/sandbox/sandbox-do.ts +++ b/services/cloud-agent-next/src/control-plane/sandbox/sandbox-do.ts @@ -120,6 +120,7 @@ import { controlPlaneAnswerPayloadSchema, controlPlaneDeliverPayloadSchema, controlPlanePrepareInputSchema, + controlPlaneRouteSpecSchema, controlPlaneSessionCredentialsPayloadSchema, controlPlaneSessionRefPayloadSchema, controlPlaneTerminalInputSchema, @@ -200,6 +201,7 @@ import { writeScopeGrant, } from './scope-grants.js'; import { computeRepoKey, repoSnapshotEligibility } from './repo-key.js'; +import { buildFrameEnv } from './frame-env.js'; import { beginRepositoryLaunch, captureRequested, @@ -259,9 +261,35 @@ const wrapperSocketAttachmentSchema = z.object({ connectionId: z.string().optional(), wrapperId: z.string().optional(), heartbeatAck: z.literal(true).optional(), + redactsNamedSecrets: z.literal(true).optional(), }); type WrapperSocketAttachment = z.infer; +/** Separates an old wrapper from a decrypt/parse failure; both fail the attempt. */ +type PrepareFrameMaterializationStage = + | 'wrapper_redaction_unsupported' + | 'frame_materialization_failed'; + +class PrepareFrameMaterializationError extends Error { + constructor( + readonly stage: PrepareFrameMaterializationStage, + readonly sourceErrorName?: string + ) { + super('session.prepare frame materialization failed'); + this.name = 'PrepareFrameMaterializationError'; + } +} + +function errorClassName(error: unknown): string { + return error instanceof Error ? error.name : 'unknown'; +} + +function materializationStage(error: unknown): PrepareFrameMaterializationStage { + return error instanceof PrepareFrameMaterializationError + ? error.stage + : 'frame_materialization_failed'; +} + /** * The Sandbox DO -> Session DO notification surface (spec §10). The V2 Session * DO is B4, so the production factory resolves a stub only when the test-only @@ -1774,7 +1802,8 @@ export class SandboxControlV2 extends DurableObject { /** * The repository snapshot key for a route, or null when none applies. It is hashed * from the input spec: the grant rewrites `spec.env` with per-attempt credential - * aliases, so the stored spec would give every session its own key. + * aliases, so the stored spec would give every session its own key. A route with + * setup commands has no key, so it neither restores nor captures a snapshot. * * Emits one `snapshot_key` diagnostic per route so a missing snapshot can be * traced to the gate that rejected it (or to a missing key secret). @@ -1793,7 +1822,10 @@ export class SandboxControlV2 extends DurableObject { userId: source.userId, orgId: source.orgId, }; - const eligibility = repoSnapshotEligibility(gate, route); + const eligibility = repoSnapshotEligibility(gate, { + ...route, + setupCommands: spec.setupCommands, + }); if (!eligibility.eligible) { this.logSnapshotKey(spec.sessionId, false, eligibility.reason); return null; @@ -1940,6 +1972,9 @@ export class SandboxControlV2 extends DurableObject { directory: payload.directory ?? spec.directory, ...(spec.branch === undefined ? {} : { branch: spec.branch }), ...(spec.branchMode === undefined ? {} : { branchMode: spec.branchMode }), + ...(spec.runtimeSkills === undefined ? {} : { runtimeSkills: spec.runtimeSkills }), + ...(spec.runtimeAgents === undefined ? {} : { runtimeAgents: spec.runtimeAgents }), + ...(spec.kiloCommands === undefined ? {} : { kiloCommands: spec.kiloCommands }), env: payload.env ?? {}, ...(payload.setupCommands === undefined ? {} : { setupCommands: payload.setupCommands }), ...(spec.runtimeIsolation === undefined ? {} : { runtimeIsolation: spec.runtimeIsolation }), @@ -2361,61 +2396,123 @@ export class SandboxControlV2 extends DurableObject { // A route without a grant never reaches `preparing` (issuance failure marks // it `failed`), so never send a spec that could carry raw issuer material. if (route.grant === null) return; - let mcp: ControlPlaneRouteSpec['mcp']; + if (route.grant.kilo.runtimeProxy !== undefined) { + // R1: minting the runtime credential proxy handle is a Session DO call that + // must stay off this DO's serial queue; the frame is sent once the handle + // is minted and bound. A mint failure fails the attempt closed. + this.ctx.waitUntil(this.sendSessionPrepareWithRuntimeProxy(route.sessionId, route.attemptId)); + return; + } + let spec: ControlPlaneRouteSpec; try { - mcp = this.materializeRouteMcp(route); + spec = this.materializePrepareFrame(route, this.wrapperRedactsNamedSecrets(socket)); } catch (error) { - // Preserve the sanitized, bounded MCP reason for diagnosis; the route - // contract has no MCP-specific reason, so the attempt fails immediately as - // `workspace_setup_failed` rather than waiting out its deadline. - logger - .withFields({ - sandboxId: this.sandboxId, - reason: error instanceof McpAttachValidationError ? error.reason : 'unknown', - }) - .warn('MCP materialization failed before session.prepare'); + this.logPrepareFrameMaterializationFailure(route, error); this.ctx.waitUntil( - this.failPrepareAttempt(route.sessionId, route.attemptId).catch(() => { + this.failPrepareAttempt( + route.sessionId, + route.attemptId, + materializationStage(error) + ).catch(() => { logger .withFields({ sandboxId: this.sandboxId, sessionId: route.sessionId, attemptId: route.attemptId, }) - .warn('Could not persist MCP preparation failure'); + .warn('Could not persist frame materialization failure'); }) ); return; } - if (route.grant.kilo.runtimeProxy !== undefined) { - // R1: minting the runtime credential proxy handle is a Session DO call that - // must stay off this DO's serial queue; the frame is sent once the handle - // is minted and bound. A mint failure fails the attempt closed. - this.ctx.waitUntil( - this.sendSessionPrepareWithRuntimeProxy(route.sessionId, route.attemptId, mcp) - ); - return; - } // The frame carries the wrapper-safe spec plus the issued credential // material only; the credential source stays DO-private. this.trySendFrame(socket, { type: 'session.prepare', - spec: this.prepareFrameSpec(route, mcp), + spec, credentials: this.prepareCredentials(route.grant, route.sessionId), }); } - /** The route spec plus what only a frame carries: materialized MCP and the capture request. */ - private prepareFrameSpec( + /** Whether the bound wrapper advertised that it redacts named secret keys. */ + private wrapperRedactsNamedSecrets(socket: WebSocket): boolean { + return this.readAttachment(socket)?.redactsNamedSecrets === true; + } + + /** + * A failure throws rather than sending a frame the wrapper would drop while the + * route waits out its preparation deadline. + */ + private materializePrepareFrame( route: RouteRecord, - mcp: ControlPlaneRouteSpec['mcp'] + redactsNamedSecrets: boolean ): ControlPlaneRouteSpec { - return { - ...route.spec, - createdOnPlatform: route.credentialSource?.createdOnPlatform, - ...(mcp === undefined ? {} : { mcp }), - ...(captureRequested(route, this.provider) ? { capture: true as const } : {}), - }; + const encryptedSecrets = route.credentialSource?.encryptedSecrets; + try { + const frameEnv = buildFrameEnv({ + specEnv: route.spec.env, + encryptedSecrets, + privateKey: this.env.AGENT_ENV_VARS_PRIVATE_KEY, + }); + // Fail closed only when a secret value would actually reach the frame and + // the wrapper cannot redact it by name; a snapshot whose keys are all + // restored or dropped sends nothing and needs no redaction. + if (frameEnv.secretEnvKeys.length > 0 && !redactsNamedSecrets) { + throw new PrepareFrameMaterializationError('wrapper_redaction_unsupported'); + } + const mcp = this.materializeRouteMcp(route); + if (frameEnv.secretEnvKeys.length > 0) { + logger + .withFields({ + sandboxId: this.sandboxId, + sessionId: route.sessionId, + secretCount: frameEnv.secretEnvKeys.length, + }) + .info('Materialized session.prepare secrets'); + } + return controlPlaneRouteSpecSchema.parse({ + ...route.spec, + createdOnPlatform: route.credentialSource?.createdOnPlatform, + ...(frameEnv.env === undefined ? {} : { env: frameEnv.env }), + ...(mcp === undefined ? {} : { mcp }), + ...(frameEnv.secretEnvKeys.length === 0 ? {} : { secretEnvKeys: frameEnv.secretEnvKeys }), + ...(captureRequested(route, this.provider) ? { capture: true as const } : {}), + }); + } catch (error) { + if (error instanceof PrepareFrameMaterializationError) throw error; + // Preserve the sanitized, bounded MCP reason for diagnosis. + if (error instanceof McpAttachValidationError) { + logger + .withFields({ + sandboxId: this.sandboxId, + sessionId: route.sessionId, + reason: error.reason, + }) + .warn('MCP materialization failed before session.prepare'); + } + throw new PrepareFrameMaterializationError( + 'frame_materialization_failed', + errorClassName(error) + ); + } + } + + /** One sanitized failure line: stage, error class and secret count only. */ + private logPrepareFrameMaterializationFailure(route: RouteRecord, error: unknown): void { + const encrypted = route.credentialSource?.encryptedSecrets; + logger + .withFields({ + sandboxId: this.sandboxId, + sessionId: route.sessionId, + attemptId: route.attemptId, + stage: materializationStage(error), + secretCount: encrypted === undefined ? 0 : Object.keys(encrypted).length, + errorClass: + error instanceof PrepareFrameMaterializationError + ? (error.sourceErrorName ?? error.name) + : errorClassName(error), + }) + .warn('session.prepare frame materialization failed'); } private prepareCredentials( @@ -2435,8 +2532,7 @@ export class SandboxControlV2 extends DurableObject { */ private async sendSessionPrepareWithRuntimeProxy( sessionId: string, - attemptId: string, - mcp: ControlPlaneRouteSpec['mcp'] + attemptId: string ): Promise { try { let route = await readRoute(this.db, sessionId); @@ -2513,11 +2609,21 @@ export class SandboxControlV2 extends DurableObject { const sendFence = this.runtimeProxyFenceFor(state); if (sendFence === null || !sameRuntimeProxyControlBinding(mintedFence, sendFence)) return; } + // Materialize against the re-read route, after the fence checks, so a + // failure never sends a frame and never fails a newer attempt. + let spec: ControlPlaneRouteSpec; + try { + spec = this.materializePrepareFrame(route, this.wrapperRedactsNamedSecrets(socket)); + } catch (error) { + this.logPrepareFrameMaterializationFailure(route, error); + await this.failPrepareAttempt(sessionId, attemptId, materializationStage(error)); + return; + } // The frame carries the wrapper-safe spec plus the issued credential // material only; the credential source stays DO-private. this.trySendFrame(socket, { type: 'session.prepare', - spec: this.prepareFrameSpec(route, mcp), + spec, credentials: this.prepareCredentials(route.grant, sessionId), }); } catch { @@ -2569,7 +2675,11 @@ export class SandboxControlV2 extends DurableObject { } /** Fail one preparation attempt, fenced by attempt id (late mint failure). */ - private async failPrepareAttempt(sessionId: string, attemptId: string): Promise { + private async failPrepareAttempt( + sessionId: string, + attemptId: string, + stage = 'prepare_dispatch_failed' + ): Promise { await this.enqueue(async () => { const state = await this.readAllocation(); await failCurrentAttempt( @@ -2577,7 +2687,7 @@ export class SandboxControlV2 extends DurableObject { sessionId, attemptId, 'workspace_setup_failed', - 'prepare_dispatch_failed' + stage ); }); } @@ -3151,6 +3261,7 @@ export class SandboxControlV2 extends DurableObject { connectionId, wrapperId: frame.wrapperId, ...(frame.heartbeatAck ? { heartbeatAck: true } : {}), + ...(frame.redactsNamedSecrets ? { redactsNamedSecrets: true } : {}), }); // Welcome first, then route effects: a re-prepared route resends // `session.prepare`, which must not arrive before the welcome. diff --git a/services/cloud-agent-next/src/control-plane/session/registration.test.ts b/services/cloud-agent-next/src/control-plane/session/registration.test.ts index 0112981f28a..7e7de475afe 100644 --- a/services/cloud-agent-next/src/control-plane/session/registration.test.ts +++ b/services/cloud-agent-next/src/control-plane/session/registration.test.ts @@ -3,6 +3,7 @@ import jwt from 'jsonwebtoken'; import { beforeAll, describe, expect, it } from 'vitest'; import { CurrentSessionMetadataSchema } from '../../persistence/session-metadata.js'; import { + controlPlaneCredentialSourceSchema, controlPlanePrepareInputSchema, controlPlaneRegistrationRouteSpecSchema, controlPlaneRouteSpecSchema, @@ -276,6 +277,92 @@ describe('buildControlPlaneSessionRegistration MCP materialization', () => { }); }); +describe('buildControlPlaneSessionRegistration encrypted secrets', () => { + it('forces per-session isolation for a secret-bearing spec and keeps secrets out of env', () => { + const envelope = encryptWithPublicKey('secret-value', publicKey); + const registration = buildControlPlaneSessionRegistration( + metadata({ profile: { encryptedSecrets: { DATABASE_URL: envelope } } }), + selection + ); + + expect(registration.credentials.encryptedSecrets).toEqual({ DATABASE_URL: envelope }); + // Secrets never enter the persisted spec env, and a secret-bearing spec owns + // its Kilo runtime so a sibling in the same directory cannot reuse its env. + expect(registration.spec.env?.DATABASE_URL).toBeUndefined(); + expect(registration.spec.runtimeIsolation).toBe('per-session'); + expect(JSON.stringify(registration)).not.toContain('secret-value'); + }); + + it('withholds profile env and secrets from a read-only Bitbucket review', () => { + const envelope = encryptWithPublicKey('secret-value', publicKey); + const registration = buildControlPlaneSessionRegistration( + metadata({ + identity: { + sessionId: 'workspace_12345678-1234-1234-1234-123456789abc', + userId: 'usr_1', + createdOnPlatform: 'code-review', + }, + repository: { + type: 'bitbucket', + url: 'https://bitbucket.org/acme/widgets', + workspaceUuid: '11111111-1111-4111-8111-111111111111', + repositoryUuid: '22222222-2222-4222-8222-222222222222', + }, + callback: { + target: { url: 'https://worker.test/api/internal/code-review-status/12345' }, + }, + profile: { + envVars: { USER_SETTING: 'private-profile-env' }, + encryptedSecrets: { DATABASE_URL: envelope }, + runtimeSkills: [{ name: 'review', rawMarkdown: 'Review' }], + runtimeAgents: [{ slug: 'reviewer', name: 'Reviewer', config: {} }], + kiloCommands: [{ name: 'review-now', template: 'Review' }], + }, + }), + selection + ); + + expect(registration.spec.env?.USER_SETTING).toBeUndefined(); + expect(registration.credentials.kiloToken).toBe('native-kilo-token'); + expect(registration.credentials.encryptedSecrets).toBeUndefined(); + expect(registration.spec.runtimeSkills).toBeUndefined(); + expect(registration.spec.runtimeAgents).toBeUndefined(); + expect(registration.spec.kiloCommands).toBeUndefined(); + expect(registration.spec.runtimeIsolation).toBeUndefined(); + }); + + it('accepts a stored credential source that predates the encryptedSecrets field', () => { + const parsed = controlPlaneCredentialSourceSchema.safeParse({ + userId: 'usr_1', + kiloSessionId: 'ses_12345678901234567890123456', + kiloToken: 'native-kilo-token', + scopeId: 'usr_1', + }); + expect(parsed.success).toBe(true); + }); + + it('carries secretEnvKeys on the frame-only spec and rejects it on the prepare input', () => { + const spec = { + sessionId: 'workspace_12345678-1234-1234-1234-123456789abc', + kiloSessionId: 'ses_12345678901234567890123456', + directory: '/tmp/worktree', + attemptId: 'attempt-1', + secretEnvKeys: ['DATABASE_URL'], + }; + expect(controlPlaneRouteSpecSchema.safeParse(spec).success).toBe(true); + expect( + controlPlanePrepareInputSchema.safeParse({ + spec, + credentials: { + userId: 'usr_1', + kiloSessionId: 'ses_12345678901234567890123456', + kiloToken: 'native-kilo-token', + }, + }).success + ).toBe(false); + }); +}); + describe('controlPlaneRouteSpecSchema MCP boundaries', () => { function spec(mcp: unknown): unknown { return { @@ -509,3 +596,85 @@ describe('buildControlPlaneSessionRegistration session directory', () => { expect(secondInput.workspace?.workspacePath).toBe(worktreePath); }); }); + +describe('profile runtime delivery', () => { + const profile = { + runtimeSkills: [ + { + name: 'review', + rawMarkdown: '---\nname: review\ndescription: Review\n---\nReview changes', + files: { 'scripts/check.sh': 'echo checked' }, + }, + ], + runtimeAgents: [ + { + slug: 'reviewer', + name: 'Reviewer', + config: { prompt: 'Review the diff', model: 'test-model' }, + }, + ], + kiloCommands: [{ name: 'review-now', template: 'Review $ARGUMENTS', agent: 'reviewer' }], + }; + + it.each(['runtimeSkills', 'runtimeAgents', 'kiloCommands'] as const)( + 'delivers %s and isolates its runtime even without modern auth', + key => { + const registration = buildControlPlaneSessionRegistration( + metadata({ profile: { [key]: profile[key] } }), + selection + ); + expect(registration.spec[key]).toEqual(profile[key]); + expect(registration.spec.runtimeIsolation).toBe('per-session'); + expect( + controlPlanePrepareInputSchema.parse({ + spec: registration.spec, + credentials: registration.credentials, + }).spec[key] + ).toEqual(profile[key]); + } + ); + + it('isolates a profile with only plain environment variables', () => { + const registration = buildControlPlaneSessionRegistration( + metadata({ profile: { envVars: { PROJECT: 'demo' } } }), + selection + ); + expect(registration.spec.env).toEqual({ PROJECT: 'demo' }); + expect(registration.spec.runtimeIsolation).toBe('per-session'); + }); + + it('delivers the full runtime profile without changing setup commands or env', () => { + const registration = buildControlPlaneSessionRegistration( + metadata({ + profile: { ...profile, envVars: { PROJECT: 'demo' }, setupCommands: ['echo setup'] }, + }), + selection + ); + expect(registration.spec).toMatchObject({ + ...profile, + env: { PROJECT: 'demo' }, + setupCommands: ['echo setup'], + runtimeIsolation: 'per-session', + }); + }); + + it('rejects a runtime profile without per-session isolation', () => { + const registration = buildControlPlaneSessionRegistration(metadata({ profile }), selection); + expect( + controlPlaneRouteSpecSchema.safeParse({ ...registration.spec, runtimeIsolation: undefined }) + .success + ).toBe(false); + }); + + it('rejects unsafe companion paths at the wrapper protocol boundary', () => { + const registration = buildControlPlaneSessionRegistration(metadata({ profile }), selection); + expect( + controlPlaneRouteSpecSchema.safeParse({ + ...registration.spec, + runtimeSkills: [ + { name: 'review', rawMarkdown: 'Review', files: { '../escape': 'unsafe' } }, + ], + }).success + ).toBe(false); + }); +}); diff --git a/services/cloud-agent-next/src/control-plane/session/registration.ts b/services/cloud-agent-next/src/control-plane/session/registration.ts index eb25a0d70f5..941433e84cc 100644 --- a/services/cloud-agent-next/src/control-plane/session/registration.ts +++ b/services/cloud-agent-next/src/control-plane/session/registration.ts @@ -1,3 +1,4 @@ +import { readProfileBundle } from '../../session-profile.js'; import { z } from 'zod'; import type { SessionAttachPayload } from '../../shared/sandbox-control-protocol.js'; import { @@ -11,7 +12,10 @@ import { type ControlPlaneSandboxSelection, } from './sandbox-selection.js'; import { credentialSourceFromMetadata } from '../../sandbox-control/session-credentials.js'; -import { buildSessionAttachPayload } from '../../sandbox-session/attach-payload.js'; +import { + buildSessionAttachPayload, + isReadOnlyBitbucketReview, +} from '../../sandbox-session/attach-payload.js'; import { McpAttachValidationError, McpConfigurationError, @@ -212,13 +216,33 @@ export function buildControlPlaneSessionRegistration( const validated = parseSessionAttachMcpServers(payload.mcp); if (!validated.success) throw new McpAttachValidationError(validated.reason); } - const spec = controlPlaneRouteSpecSchema.parse( - controlPlaneRouteSpecFromAttachPayload( + // A read-only Bitbucket review runs with worker-owned env only, so its + // encrypted profile secrets are withheld, mirroring the legacy plane and the + // withheld MCP snapshot. + const encryptedSecrets = isReadOnlyBitbucketReview(metadata) + ? undefined + : metadata.profile?.encryptedSecrets; + const hasEncryptedSecrets = + encryptedSecrets !== undefined && Object.keys(encryptedSecrets).length > 0; + const profile = isReadOnlyBitbucketReview(metadata) ? {} : readProfileBundle(metadata); + const runtimeProfile = { + ...(profile.runtimeSkills?.length ? { runtimeSkills: profile.runtimeSkills } : {}), + ...(profile.runtimeAgents?.length ? { runtimeAgents: profile.runtimeAgents } : {}), + ...(profile.kiloCommands?.length ? { kiloCommands: profile.kiloCommands } : {}), + }; + const spec = controlPlaneRouteSpecSchema.parse({ + ...runtimeProfile, + ...controlPlaneRouteSpecFromAttachPayload( payload, { sessionId: metadata.identity.sessionId, kiloSessionId }, - hasModernRuntimeAuthorization(metadata) ? { runtimeIsolation: 'per-session' } : undefined - ) - ); + hasModernRuntimeAuthorization(metadata) || + hasEncryptedSecrets || + Object.keys(payload.env ?? {}).some(key => key !== 'KILOCODE_TOKEN') || + Object.keys(runtimeProfile).length > 0 + ? { runtimeIsolation: 'per-session' } + : undefined + ), + }); // `payload.mcp` is present only when materialization was not omitted for a // read-only Bitbucket review, so the encrypted snapshot is gated on it too. const encryptedMcpServers = payload.mcp === undefined ? undefined : metadata.profile?.mcpServers; @@ -226,6 +250,7 @@ export function buildControlPlaneSessionRegistration( ...credentialSourceFromMetadata(metadata), scopeId: metadata.workspace?.worktreeId ?? metadata.identity.sessionId, ...(encryptedMcpServers === undefined ? {} : { mcpServers: encryptedMcpServers }), + ...(encryptedSecrets === undefined ? {} : { encryptedSecrets }), }; return { sandboxId, spec, credentials, sandboxSelection: { ...sandboxSelection, containment } }; } diff --git a/services/cloud-agent-next/src/persistence/model-utils.ts b/services/cloud-agent-next/src/persistence/model-utils.ts index 38dfbf918d0..7bc2adad89e 100644 --- a/services/cloud-agent-next/src/persistence/model-utils.ts +++ b/services/cloud-agent-next/src/persistence/model-utils.ts @@ -1,16 +1 @@ -export const LOCAL_FAKE_DETERMINISTIC_MODEL_ID = 'fake-deterministic'; - -export function normalizeKilocodeModel(model: string | undefined | null): string | undefined { - if (!model) return undefined; - const trimmed = model.trim(); - if (!trimmed) return undefined; - return trimmed.startsWith('kilo/') ? trimmed : `kilo/${trimmed}`; -} - -export function dispatchedKilocodeModelId(model: string | undefined | null): string | undefined { - return normalizeKilocodeModel(model)?.replace(/^kilo\//, ''); -} - -export function isLocalFakeDeterministicModel(model: string | undefined | null): boolean { - return dispatchedKilocodeModelId(model) === LOCAL_FAKE_DETERMINISTIC_MODEL_ID; -} +export * from '../shared/model-utils.js'; diff --git a/services/cloud-agent-next/src/persistence/schemas.ts b/services/cloud-agent-next/src/persistence/schemas.ts index b2005d45b04..d283f4f74af 100644 --- a/services/cloud-agent-next/src/persistence/schemas.ts +++ b/services/cloud-agent-next/src/persistence/schemas.ts @@ -1,6 +1,12 @@ +import { + RuntimeSkillsSchema, + RuntimeAgentsSchema, + RuntimeKiloCommandsSchema, +} from '../shared/runtime-profile.js'; +export * from '../shared/runtime-profile.js'; import * as z from 'zod'; import { MESSAGE_ID_FORMAT_DESCRIPTION, MESSAGE_ID_PATTERN } from '../session/message-id.js'; -import { BUILTIN_AGENT_MODES, Limits } from '../schema.js'; +import { Limits } from '../schema.js'; import { isValidSandboxId } from '../sandbox-id.js'; import type { SandboxId } from '../types.js'; @@ -220,151 +226,6 @@ export const MCPServerConfigSchema = z.discriminatedUnion('type', [ MCPRemoteServerConfigSchema, ]); -const SKILL_FILE_PATH_PATTERN = /^[a-zA-Z0-9._\-/]+$/; - -/** Validate a map of companion files bundled with a skill. */ -const RuntimeSkillFilesSchema = z - .record( - z.string().min(1).max(Limits.MAX_RUNTIME_SKILL_COMPANION_PATH_LENGTH), - z.string().max(Limits.MAX_RUNTIME_SKILL_COMPANION_FILE_SIZE) - ) - .refine( - files => Object.keys(files).length <= Limits.MAX_RUNTIME_SKILL_COMPANION_FILES, - `A skill may have at most ${Limits.MAX_RUNTIME_SKILL_COMPANION_FILES} companion files` - ) - .superRefine((files, ctx) => { - let total = 0; - for (const [path, content] of Object.entries(files)) { - if (!SKILL_FILE_PATH_PATTERN.test(path)) { - ctx.addIssue({ code: 'custom', message: `Skill file path rejected: ${path}` }); - return; - } - if (path.startsWith('/') || path.includes('..') || path.includes('//')) { - ctx.addIssue({ code: 'custom', message: `Skill file path rejected: ${path}` }); - return; - } - if (path === 'SKILL.md' || path.toLowerCase() === 'skill.md') { - ctx.addIssue({ - code: 'custom', - message: 'SKILL.md must be passed as rawMarkdown, not inside files', - }); - return; - } - total += content.length; - } - if (total > Limits.MAX_RUNTIME_SKILL_COMPANION_FILES_TOTAL) { - ctx.addIssue({ - code: 'custom', - message: `Skill companion files total ${total} bytes, exceeds ${Limits.MAX_RUNTIME_SKILL_COMPANION_FILES_TOTAL}`, - }); - } - }); - -/** - * Runtime skill schema. Each entry is materialized to - * `${SESSION_HOME}/.kilocode/skills//` at preparation time — `rawMarkdown` - * is written to `SKILL.md`, and each `files[path]` is written under the same - * directory. - */ -export const RuntimeSkillSchema = z.object({ - name: z - .string() - .min(1) - .max(Limits.MAX_RUNTIME_SKILL_NAME_LENGTH) - .regex(/^[a-z0-9][a-z0-9-]*$/, 'Skill name must be a slug'), - rawMarkdown: z.string().min(1).max(Limits.MAX_RUNTIME_SKILL_MARKDOWN), - files: RuntimeSkillFilesSchema.optional(), -}); - -export const RuntimeSkillsSchema = z - .array(RuntimeSkillSchema) - .max(Limits.MAX_RUNTIME_SKILLS, `Maximum ${Limits.MAX_RUNTIME_SKILLS} runtime skills allowed`); - -export type RuntimeSkillInput = z.infer; - -// --- Runtime agents --- - -const PermissionActionSchema = z.enum(['allow', 'ask', 'deny']); -// Flat permissive shape — the runtime tolerates any shape the CLI accepts -// (bare action string, per-tool map with per-pattern maps, null sentinels). -// Schema-level typing kept loose so the zod inference used by MetadataSchema -// stays tractable; tighter validation lives at the web-app boundary. -const PermissionConfigSchema = z.union([PermissionActionSchema, z.record(z.string(), z.unknown())]); - -/** - * Runtime agent schema. Each entry is materialized into - * `KILO_CONFIG_CONTENT.agent.` at session preparation time. Mirrors the - * CLI's AgentConfig shape so we pass through verbatim. - * - * Reserved built-in slugs (`code`, `plan`, `architect`, `custom`, …) are - * rejected here so an inline or persisted runtime agent cannot override a - * built-in agent's prompt or permissions inside the sandbox. The web-side - * profile service applies the same rule when persisting agents. - */ -export const RuntimeAgentSchema = z.object({ - slug: z - .string() - .min(1) - .max(Limits.MAX_RUNTIME_AGENT_SLUG_LENGTH) - .regex(/^[a-z][a-z0-9-]*$/, 'Agent slug must start with a letter') - .refine(slug => !BUILTIN_AGENT_MODES.has(slug), { - message: 'Slug conflicts with a built-in agent; choose a different slug', - }), - name: z.string().min(1).max(Limits.MAX_RUNTIME_AGENT_NAME_LENGTH), - config: z - .object({ - prompt: z.string().max(Limits.MAX_RUNTIME_AGENT_PROMPT).optional(), - description: z.string().max(Limits.MAX_RUNTIME_AGENT_DESCRIPTION).optional(), - mode: z.enum(['subagent', 'primary', 'all']).optional(), - model: z.string().max(Limits.MAX_RUNTIME_AGENT_MODEL_LENGTH).nullable().optional(), - variant: z.string().max(50).optional(), - temperature: z.number().optional(), - top_p: z.number().optional(), - steps: z.number().int().positive().optional(), - hidden: z.boolean().optional(), - disable: z.boolean().optional(), - color: z.string().max(50).optional(), - permission: PermissionConfigSchema.optional(), - options: z.record(z.string(), z.unknown()).optional(), - }) - // Variant keys are model-specific, so a `variant` without a `model` - // has no anchor — mirror the web-side AgentConfigSchema invariant. - .refine(c => !c.variant || (typeof c.model === 'string' && c.model.length > 0), { - message: 'variant requires a model — variants are model-specific', - path: ['variant'], - }), -}); - -export const RuntimeAgentsSchema = z - .array(RuntimeAgentSchema) - .max(Limits.MAX_RUNTIME_AGENTS, `Maximum ${Limits.MAX_RUNTIME_AGENTS} runtime agents allowed`); - -export type RuntimeAgentInput = z.infer; - -// --- Runtime kilo commands --- - -export const RuntimeKiloCommandSchema = z.object({ - name: z - .string() - .min(1) - .max(Limits.MAX_RUNTIME_KILO_COMMAND_NAME_LENGTH) - .regex(/^[a-z][a-z0-9-]*$/, 'Command name must start with a letter and be a slug'), - template: z.string().min(1).max(Limits.MAX_RUNTIME_KILO_COMMAND_TEMPLATE), - description: z.string().max(Limits.MAX_RUNTIME_KILO_COMMAND_DESCRIPTION).nullable().optional(), - agent: z.string().max(Limits.MAX_RUNTIME_AGENT_SLUG_LENGTH).nullable().optional(), - model: z.string().max(Limits.MAX_RUNTIME_AGENT_MODEL_LENGTH).nullable().optional(), - subtask: z.boolean().optional(), -}); - -export const RuntimeKiloCommandsSchema = z - .array(RuntimeKiloCommandSchema) - .max( - Limits.MAX_RUNTIME_KILO_COMMANDS, - `Maximum ${Limits.MAX_RUNTIME_KILO_COMMANDS} runtime kilo commands allowed` - ); - -export type RuntimeKiloCommandInput = z.infer; - /** Discriminated payload for the initial execution on a newly-prepared session. */ export const InitialExecutionPayloadSchema = z.discriminatedUnion('type', [ z.object({ diff --git a/services/cloud-agent-next/src/sandbox-control/session-credentials.ts b/services/cloud-agent-next/src/sandbox-control/session-credentials.ts index 65c44d9957c..30ea48ca994 100644 --- a/services/cloud-agent-next/src/sandbox-control/session-credentials.ts +++ b/services/cloud-agent-next/src/sandbox-control/session-credentials.ts @@ -25,6 +25,7 @@ import { } from '../services/git-token-service-client.js'; import { readProfileBundle } from '../session-profile.js'; import { runtimeCredentialProxyFacadeBaseUrl } from '../runtime-credential-proxy.js'; +import { CONTROL_RUNTIME_RESERVED_ENV_VARS } from '../shared/runtime-environment.js'; import type { ControlPlaneCredentialRepository, ControlPlaneCredentialSource, @@ -736,6 +737,29 @@ async function selectDirectKiloToken( } } +/** + * The environment key names the grant projection in `preparedPayload` owns. The + * frame builder restores any of these that the pre-overlay spec already carried + * (presence, not truthiness), so a profile secret of the same name cannot + * displace the issued credential. + */ +export const ISSUED_CREDENTIAL_ENV_KEYS = [ + 'KILOCODE_TOKEN', + 'KILOCODE_ORGANIZATION_ID', + 'GH_TOKEN', + 'GITHUB_TOKEN', + 'GITLAB_TOKEN', + 'GLAB_IS_OAUTH2', + 'GITLAB_HOST', + 'GITLAB_SUBFOLDER', + 'BITBUCKET_TOKEN', + 'KILO_BITBUCKET_WORKSPACE_SLUG', + 'KILO_BITBUCKET_REPOSITORY_SLUG', + 'KILO_BITBUCKET_WORKSPACE_UUID', + 'KILO_BITBUCKET_REPOSITORY_UUID', + ...CONTROL_RUNTIME_RESERVED_ENV_VARS, +] as const; + function preparedPayload(input: { payload: SessionAttachPayload; grant: SessionCredentialGrant; diff --git a/services/cloud-agent-next/src/sandbox-session/attach-payload.ts b/services/cloud-agent-next/src/sandbox-session/attach-payload.ts index 160cdb39244..252c2e5b18f 100644 --- a/services/cloud-agent-next/src/sandbox-session/attach-payload.ts +++ b/services/cloud-agent-next/src/sandbox-session/attach-payload.ts @@ -48,6 +48,14 @@ function gitFromMetadata( }; } +export function isReadOnlyBitbucketReview( + metadata: Pick +): boolean { + if (metadata.identity.createdOnPlatform !== 'code-review') return false; + if (metadata.repository?.type !== 'bitbucket') return false; + return codeReviewIdFromCallbackTarget(metadata.callback?.target) !== null; +} + export function buildSessionAttachPayload( metadata: SessionMetadata, preparation?: SessionAttachPayload['preparation'], @@ -67,14 +75,10 @@ export function buildSessionAttachPayload( ? 'working' : undefined; const profile = readProfileBundle(metadata); - const bitbucketReviewId = - metadata.identity.createdOnPlatform === 'code-review' && - metadata.repository?.type === 'bitbucket' - ? codeReviewIdFromCallbackTarget(metadata.callback?.target) - : null; + const readOnlyBitbucketReview = isReadOnlyBitbucketReview(metadata); validateControlSessionOptions(metadata); const env = { - ...(profile.envVars ?? {}), + ...(!readOnlyBitbucketReview ? (profile.envVars ?? {}) : {}), ...(metadata.auth.kilocodeToken ? { KILOCODE_TOKEN: metadata.auth.kilocodeToken } : {}), }; rejectReservedControlRuntimeEnvironment(env); @@ -85,9 +89,7 @@ export function buildSessionAttachPayload( ...(metadata.auth.kiloSessionId ? { snapshotIdentity: metadata.auth.kiloSessionId } : {}), ...(git ? { git } : {}), ...(Object.keys(env).length > 0 ? { env } : {}), - ...(bitbucketReviewId === null && - profile.mcpServers && - Object.keys(profile.mcpServers).length > 0 + ...(!readOnlyBitbucketReview && profile.mcpServers && Object.keys(profile.mcpServers).length > 0 ? { mcp: materializeMcpServers(profile.mcpServers, mcpPrivateKey) } : {}), ...(profile.setupCommands?.length ? { setupCommands: profile.setupCommands } : {}), diff --git a/services/cloud-agent-next/src/schema.ts b/services/cloud-agent-next/src/schema.ts index cc682286b8a..e7180b9f616 100644 --- a/services/cloud-agent-next/src/schema.ts +++ b/services/cloud-agent-next/src/schema.ts @@ -1,62 +1,8 @@ -import { z } from 'zod'; import type { MCPServerConfig } from './persistence/types.js'; -// === Agent Modes === -/** - * Internal agent modes used by the kilo CLI. - * These are the actual modes passed to `kilo run --agent `. - */ -export const InternalAgentModes = [ - 'code', - 'plan', - 'debug', - 'orchestrator', - 'ask', - 'custom', -] as const; -export type InternalAgentMode = (typeof InternalAgentModes)[number]; - -/** - * Input agent modes accepted by the API. - * These include backward-compatible aliases: - * - build: maps to 'code' - * - architect: maps to 'plan' - * All other modes pass through 1:1 to the CLI. - */ -export const AgentModes = [ - 'code', - 'plan', - 'debug', - 'orchestrator', - 'ask', - 'build', - 'architect', - 'custom', -] as const; -/** - * AgentMode accepts any string — built-in slugs from `AgentModes` or any - * custom slug from a session's `runtimeAgents`. Use `AgentModeSchema` to - * validate that a value is one of the built-ins specifically. - */ -export type AgentMode = string; -export type BuiltinAgentMode = (typeof AgentModes)[number]; -export const AgentModeSchema = z.enum(AgentModes); - -/** - * Maps input agent modes to internal modes used by kilo CLI. Built-ins are - * aliased (build → code, architect → plan); any non-built-in slug (from a - * runtimeMode) is passed through unchanged. - */ -export function normalizeAgentMode(mode: string): string { - switch (mode) { - case 'build': - return 'code'; - case 'architect': - return 'plan'; - default: - return mode; - } -} +export * from './shared/agent-modes.js'; +import { type AgentMode } from './shared/agent-modes.js'; +import { RuntimeProfileLimits } from './shared/runtime-profile.js'; // === Limits === export const Limits = { @@ -67,30 +13,11 @@ export const Limits = { MAX_SETUP_COMMANDS: 20, MAX_SETUP_COMMAND_LENGTH: 500, MAX_MCP_SERVERS: 20, - MAX_RUNTIME_SKILLS: 50, - MAX_RUNTIME_SKILL_MARKDOWN: 100_000, // ~100KB - MAX_RUNTIME_SKILL_NAME_LENGTH: 100, - MAX_RUNTIME_SKILL_COMPANION_FILES: 40, - MAX_RUNTIME_SKILL_COMPANION_FILE_SIZE: 100_000, - MAX_RUNTIME_SKILL_COMPANION_FILES_TOTAL: 500_000, - MAX_RUNTIME_SKILL_COMPANION_PATH_LENGTH: 200, - MAX_RUNTIME_AGENTS: 20, - MAX_RUNTIME_AGENT_SLUG_LENGTH: 50, - MAX_RUNTIME_AGENT_NAME_LENGTH: 100, - MAX_RUNTIME_AGENT_PROMPT: 50_000, - MAX_RUNTIME_AGENT_DESCRIPTION: 2000, - MAX_RUNTIME_AGENT_MODEL_LENGTH: 200, - MAX_RUNTIME_KILO_COMMANDS: 50, - MAX_RUNTIME_KILO_COMMAND_NAME_LENGTH: 100, - MAX_RUNTIME_KILO_COMMAND_TEMPLATE: 100_000, - MAX_RUNTIME_KILO_COMMAND_DESCRIPTION: 2000, + ...RuntimeProfileLimits, SESSION_TTL_DAYS: 90, SESSION_TTL_MS: 90 * 24 * 60 * 60 * 1000, // 90 days in milliseconds } as const; -/** Built-in mode slugs recognized by the CLI. */ -export const BUILTIN_AGENT_MODES = new Set(AgentModes); - // === ExecutionParams (for session-service) === export type ExecutionParams = { sessionId: string; // cloudAgentSessionId diff --git a/services/cloud-agent-next/src/session-service.ts b/services/cloud-agent-next/src/session-service.ts index a9dac178acb..21f6bb1f589 100644 --- a/services/cloud-agent-next/src/session-service.ts +++ b/services/cloud-agent-next/src/session-service.ts @@ -1,3 +1,8 @@ +import { + buildAgentEntryFromRuntimeAgent, + buildKiloCommandsConfiguration, +} from './shared/runtime-profile-config.js'; +export { buildAgentEntryFromRuntimeAgent } from './shared/runtime-profile-config.js'; import { dirname, relative } from 'node:path'; import { parseManagedBitbucketCloneUrl, @@ -56,12 +61,7 @@ import type { CreateSessionForCloudAgentResult, } from '@kilocode/session-ingest-contracts'; import { timedExec } from './sandbox-timeout-logging.js'; -import type { - PersistenceEnv, - CloudAgentSessionState, - RuntimeSkill, - RuntimeAgent, -} from './persistence/types.js'; +import type { PersistenceEnv, CloudAgentSessionState, RuntimeSkill } from './persistence/types.js'; import { getEffectiveCredentialContainment, parseSessionMetadata, @@ -778,26 +778,6 @@ function shortHash(input: string): string { return (hash >>> 0).toString(16); } -export function buildAgentEntryFromRuntimeAgent(agent: RuntimeAgent): Record { - const { config } = agent; - const entry: Record = { - mode: config.mode ?? 'primary', - }; - if (config.prompt !== undefined) entry.prompt = config.prompt; - if (config.description !== undefined) entry.description = config.description; - if (config.model != null) entry.model = normalizeKilocodeModel(config.model); - if (config.variant !== undefined) entry.variant = config.variant; - if (config.temperature !== undefined) entry.temperature = config.temperature; - if (config.top_p !== undefined) entry.top_p = config.top_p; - if (config.steps !== undefined) entry.steps = config.steps; - if (config.hidden !== undefined) entry.hidden = config.hidden; - if (config.disable !== undefined) entry.disable = config.disable; - if (config.color !== undefined) entry.color = config.color; - if (config.permission !== undefined) entry.permission = config.permission; - if (config.options !== undefined) entry.options = config.options; - return entry; -} - function isSafeSkillFilePath(relativePath: string): boolean { if (relativePath.length === 0 || relativePath.length > 200) return false; if (relativePath.startsWith('/')) return false; @@ -1373,18 +1353,7 @@ export class SessionService { configContent.agent = agentConfig; } if (!bitbucketInputPath && kiloCommands && kiloCommands.length > 0) { - configContent.command = Object.fromEntries( - kiloCommands.map(cmd => [ - cmd.name, - { - template: cmd.template, - ...(cmd.description && { description: cmd.description }), - ...(cmd.agent && { agent: cmd.agent }), - ...(cmd.model && { model: normalizeKilocodeModel(cmd.model) }), - subtask: cmd.subtask ?? false, - }, - ]) - ); + configContent.command = buildKiloCommandsConfiguration(kiloCommands); logger.info('Kilo commands merged into KILO_CONFIG_CONTENT', { kiloCommandNames: kiloCommands.map(c => c.name), kiloCommandCount: kiloCommands.length, diff --git a/services/cloud-agent-next/src/shared/agent-modes.ts b/services/cloud-agent-next/src/shared/agent-modes.ts new file mode 100644 index 00000000000..cb47a50503e --- /dev/null +++ b/services/cloud-agent-next/src/shared/agent-modes.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; + +// === Agent Modes === +/** + * Internal agent modes used by the kilo CLI. + * These are the actual modes passed to `kilo run --agent `. + */ +export const InternalAgentModes = [ + 'code', + 'plan', + 'debug', + 'orchestrator', + 'ask', + 'custom', +] as const; +export type InternalAgentMode = (typeof InternalAgentModes)[number]; + +/** + * Input agent modes accepted by the API. + * These include backward-compatible aliases: + * - build: maps to 'code' + * - architect: maps to 'plan' + * All other modes pass through 1:1 to the CLI. + */ +export const AgentModes = [ + 'code', + 'plan', + 'debug', + 'orchestrator', + 'ask', + 'build', + 'architect', + 'custom', +] as const; +/** + * AgentMode accepts any string — built-in slugs from `AgentModes` or any + * custom slug from a session's `runtimeAgents`. Use `AgentModeSchema` to + * validate that a value is one of the built-ins specifically. + */ +export type AgentMode = string; +export type BuiltinAgentMode = (typeof AgentModes)[number]; +export const AgentModeSchema = z.enum(AgentModes); + +/** + * Maps input agent modes to internal modes used by kilo CLI. Built-ins are + * aliased (build → code, architect → plan); any non-built-in slug (from a + * runtimeMode) is passed through unchanged. + */ +export function normalizeAgentMode(mode: string): string { + switch (mode) { + case 'build': + return 'code'; + case 'architect': + return 'plan'; + default: + return mode; + } +} + +/** Built-in mode slugs recognized by the CLI. */ +export const BUILTIN_AGENT_MODES = new Set(AgentModes); diff --git a/services/cloud-agent-next/src/shared/control-plane-protocol.ts b/services/cloud-agent-next/src/shared/control-plane-protocol.ts index fec3bc93767..83644fd917c 100644 --- a/services/cloud-agent-next/src/shared/control-plane-protocol.ts +++ b/services/cloud-agent-next/src/shared/control-plane-protocol.ts @@ -1,3 +1,8 @@ +import { + RuntimeSkillsSchema, + RuntimeAgentsSchema, + RuntimeKiloCommandsSchema, +} from './runtime-profile.js'; import type { CloudAgentAssistantFailureReason, CloudAgentProviderOwnership, @@ -239,7 +244,7 @@ export const controlPlaneRouteSpecSchema = z branchMode: z.literal('working').optional(), git: controlPlaneRouteGitSchema.optional(), kilo: controlPlaneRouteKiloSchema.optional(), - env: z.record(z.string().max(256), z.string().max(8192)).optional(), + env: z.record(z.string().max(256), z.string().max(10000)).optional(), /** * Materialized (plaintext) MCP servers for `KILO_CONFIG_CONTENT.mcp`. Only * the Sandbox DO adds this to the `session.prepare` frame; it is never stored @@ -248,6 +253,9 @@ export const controlPlaneRouteSpecSchema = z */ mcp: sessionAttachMcpServersSchema.optional(), setupCommands: z.array(z.string().max(500)).max(20).optional(), + runtimeSkills: RuntimeSkillsSchema.optional(), + runtimeAgents: RuntimeAgentsSchema.optional(), + kiloCommands: RuntimeKiloCommandsSchema.optional(), runtimeIsolation: z.enum(['per-session']).optional(), attemptId: z.string().min(1).max(128), /** @@ -264,7 +272,19 @@ export const controlPlaneRouteSpecSchema = z */ secretEnvKeys: z.array(z.string().min(1).max(128)).max(50).optional(), }) - .strict(); + .strict() + .superRefine((spec, context) => { + if ( + (spec.runtimeSkills?.length || spec.runtimeAgents?.length || spec.kiloCommands?.length) && + spec.runtimeIsolation !== 'per-session' + ) { + context.addIssue({ + code: 'custom', + path: ['runtimeIsolation'], + message: 'Runtime profiles require per-session isolation', + }); + } + }); /** * Route spec accepted at the registration boundary. `secretEnvKeys` is @@ -348,6 +368,15 @@ export const controlPlaneCredentialSourceSchema = z * the wrapper bundle) never imports the worker persistence schema. */ mcpServers: z.record(z.string().min(1).max(100), z.unknown()).optional(), + /** + * Worker-encrypted `profile.encryptedSecrets` snapshot. Same contract as + * `mcpServers`: persisted only inside this DO-private source, re-validated and + * decrypted by the Sandbox DO only when building a `session.prepare` frame, + * and never serialized into a wrapper frame or a route spec. Opaque here so + * the shared schema never imports the worker persistence schema. Secret names + * may be up to 128 characters (`EncryptedSecretsSchema`). + */ + encryptedSecrets: z.record(z.string().min(1).max(128), z.unknown()).optional(), /** Credential scope (worktree id); defaults to the route's session id. */ scopeId: z.string().min(1).max(256).optional(), }) @@ -642,6 +671,7 @@ const controlPlaneHelloFrameSchema = z /** * Optional capability: the wrapper redacts a value by each named secret key * from `session.prepare`, not only names matching the `SECRET_NAME` heuristic. + * A secret-bearing prepare is not sent to a wrapper that did not advertise it. */ redactsNamedSecrets: z.literal(true).optional(), }) diff --git a/services/cloud-agent-next/src/shared/model-utils.ts b/services/cloud-agent-next/src/shared/model-utils.ts new file mode 100644 index 00000000000..38dfbf918d0 --- /dev/null +++ b/services/cloud-agent-next/src/shared/model-utils.ts @@ -0,0 +1,16 @@ +export const LOCAL_FAKE_DETERMINISTIC_MODEL_ID = 'fake-deterministic'; + +export function normalizeKilocodeModel(model: string | undefined | null): string | undefined { + if (!model) return undefined; + const trimmed = model.trim(); + if (!trimmed) return undefined; + return trimmed.startsWith('kilo/') ? trimmed : `kilo/${trimmed}`; +} + +export function dispatchedKilocodeModelId(model: string | undefined | null): string | undefined { + return normalizeKilocodeModel(model)?.replace(/^kilo\//, ''); +} + +export function isLocalFakeDeterministicModel(model: string | undefined | null): boolean { + return dispatchedKilocodeModelId(model) === LOCAL_FAKE_DETERMINISTIC_MODEL_ID; +} diff --git a/services/cloud-agent-next/src/shared/runtime-profile-config.ts b/services/cloud-agent-next/src/shared/runtime-profile-config.ts new file mode 100644 index 00000000000..60258cfd2e7 --- /dev/null +++ b/services/cloud-agent-next/src/shared/runtime-profile-config.ts @@ -0,0 +1,37 @@ +import type { RuntimeAgentInput, RuntimeKiloCommandInput } from './runtime-profile.js'; +import { normalizeKilocodeModel } from './model-utils.js'; + +export function buildAgentEntryFromRuntimeAgent(agent: RuntimeAgentInput): Record { + const { config } = agent; + const entry: Record = { + mode: config.mode ?? 'primary', + }; + if (config.prompt !== undefined) entry.prompt = config.prompt; + if (config.description !== undefined) entry.description = config.description; + if (config.model != null) entry.model = normalizeKilocodeModel(config.model); + if (config.variant !== undefined) entry.variant = config.variant; + if (config.temperature !== undefined) entry.temperature = config.temperature; + if (config.top_p !== undefined) entry.top_p = config.top_p; + if (config.steps !== undefined) entry.steps = config.steps; + if (config.hidden !== undefined) entry.hidden = config.hidden; + if (config.disable !== undefined) entry.disable = config.disable; + if (config.color !== undefined) entry.color = config.color; + if (config.permission !== undefined) entry.permission = config.permission; + if (config.options !== undefined) entry.options = config.options; + return entry; +} + +export function buildKiloCommandsConfiguration(commands: readonly RuntimeKiloCommandInput[]) { + return Object.fromEntries( + commands.map(command => [ + command.name, + { + template: command.template, + ...(command.description && { description: command.description }), + ...(command.agent && { agent: command.agent }), + ...(command.model && { model: normalizeKilocodeModel(command.model) }), + subtask: command.subtask ?? false, + }, + ]) + ); +} diff --git a/services/cloud-agent-next/src/shared/runtime-profile.ts b/services/cloud-agent-next/src/shared/runtime-profile.ts new file mode 100644 index 00000000000..cd690fafa72 --- /dev/null +++ b/services/cloud-agent-next/src/shared/runtime-profile.ts @@ -0,0 +1,183 @@ +import * as z from 'zod'; +import { BUILTIN_AGENT_MODES } from './agent-modes.js'; + +export const RuntimeProfileLimits = { + MAX_RUNTIME_SKILLS: 50, + MAX_RUNTIME_SKILL_MARKDOWN: 100_000, // ~100KB + MAX_RUNTIME_SKILL_NAME_LENGTH: 100, + MAX_RUNTIME_SKILL_COMPANION_FILES: 40, + MAX_RUNTIME_SKILL_COMPANION_FILE_SIZE: 100_000, + MAX_RUNTIME_SKILL_COMPANION_FILES_TOTAL: 500_000, + MAX_RUNTIME_SKILL_COMPANION_PATH_LENGTH: 200, + MAX_RUNTIME_AGENTS: 20, + MAX_RUNTIME_AGENT_SLUG_LENGTH: 50, + MAX_RUNTIME_AGENT_NAME_LENGTH: 100, + MAX_RUNTIME_AGENT_PROMPT: 50_000, + MAX_RUNTIME_AGENT_DESCRIPTION: 2000, + MAX_RUNTIME_AGENT_MODEL_LENGTH: 200, + MAX_RUNTIME_KILO_COMMANDS: 50, + MAX_RUNTIME_KILO_COMMAND_NAME_LENGTH: 100, + MAX_RUNTIME_KILO_COMMAND_TEMPLATE: 100_000, + MAX_RUNTIME_KILO_COMMAND_DESCRIPTION: 2000, +} as const; + +const SKILL_FILE_PATH_PATTERN = /^[a-zA-Z0-9._\-/]+$/; + +/** Shared by schema validation and the legacy writer that skips unsafe files. */ +export function isSafeSkillFilePath(path: string): boolean { + return ( + path.length > 0 && + path.length <= RuntimeProfileLimits.MAX_RUNTIME_SKILL_COMPANION_PATH_LENGTH && + SKILL_FILE_PATH_PATTERN.test(path) && + !path.startsWith('/') && + !path.includes('..') && + !path.includes('//') && + path.toLowerCase() !== 'skill.md' + ); +} + +/** Validate a map of companion files bundled with a skill. */ +const RuntimeSkillFilesSchema = z + .record( + z.string().min(1).max(RuntimeProfileLimits.MAX_RUNTIME_SKILL_COMPANION_PATH_LENGTH), + z.string().max(RuntimeProfileLimits.MAX_RUNTIME_SKILL_COMPANION_FILE_SIZE) + ) + .refine( + files => Object.keys(files).length <= RuntimeProfileLimits.MAX_RUNTIME_SKILL_COMPANION_FILES, + `A skill may have at most ${RuntimeProfileLimits.MAX_RUNTIME_SKILL_COMPANION_FILES} companion files` + ) + .superRefine((files, ctx) => { + let total = 0; + for (const [path, content] of Object.entries(files)) { + if (!isSafeSkillFilePath(path)) { + ctx.addIssue({ code: 'custom', message: `Skill file path rejected: ${path}` }); + return; + } + total += content.length; + } + if (total > RuntimeProfileLimits.MAX_RUNTIME_SKILL_COMPANION_FILES_TOTAL) { + ctx.addIssue({ + code: 'custom', + message: `Skill companion files total ${total} bytes, exceeds ${RuntimeProfileLimits.MAX_RUNTIME_SKILL_COMPANION_FILES_TOTAL}`, + }); + } + }); + +/** + * Runtime skill schema. Each entry is materialized to + * `${SESSION_HOME}/.kilocode/skills//` at preparation time — `rawMarkdown` + * is written to `SKILL.md`, and each `files[path]` is written under the same + * directory. + */ +export const RuntimeSkillSchema = z.object({ + name: z + .string() + .min(1) + .max(RuntimeProfileLimits.MAX_RUNTIME_SKILL_NAME_LENGTH) + .regex(/^[a-z0-9][a-z0-9-]*$/, 'Skill name must be a slug'), + rawMarkdown: z.string().min(1).max(RuntimeProfileLimits.MAX_RUNTIME_SKILL_MARKDOWN), + files: RuntimeSkillFilesSchema.optional(), +}); + +export const RuntimeSkillsSchema = z + .array(RuntimeSkillSchema) + .max( + RuntimeProfileLimits.MAX_RUNTIME_SKILLS, + `Maximum ${RuntimeProfileLimits.MAX_RUNTIME_SKILLS} runtime skills allowed` + ); + +export type RuntimeSkillInput = z.infer; + +// --- Runtime agents --- + +const PermissionActionSchema = z.enum(['allow', 'ask', 'deny']); +// Flat permissive shape — the runtime tolerates any shape the CLI accepts +// (bare action string, per-tool map with per-pattern maps, null sentinels). +// Schema-level typing kept loose so the zod inference used by MetadataSchema +// stays tractable; tighter validation lives at the web-app boundary. +const PermissionConfigSchema = z.union([PermissionActionSchema, z.record(z.string(), z.unknown())]); + +/** + * Runtime agent schema. Each entry is materialized into + * `KILO_CONFIG_CONTENT.agent.` at session preparation time. Mirrors the + * CLI's AgentConfig shape so we pass through verbatim. + * + * Reserved built-in slugs (`code`, `plan`, `architect`, `custom`, …) are + * rejected here so an inline or persisted runtime agent cannot override a + * built-in agent's prompt or permissions inside the sandbox. The web-side + * profile service applies the same rule when persisting agents. + */ +export const RuntimeAgentSchema = z.object({ + slug: z + .string() + .min(1) + .max(RuntimeProfileLimits.MAX_RUNTIME_AGENT_SLUG_LENGTH) + .regex(/^[a-z][a-z0-9-]*$/, 'Agent slug must start with a letter') + .refine(slug => !BUILTIN_AGENT_MODES.has(slug), { + message: 'Slug conflicts with a built-in agent; choose a different slug', + }), + name: z.string().min(1).max(RuntimeProfileLimits.MAX_RUNTIME_AGENT_NAME_LENGTH), + config: z + .object({ + prompt: z.string().max(RuntimeProfileLimits.MAX_RUNTIME_AGENT_PROMPT).optional(), + description: z.string().max(RuntimeProfileLimits.MAX_RUNTIME_AGENT_DESCRIPTION).optional(), + mode: z.enum(['subagent', 'primary', 'all']).optional(), + model: z + .string() + .max(RuntimeProfileLimits.MAX_RUNTIME_AGENT_MODEL_LENGTH) + .nullable() + .optional(), + variant: z.string().max(50).optional(), + temperature: z.number().optional(), + top_p: z.number().optional(), + steps: z.number().int().positive().optional(), + hidden: z.boolean().optional(), + disable: z.boolean().optional(), + color: z.string().max(50).optional(), + permission: PermissionConfigSchema.optional(), + options: z.record(z.string(), z.unknown()).optional(), + }) + // Variant keys are model-specific, so a `variant` without a `model` + // has no anchor — mirror the web-side AgentConfigSchema invariant. + .refine(c => !c.variant || (typeof c.model === 'string' && c.model.length > 0), { + message: 'variant requires a model — variants are model-specific', + path: ['variant'], + }), +}); + +export const RuntimeAgentsSchema = z + .array(RuntimeAgentSchema) + .max( + RuntimeProfileLimits.MAX_RUNTIME_AGENTS, + `Maximum ${RuntimeProfileLimits.MAX_RUNTIME_AGENTS} runtime agents allowed` + ); + +export type RuntimeAgentInput = z.infer; + +// --- Runtime kilo commands --- + +export const RuntimeKiloCommandSchema = z.object({ + name: z + .string() + .min(1) + .max(RuntimeProfileLimits.MAX_RUNTIME_KILO_COMMAND_NAME_LENGTH) + .regex(/^[a-z][a-z0-9-]*$/, 'Command name must start with a letter and be a slug'), + template: z.string().min(1).max(RuntimeProfileLimits.MAX_RUNTIME_KILO_COMMAND_TEMPLATE), + description: z + .string() + .max(RuntimeProfileLimits.MAX_RUNTIME_KILO_COMMAND_DESCRIPTION) + .nullable() + .optional(), + agent: z.string().max(RuntimeProfileLimits.MAX_RUNTIME_AGENT_SLUG_LENGTH).nullable().optional(), + model: z.string().max(RuntimeProfileLimits.MAX_RUNTIME_AGENT_MODEL_LENGTH).nullable().optional(), + subtask: z.boolean().optional(), +}); + +export const RuntimeKiloCommandsSchema = z + .array(RuntimeKiloCommandSchema) + .max( + RuntimeProfileLimits.MAX_RUNTIME_KILO_COMMANDS, + `Maximum ${RuntimeProfileLimits.MAX_RUNTIME_KILO_COMMANDS} runtime kilo commands allowed` + ); + +export type RuntimeKiloCommandInput = z.infer; diff --git a/services/cloud-agent-next/test/integration/helpers/fake-wrapper.ts b/services/cloud-agent-next/test/integration/helpers/fake-wrapper.ts index 87f9039d3a9..8fd6045d3ed 100644 --- a/services/cloud-agent-next/test/integration/helpers/fake-wrapper.ts +++ b/services/cloud-agent-next/test/integration/helpers/fake-wrapper.ts @@ -15,6 +15,7 @@ export type FakeWrapperHelloInput = { wrapperId: string; allocationId: string; protocolVersion?: number; + redactsNamedSecrets?: boolean; }; /** @@ -118,6 +119,7 @@ export class FakeWrapper { wrapperId: input.wrapperId, allocationId: input.allocationId, protocolVersion: input.protocolVersion ?? CONTROL_PLANE_PROTOCOL_VERSION, + ...(input.redactsNamedSecrets ? { redactsNamedSecrets: true } : {}), }); return this.next(); } diff --git a/services/cloud-agent-next/test/integration/sandbox-control-v2-repo-snapshots.test.ts b/services/cloud-agent-next/test/integration/sandbox-control-v2-repo-snapshots.test.ts index da9c7d32427..be8ae6dd438 100644 --- a/services/cloud-agent-next/test/integration/sandbox-control-v2-repo-snapshots.test.ts +++ b/services/cloud-agent-next/test/integration/sandbox-control-v2-repo-snapshots.test.ts @@ -133,6 +133,7 @@ type RouteOverrides = { directory?: string; git?: ControlPlaneRouteSpec['git'] | null; env?: Record; + setupCommands?: string[]; userId?: string; orgId?: string | null; sessionId?: string; @@ -148,6 +149,7 @@ function prepareInput(overrides: RouteOverrides = {}) { attemptId: `${sessionId}-requested`, ...(git === undefined ? {} : { git }), ...(overrides.env === undefined ? {} : { env: overrides.env }), + ...(overrides.setupCommands === undefined ? {} : { setupCommands: overrides.setupCommands }), }; return { spec, @@ -406,6 +408,20 @@ describe('repository snapshot launch', () => { expect(frame.spec.capture).toBeUndefined(); }); + it('launches without a key and never asks for a capture when setup commands are present', async () => { + const provider = createFakeProvider(); + const stub = await setup('sbx__repo_launch_setup', provider); + await stub.prepare(prepareInput({ setupCommands: ['pnpm install'] })); + + expect(await repoKeyOf(stub)).toBeNull(); + + const { wrapper } = await connect('sbx__repo_launch_setup', provider); + + expect(provider.launchOptions).toEqual([{}]); + const frame = await prepareFrameOf(wrapper); + expect(frame.spec.capture).toBeUndefined(); + }); + it('discards a snapshot whose start never connected and starts from the image', async () => { const provider = createFakeProvider(); const stub = await setup('sbx__repo_launch_discard', provider); diff --git a/services/cloud-agent-next/test/integration/sandbox-control-v2-runtime-proxy.test.ts b/services/cloud-agent-next/test/integration/sandbox-control-v2-runtime-proxy.test.ts index b0624e58c10..b5e966f1405 100644 --- a/services/cloud-agent-next/test/integration/sandbox-control-v2-runtime-proxy.test.ts +++ b/services/cloud-agent-next/test/integration/sandbox-control-v2-runtime-proxy.test.ts @@ -1,4 +1,5 @@ import { createExecutionContext, env, reset, runInDurableObject } from 'cloudflare:test'; +import { generateKeyPairSync } from 'node:crypto'; import { renewRuntimeAuthorization, sealRuntimeAuthorization, @@ -21,6 +22,7 @@ import { renewStoredRuntimeAuthorization, } from '../../src/session/runtime-authorization-persistence.js'; import worker from '../../src/server.js'; +import { logger } from '../../src/logger.js'; import type { AgentSandboxProvider, Env } from '../../src/types.js'; import type { ProviderAdapter, StopResult } from '../../src/sandbox-control/provider.js'; import { generateSandboxId } from '../../src/sandbox-id.js'; @@ -37,6 +39,10 @@ import { } from './helpers/fake-credentials.js'; import { FakeWrapper } from './helpers/fake-wrapper.js'; import { waitFor } from './wait-for.js'; +import { encryptWithPublicKey } from '../../src/utils/encryption.js'; +import { routes as routesTable } from '../../src/control-plane/sandbox/sqlite-schema.js'; +import { eq } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/durable-sqlite'; type SandboxControlNamespace = DurableObjectNamespace; type SessionNamespace = DurableObjectNamespace; @@ -99,6 +105,7 @@ function metadata(input: { kiloToken: string; provider?: AgentSandboxProvider; worktreeId?: string; + encryptedSecrets?: Record; }) { return parseSessionMetadata({ metadataSchemaVersion: 2, @@ -110,6 +117,9 @@ function metadata(input: { }, auth: { kiloSessionId: input.kiloSessionIdValue, kilocodeToken: input.kiloToken }, agent: { mode: 'code', model: 'test/model' }, + ...(input.encryptedSecrets === undefined + ? {} + : { profile: { encryptedSecrets: input.encryptedSecrets } }), repository: { type: 'git', url: 'https://github.com/acme/widgets.git', @@ -200,7 +210,8 @@ function createProvider(): FakeProvider { async function installSandbox( sandboxId: string, provider: FakeProvider, - broker = createFakeCredentialBroker() + broker = createFakeCredentialBroker(), + privateKey?: string ): Promise> { const sandboxStub = sandboxes.getByName(sandboxId); await runInDurableObject(sandboxStub, async instance => { @@ -212,6 +223,7 @@ async function installSandbox( KILOCODE_BACKEND_BASE_URL: WORKER_URL, KILO_OPENROUTER_BASE: WORKER_URL, KILO_SESSION_INGEST_URL: WORKER_URL, + AGENT_ENV_VARS_PRIVATE_KEY: privateKey, }); Object.assign(instance, { createProviderAdapter: () => provider.adapter, @@ -230,6 +242,7 @@ async function createVercelSession(input: { provider?: AgentSandboxProvider; contained?: boolean; worktreeId?: string; + encryptedSecrets?: Record; }): Promise> { const sessionStub = sessions.getByName(sessionDoName(USER_ID, input.sessionId)); await runInDurableObject(sessionStub, instance => { @@ -243,6 +256,7 @@ async function createVercelSession(input: { kiloToken: input.kiloToken, provider: input.provider, worktreeId: input.worktreeId, + encryptedSecrets: input.encryptedSecrets, }), message: promptPayload(messageId()), sandboxSelection: { @@ -994,3 +1008,198 @@ describe('SandboxControlV2 runtime credential proxy (R1)', () => { expect(await wrapper.next(100)).toBeNull(); }); }); + +describe('SandboxControlV2 runtime proxy prepare frame secrets', () => { + const SECRET_VALUE = 'proxy-secret-value'; + + function generateSecretsKeyPair() { + return generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + } + + function readRouteRow(sandboxStub: DurableObjectStub, sessionId: string) { + return runInDurableObject(sandboxStub, async (_instance, state) => { + const db = drizzle(state.storage, { logger: false }); + const rows = await db.select().from(routesTable).where(eq(routesTable.session_id, sessionId)); + return rows[0] ?? null; + }); + } + + async function captureRouteFailures( + action: () => Promise + ): Promise<{ result: T; failures: Record[] }> { + const failures: Record[] = []; + const withFields = vi.spyOn(logger, 'withFields').mockImplementation(fields => { + const bounded = fields as unknown as Record; + if (bounded.diagnosticEvent === 'route_failed') failures.push(bounded); + return logger; + }); + const info = vi.spyOn(logger, 'info').mockImplementation(() => {}); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + try { + const result = await action(); + return { result, failures }; + } finally { + withFields.mockRestore(); + info.mockRestore(); + warn.mockRestore(); + } + } + + it('decrypts secrets into the proxy prepare frame and never persists plaintext', async () => { + const { publicKey, privateKey } = generateSecretsKeyPair(); + const envelope = encryptWithPublicKey(SECRET_VALUE, publicKey); + const sessionId = newSessionId(); + const sandboxId = await generateSandboxId('*', ORG_ID, USER_ID, sessionId); + const provider = createProvider(); + const sandboxStub = await installSandbox( + sandboxId, + provider, + createFakeCredentialBroker(), + privateKey + ); + const { seal, token } = await sealedRuntimeAuthorization(sessionId); + await createVercelSession({ + sessionId, + kiloId: kiloSessionId(), + sandboxId, + kiloToken: token, + seal, + encryptedSecrets: { DATABASE_URL: envelope }, + }); + + await waitFor(() => expect(provider.launchEnvs).toHaveLength(1)); + const identity = launchIdentity(provider); + const wrapper = await FakeWrapper.connect({ sandboxId, credential: identity.credential }); + expect( + await wrapper.hello({ + wrapperId: 'wr_1', + allocationId: identity.allocationId, + redactsNamedSecrets: true, + }) + ).toEqual({ type: 'welcome', protocolVersion: CONTROL_PLANE_PROTOCOL_VERSION }); + + const frame = await wrapper.next(); + if (frame?.type !== 'session.prepare') throw new Error('expected session.prepare'); + expect(frame.spec.env?.DATABASE_URL).toBe(SECRET_VALUE); + expect(frame.spec.secretEnvKeys).toEqual(['DATABASE_URL']); + + const row = await readRouteRow(sandboxStub, sessionId); + expect(row?.spec ?? '').not.toContain(SECRET_VALUE); + expect(row?.spec ?? '').not.toContain('secretEnvKeys'); + expect(row?.credential_source ?? '').toContain(envelope.encryptedData); + expect(row?.credential_source ?? '').not.toContain(SECRET_VALUE); + }); + + it('fails without sending when the proxy wrapper did not advertise named-secret redaction', async () => { + const { publicKey, privateKey } = generateSecretsKeyPair(); + const envelope = encryptWithPublicKey(SECRET_VALUE, publicKey); + const sessionId = newSessionId(); + const sandboxId = await generateSandboxId('*', ORG_ID, USER_ID, sessionId); + const provider = createProvider(); + const sandboxStub = await installSandbox( + sandboxId, + provider, + createFakeCredentialBroker(), + privateKey + ); + const { seal, token } = await sealedRuntimeAuthorization(sessionId); + await createVercelSession({ + sessionId, + kiloId: kiloSessionId(), + sandboxId, + kiloToken: token, + seal, + encryptedSecrets: { DATABASE_URL: envelope }, + }); + + await waitFor(() => expect(provider.launchEnvs).toHaveLength(1)); + const identity = launchIdentity(provider); + const connected = await FakeWrapper.connect({ sandboxId, credential: identity.credential }); + const { result: wrapper, failures } = await captureRouteFailures(async () => { + expect( + await connected.hello({ wrapperId: 'wr_1', allocationId: identity.allocationId }) + ).toEqual({ + type: 'welcome', + protocolVersion: CONTROL_PLANE_PROTOCOL_VERSION, + }); + + await waitFor(async () => { + expect(await sandboxStub.status({ sessionId })).toEqual({ + sessionId, + view: { + state: 'failed', + attemptId: expect.any(String), + reason: 'workspace_setup_failed', + }, + }); + }); + return connected; + }); + expect(failures).toContainEqual( + expect.objectContaining({ + diagnosticEvent: 'route_failed', + reason: 'workspace_setup_failed', + stage: 'wrapper_redaction_unsupported', + }) + ); + expect(await wrapper.next(100)).toBeNull(); + expect(JSON.stringify(await readRouteRow(sandboxStub, sessionId))).not.toContain(SECRET_VALUE); + }); + + it('fails without sending when proxy secrets cannot be decrypted, leaking no value', async () => { + const { publicKey } = generateSecretsKeyPair(); + const envelope = encryptWithPublicKey(SECRET_VALUE, publicKey); + const sessionId = newSessionId(); + const sandboxId = await generateSandboxId('*', ORG_ID, USER_ID, sessionId); + const provider = createProvider(); + // No AGENT_ENV_VARS_PRIVATE_KEY binding: decryption fails at send time. + const sandboxStub = await installSandbox(sandboxId, provider); + const { seal, token } = await sealedRuntimeAuthorization(sessionId); + await createVercelSession({ + sessionId, + kiloId: kiloSessionId(), + sandboxId, + kiloToken: token, + seal, + encryptedSecrets: { DATABASE_URL: envelope }, + }); + + await waitFor(() => expect(provider.launchEnvs).toHaveLength(1)); + const identity = launchIdentity(provider); + const connected = await FakeWrapper.connect({ sandboxId, credential: identity.credential }); + const { result: wrapper, failures } = await captureRouteFailures(async () => { + expect( + await connected.hello({ + wrapperId: 'wr_1', + allocationId: identity.allocationId, + redactsNamedSecrets: true, + }) + ).toEqual({ type: 'welcome', protocolVersion: CONTROL_PLANE_PROTOCOL_VERSION }); + + await waitFor(async () => { + expect(await sandboxStub.status({ sessionId })).toEqual({ + sessionId, + view: { + state: 'failed', + attemptId: expect.any(String), + reason: 'workspace_setup_failed', + }, + }); + }); + return connected; + }); + expect(failures).toContainEqual( + expect.objectContaining({ + diagnosticEvent: 'route_failed', + reason: 'workspace_setup_failed', + stage: 'frame_materialization_failed', + }) + ); + expect(await wrapper.next(100)).toBeNull(); + expect(JSON.stringify(await readRouteRow(sandboxStub, sessionId))).not.toContain(SECRET_VALUE); + }); +}); diff --git a/services/cloud-agent-next/test/integration/sandbox-control-v2.test.ts b/services/cloud-agent-next/test/integration/sandbox-control-v2.test.ts index 67b853a11fb..a5459905535 100644 --- a/services/cloud-agent-next/test/integration/sandbox-control-v2.test.ts +++ b/services/cloud-agent-next/test/integration/sandbox-control-v2.test.ts @@ -1,4 +1,5 @@ import { env, evictAllDurableObjects, reset, runInDurableObject } from 'cloudflare:test'; +import { generateKeyPairSync } from 'node:crypto'; import { resolveSecret } from '../../src/auth.js'; import { mintSandboxLaunchCredential, @@ -35,7 +36,13 @@ import { } from '../../src/sandbox-control/billing-schedule.js'; import { CONTROL_PLANE_TIMERS } from '../../src/shared/control-plane-timers.js'; import { logger } from '../../src/logger.js'; +import { encryptWithPublicKey } from '../../src/utils/encryption.js'; import { FakeWrapper } from './helpers/fake-wrapper.js'; +import { + createFakeCredentialBroker, + installFakeCredentialEnv, +} from './helpers/fake-credentials.js'; +import { FakeSessionPeer } from './helpers/fake-session-peer.js'; import { waitFor } from './wait-for.js'; import { SandboxStatusSnapshotSchema } from '../../src/shared/sandbox-status.js'; @@ -368,6 +375,31 @@ async function captureAllocationTransitions( }); } +/** + * Captures `route_failed` diagnostics emitted while `action` runs, so a test can + * assert the diagnostic stage of a materialization failure. + */ +async function captureRouteFailures( + action: () => Promise +): Promise<{ result: T; failures: Record[] }> { + const failures: Record[] = []; + const withFields = vi.spyOn(logger, 'withFields').mockImplementation(fields => { + const bounded = fields as unknown as Record; + if (bounded.diagnosticEvent === 'route_failed') failures.push(bounded); + return logger; + }); + const info = vi.spyOn(logger, 'info').mockImplementation(() => {}); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + try { + const result = await action(); + return { result, failures }; + } finally { + withFields.mockRestore(); + info.mockRestore(); + warn.mockRestore(); + } +} + afterEach(async () => { await reset(); }); @@ -2643,3 +2675,233 @@ describe('SandboxControlV2 allocation lifecycle', () => { } ); }); + +describe('SandboxControlV2 prepare frame secrets', () => { + const SECRET_VALUE = 'framed-secret-value'; + + function generateSecretsKeyPair() { + return generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + } + + function secretsPrepareInput( + sessionId: string, + encryptedSecrets: Record + ): Parameters['prepare']>[0] { + // `containedKiloSessionIdSchema` requires exactly 26 alphanumerics. + const kiloSessionId = 'ses_aaaaaaaaaaaaaaaaaaaaaaaaaa'; + return { + spec: { + sessionId, + kiloSessionId, + directory: `/workspace/${sessionId}`, + attemptId: `${sessionId}-requested`, + env: { DATABASE_URL: 'plaintext-db' }, + }, + credentials: { + userId: 'owner-1', + kiloSessionId, + kiloToken: 'native-kilo-token', + orgId: 'org-1', + repository: { type: 'github', repo: 'acme/widgets' }, + scopeId: sessionId, + encryptedSecrets, + }, + }; + } + + async function startSecrets( + provider: FakeProvider, + privateKey: string | undefined, + peer: FakeSessionPeer + ): Promise> { + const stub = sandboxNamespace.getByName(SANDBOX_ID); + await runInDurableObject(stub, async instance => { + await instance.getAllocationState(); + installFakeCredentialEnv(instance.env, createFakeCredentialBroker(), { + AGENT_ENV_VARS_PRIVATE_KEY: privateKey, + }); + Object.assign(instance, { + createProviderAdapter: () => provider.adapter, + provider: provider.adapter, + sessionPeerFor: (_ownerId: string, sessionId: string) => peer.forSession(sessionId), + }); + }); + return stub; + } + + async function connectHello( + provider: FakeProvider, + redactsNamedSecrets: boolean + ): Promise { + await waitFor(() => expect(provider.launchEnvs).toHaveLength(1)); + const { credential, allocationId } = launchIdentity(provider); + const wrapper = await FakeWrapper.connect({ sandboxId: SANDBOX_ID, credential }); + const reply = await wrapper.hello({ + wrapperId: 'wr_secrets', + allocationId, + ...(redactsNamedSecrets ? { redactsNamedSecrets: true } : {}), + }); + expect(reply).toEqual({ type: 'welcome', protocolVersion: CONTROL_PLANE_PROTOCOL_VERSION }); + return wrapper; + } + + function readRouteRow(stub: DurableObjectStub, sessionId: string) { + return runInDurableObject(stub, async (_instance, state) => { + const db = drizzle(state.storage, { logger: false }); + const rows = await db.select().from(routesTable).where(eq(routesTable.session_id, sessionId)); + return rows[0] ?? null; + }); + } + + it.each([{ secretValue: SECRET_VALUE }, { secretValue: 'x'.repeat(10000) }])( + 'delivers profile collections and secrets without persisting plaintext', + async ({ secretValue }) => { + const { publicKey, privateKey } = generateSecretsKeyPair(); + const envelope = encryptWithPublicKey(secretValue, publicKey); + const provider = createFakeProvider(); + const peer = new FakeSessionPeer(); + const stub = await startSecrets(provider, privateKey, peer); + const sessionId = 'workspace_11111111-1111-4111-8111-111111111111'; + const input = secretsPrepareInput(sessionId, { DATABASE_URL: envelope }); + const profile = { + runtimeSkills: [ + { name: 'review', rawMarkdown: '# Review', files: { 'guide.txt': 'guide' } }, + ], + runtimeAgents: [{ slug: 'reviewer', name: 'Reviewer', config: { prompt: 'Review' } }], + kiloCommands: [{ name: 'review-now', template: 'Review now' }], + }; + await stub.prepare({ + ...input, + spec: { ...input.spec, ...profile, runtimeIsolation: 'per-session' }, + }); + + const wrapper = await connectHello(provider, true); + const frame = await wrapper.next(); + if (frame?.type !== 'session.prepare') throw new Error('expected session.prepare'); + expect(frame.spec.env?.DATABASE_URL).toBe(secretValue); + expect(frame.spec.secretEnvKeys).toEqual(['DATABASE_URL']); + expect(frame.spec).toMatchObject(profile); + expect(frame.spec.runtimeIsolation).toBe('per-session'); + + const row = await readRouteRow(stub, sessionId); + expect(row?.spec ?? '').not.toContain(secretValue); + expect(row?.spec ?? '').not.toContain('secretEnvKeys'); + expect(row?.credential_source ?? '').toContain(envelope.encryptedData); + expect(row?.credential_source ?? '').not.toContain(secretValue); + } + ); + + it('fails without sending when the wrapper did not advertise named-secret redaction', async () => { + const { publicKey, privateKey } = generateSecretsKeyPair(); + const envelope = encryptWithPublicKey(SECRET_VALUE, publicKey); + const provider = createFakeProvider(); + const peer = new FakeSessionPeer(); + const stub = await startSecrets(provider, privateKey, peer); + const sessionId = 'workspace_22222222-2222-4222-8222-222222222222'; + await stub.prepare(secretsPrepareInput(sessionId, { DATABASE_URL: envelope })); + + const { result: wrapper, failures } = await captureRouteFailures(async () => { + const wrapper = await connectHello(provider, false); + await waitFor(() => + expect(peer.routeUpdatesFor(sessionId)).toContainEqual( + expect.objectContaining({ state: 'failed', reason: 'workspace_setup_failed' }) + ) + ); + return wrapper; + }); + expect(failures).toContainEqual( + expect.objectContaining({ + diagnosticEvent: 'route_failed', + reason: 'workspace_setup_failed', + stage: 'wrapper_redaction_unsupported', + }) + ); + expect(await wrapper.next(250)).toBeNull(); + expect(await readRouteRow(stub, sessionId)).toMatchObject({ + state: 'failed', + reason: 'workspace_setup_failed', + }); + }); + + it('sends a frame without redaction when every secret key is restored or dropped', async () => { + const { publicKey, privateKey } = generateSecretsKeyPair(); + const envelope = encryptWithPublicKey(SECRET_VALUE, publicKey); + const provider = createFakeProvider(); + const peer = new FakeSessionPeer(); + const stub = await startSecrets(provider, privateKey, peer); + const sessionId = 'workspace_66666666-6666-4666-8666-666666666666'; + // The grant does not project this key, so the overlay drops it and no secret + // value reaches the frame; a wrapper without named-secret redaction can still + // receive the frame. + await stub.prepare(secretsPrepareInput(sessionId, { KILOCODE_ORGANIZATION_ID: envelope })); + + const wrapper = await connectHello(provider, false); + const frame = await wrapper.next(); + if (frame?.type !== 'session.prepare') throw new Error('expected session.prepare'); + expect(frame.spec.secretEnvKeys).toBeUndefined(); + expect(frame.spec.env?.KILOCODE_ORGANIZATION_ID).toBeUndefined(); + expect(JSON.stringify(peer.routeUpdatesFor(sessionId))).not.toContain(SECRET_VALUE); + }); + + it('fails without sending when secrets cannot be decrypted, leaking no value', async () => { + const { publicKey } = generateSecretsKeyPair(); + const envelope = encryptWithPublicKey(SECRET_VALUE, publicKey); + const provider = createFakeProvider(); + const peer = new FakeSessionPeer(); + const stub = await startSecrets(provider, undefined, peer); + const sessionId = 'workspace_33333333-3333-4333-8333-333333333333'; + await stub.prepare(secretsPrepareInput(sessionId, { DATABASE_URL: envelope })); + + const { result: wrapper, failures } = await captureRouteFailures(async () => { + const wrapper = await connectHello(provider, true); + await waitFor(() => + expect(peer.routeUpdatesFor(sessionId)).toContainEqual( + expect.objectContaining({ state: 'failed', reason: 'workspace_setup_failed' }) + ) + ); + return wrapper; + }); + expect(failures).toContainEqual( + expect.objectContaining({ + diagnosticEvent: 'route_failed', + reason: 'workspace_setup_failed', + stage: 'frame_materialization_failed', + }) + ); + expect(await wrapper.next(250)).toBeNull(); + expect(JSON.stringify(peer.routeUpdatesFor(sessionId))).not.toContain(SECRET_VALUE); + expect(JSON.stringify(await readRouteRow(stub, sessionId))).not.toContain(SECRET_VALUE); + }); + + it('fails without sending when the merged frame env exceeds the schema', async () => { + const { publicKey, privateKey } = generateSecretsKeyPair(); + const envelope = encryptWithPublicKey('x'.repeat(10001), publicKey); + const provider = createFakeProvider(); + const peer = new FakeSessionPeer(); + const stub = await startSecrets(provider, privateKey, peer); + const sessionId = 'workspace_44444444-4444-4444-8444-444444444444'; + await stub.prepare(secretsPrepareInput(sessionId, { DATABASE_URL: envelope })); + + const { result: wrapper, failures } = await captureRouteFailures(async () => { + const wrapper = await connectHello(provider, true); + await waitFor(() => + expect(peer.routeUpdatesFor(sessionId)).toContainEqual( + expect.objectContaining({ state: 'failed', reason: 'workspace_setup_failed' }) + ) + ); + return wrapper; + }); + expect(failures).toContainEqual( + expect.objectContaining({ + diagnosticEvent: 'route_failed', + reason: 'workspace_setup_failed', + stage: 'frame_materialization_failed', + }) + ); + expect(await wrapper.next(250)).toBeNull(); + }); +}); diff --git a/services/cloud-agent-next/wrapper/src/control-plane/prepare.test.ts b/services/cloud-agent-next/wrapper/src/control-plane/prepare.test.ts index 4c7960eb457..02139a865cc 100644 --- a/services/cloud-agent-next/wrapper/src/control-plane/prepare.test.ts +++ b/services/cloud-agent-next/wrapper/src/control-plane/prepare.test.ts @@ -1,3 +1,8 @@ +import { existsSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { profileConfigPath } from './runtime-profile.js'; import { describe, expect, it, spyOn } from 'bun:test'; import { CONTROL_PLANE_TIMERS, @@ -106,6 +111,7 @@ function createHarness( hasGit?: boolean; capture?: boolean; beforeEnsure?: () => Promise; + homeRoot?: string; beforeInstall?: () => Promise; } = {} ): Harness { @@ -186,7 +192,7 @@ function createHarness( onNativeDiagnostic: (event, fields) => nativeDiagnostics.push({ event, fields }), runtimes, inheritedEnv: {}, - homeRoot: '/tmp/prepare-test-homes', + homeRoot: options.homeRoot ?? '/tmp/prepare-test-homes', allocationId: 'alloc-current', now: () => NOW, ...(options.capture @@ -1964,3 +1970,100 @@ describe('capturing a repository snapshot', () => { expect(withoutRepo.captureRequests).toEqual([]); }); }); + +describe('control-plane runtime profiles', () => { + it('materializes skills, agents and commands before Kilo starts and retains them through credential refresh', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'control-profile-')); + const profile = { + runtimeSkills: [ + { + name: 'review', + rawMarkdown: '---\nname: review\ndescription: Review\n---\nReview changes', + files: { 'scripts/check.sh': 'echo checked' }, + }, + ], + runtimeAgents: [ + { + slug: 'reviewer', + name: 'Reviewer', + config: { + prompt: 'Review carefully', + model: 'test-model', + variant: 'high', + permission: { bash: 'deny' }, + }, + }, + ], + kiloCommands: [ + { + name: 'review-now', + template: 'Review $ARGUMENTS', + agent: 'reviewer', + model: 'command-model', + subtask: true, + }, + ], + }; + const harness = createHarness(FAST_TIMERS, { + homeRoot: root, + beforeEnsure: async () => { + const env = harness.ensureInputs.at(-1)!.env; + if (!env.KILO_CONFIG) return; + expect( + await fs.readFile(path.join(env.HOME, '.kilocode/skills/review/SKILL.md'), 'utf8') + ).toBe(profile.runtimeSkills[0].rawMarkdown); + expect( + await fs.readFile(path.join(env.HOME, '.kilocode/skills/review/scripts/check.sh'), 'utf8') + ).toBe('echo checked'); + expect(JSON.parse(await fs.readFile(env.KILO_CONFIG, 'utf8'))).toMatchObject({ + agent: { + reviewer: { + mode: 'primary', + prompt: 'Review carefully', + model: 'kilo/test-model', + variant: 'high', + permission: { bash: 'deny' }, + }, + }, + command: { + 'review-now': { + template: 'Review $ARGUMENTS', + agent: 'reviewer', + model: 'kilo/command-model', + subtask: true, + }, + }, + }); + }, + }); + try { + const spec = routeSpec({ ...profile, runtimeIsolation: 'per-session' }); + await harness.manager.prepare(spec); + expect(harness.frames.at(-1)?.type).toBe('session.ready'); + const env = harness.ensureInputs[0].env; + expect(env.KILO_CONFIG).toBe(profileConfigPath(env.HOME)); + await harness.manager.installCredentials({ + sessionId: spec.sessionId, + kilo: { token: 'rotated-token' }, + }); + expect(harness.installCalls[0].env.KILO_CONFIG).toBe(env.KILO_CONFIG); + expect(harness.installCalls[0].env.KILOCODE_TOKEN).toBe('rotated-token'); + expect( + JSON.parse(await fs.readFile(env.KILO_CONFIG, 'utf8')).command['review-now'].agent + ).toBe('reviewer'); + const sibling = routeSpec({ + sessionId: 'ses_22222222222222222222222222', + runtimeIsolation: 'per-session', + }); + await harness.manager.prepare(sibling); + const siblingEnv = harness.ensureInputs[1].env; + expect(siblingEnv.HOME).not.toBe(env.HOME); + expect(siblingEnv.KILO_CONFIG).toBeUndefined(); + expect(existsSync(path.join(siblingEnv.HOME, '.kilocode/skills/review/SKILL.md'))).toBe( + false + ); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/services/cloud-agent-next/wrapper/src/control-plane/prepare.ts b/services/cloud-agent-next/wrapper/src/control-plane/prepare.ts index 1ca723489c2..a5668804e80 100644 --- a/services/cloud-agent-next/wrapper/src/control-plane/prepare.ts +++ b/services/cloud-agent-next/wrapper/src/control-plane/prepare.ts @@ -1,3 +1,4 @@ +import { materializeRuntimeProfile, withRuntimeProfileEnvironment } from './runtime-profile.js'; import { createHash } from 'node:crypto'; import fs from 'node:fs/promises'; import os from 'node:os'; @@ -933,13 +934,16 @@ export function createPreparationManager(deps: PrepareDeps): PreparationManager // Built inside the failure scope: an over-limit CLI config must surface as // `session.failed`, not reject the prepare promise (which the caller // swallows while the route waits out its timeout). - const env = buildWorktreeKiloEnvironment( - directory, - home, - kiloAuth, - spec.env ?? {}, - inheritedEnv, - spec.mcp + const env = withRuntimeProfileEnvironment( + buildWorktreeKiloEnvironment( + directory, + home, + kiloAuth, + spec.env ?? {}, + inheritedEnv, + spec.mcp + ), + spec ); const redact = createSecretRedactor( inheritedEnv, @@ -966,6 +970,7 @@ export function createPreparationManager(deps: PrepareDeps): PreparationManager if (owner.released) return; currentStep = 'kilo_runtime'; emitProgress(sessionId, 'kilo_runtime'); + if (spec.runtimeIsolation === 'per-session') await materializeRuntimeProfile(home, spec); const client = await withRuntimeStartRetry( key, () => @@ -1016,17 +1021,20 @@ export function createPreparationManager(deps: PrepareDeps): PreparationManager ): Promise { const route = prepared.get(credentials.sessionId); if (!route) return; - const nextEnv = buildWorktreeKiloEnvironment( - route.directory, - route.home, - { - ...route.kilo, - token: credentials.proxy?.handle ?? credentials.kilo.token, - targets: credentials.proxy?.targets ?? route.kilo.targets, - }, - route.spec.env ?? {}, - inheritedEnv, - route.spec.mcp + const nextEnv = withRuntimeProfileEnvironment( + buildWorktreeKiloEnvironment( + route.directory, + route.home, + { + ...route.kilo, + token: credentials.proxy?.handle ?? credentials.kilo.token, + targets: credentials.proxy?.targets ?? route.kilo.targets, + }, + route.spec.env ?? {}, + inheritedEnv, + route.spec.mcp + ), + route.spec ); route.env = nextEnv; await deps.runtimes.installCredentials(route.key, nextEnv); diff --git a/services/cloud-agent-next/wrapper/src/control-plane/runtime-profile.real.test.ts b/services/cloud-agent-next/wrapper/src/control-plane/runtime-profile.real.test.ts new file mode 100644 index 00000000000..9f1a9ed1c81 --- /dev/null +++ b/services/cloud-agent-next/wrapper/src/control-plane/runtime-profile.real.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'bun:test'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { buildWorktreeKiloEnvironment } from '../control/worktree-runtime.js'; +import { materializeRuntimeProfile, withRuntimeProfileEnvironment } from './runtime-profile.js'; + +const binary = process.env.KILO_781_BINARY; +const suite = binary ? describe : describe.skip; + +suite('pinned Kilo profile discovery', () => { + it('discovers profile skills, custom agents and commands from the control-plane runtime home', async () => { + if (!binary) throw new Error('KILO_781_BINARY is required'); + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'profile-kilo-781-')); + const home = path.join(root, 'home'); + const directory = path.join(root, 'workspace'); + const profile = { + runtimeSkills: [ + { + name: 'profile-review', + rawMarkdown: + '---\nname: profile-review\ndescription: Review a profile fixture\n---\nRead scripts/check.sh before reviewing.', + files: { 'scripts/check.sh': 'echo profile-skill' }, + }, + ], + runtimeAgents: [ + { + slug: 'profile-reviewer', + name: 'Profile reviewer', + config: { prompt: 'Review profile fixtures', mode: 'primary' as const }, + }, + ], + kiloCommands: [ + { + name: 'profile-check', + template: 'Review the profile fixture', + agent: 'profile-reviewer', + }, + ], + }; + await fs.mkdir(directory, { recursive: true }); + await materializeRuntimeProfile(home, profile); + const env = withRuntimeProfileEnvironment( + buildWorktreeKiloEnvironment( + directory, + home, + { + scopeId: 'profile-test', + token: 'local-test', + targets: { + backendBaseUrl: 'http://127.0.0.1:1', + providerBaseUrl: 'http://127.0.0.1:1', + sessionIngestBaseUrl: 'http://127.0.0.1:1', + }, + }, + {}, + { PATH: process.env.PATH, TMPDIR: os.tmpdir() } + ), + profile + ); + const proc = Bun.spawn([binary, 'serve', '--hostname=127.0.0.1', '--port=0'], { + cwd: directory, + env, + stdout: 'pipe', + stderr: 'ignore', + }); + const signal = AbortSignal.timeout(25_000); + const expired = new Promise((_, reject) => + signal.addEventListener( + 'abort', + () => reject(new Error('Kilo profile discovery timed out')), + { once: true } + ) + ); + try { + const reader = proc.stdout.getReader(); + let output = ''; + let url: string | undefined; + while (!url) { + const next = await Promise.race([reader.read(), expired]); + if (next.done) throw new Error('Kilo exited before listening'); + output = (output + new TextDecoder().decode(next.value)).slice(-8192); + url = /kilo server listening on (http:\/\/127\.0\.0\.1:\d+)/.exec(output)?.[1]; + } + const read = async (endpoint: string): Promise => { + const target = new URL(endpoint, url); + target.searchParams.set('directory', directory); + const response = await fetch(target, { signal }); + expect(response.ok).toBe(true); + return response.json(); + }; + expect(await read('/skill')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'profile-review', + description: 'Review a profile fixture', + }), + ]) + ); + expect(await read('/agent')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'profile-reviewer', mode: 'primary' }), + ]) + ); + expect(await read('/command')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'profile-check', + agent: 'profile-reviewer', + template: 'Review the profile fixture', + }), + expect.objectContaining({ name: 'profile-review', source: 'skill' }), + ]) + ); + expect( + await fs.readFile( + path.join(home, '.kilocode/skills/profile-review/scripts/check.sh'), + 'utf8' + ) + ).toBe('echo profile-skill'); + } finally { + proc.kill(); + await proc.exited; + await fs.rm(root, { recursive: true, force: true }); + } + }, 30_000); +}); diff --git a/services/cloud-agent-next/wrapper/src/control-plane/runtime-profile.test.ts b/services/cloud-agent-next/wrapper/src/control-plane/runtime-profile.test.ts new file mode 100644 index 00000000000..c68e7f46df6 --- /dev/null +++ b/services/cloud-agent-next/wrapper/src/control-plane/runtime-profile.test.ts @@ -0,0 +1,87 @@ +import { writeRuntimeSkills } from '../runtime-skills.js'; +import { existsSync } from 'node:fs'; +import { describe, expect, it } from 'bun:test'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + materializeRuntimeProfile, + profileConfigPath, + withRuntimeProfileEnvironment, +} from './runtime-profile.js'; + +describe('runtime profile materialization', () => { + it('writes large valid command templates to disk without expanding process env', async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'runtime-profile-')); + try { + const profile = { kiloCommands: [{ name: 'long-review', template: 'x'.repeat(100_000) }] }; + await materializeRuntimeProfile(home, profile); + const env = withRuntimeProfileEnvironment( + { HOME: home, KILO_CONFIG_CONTENT: '{"provider":{}}' }, + profile + ); + expect(env.KILO_CONFIG_CONTENT).toBe('{"provider":{}}'); + expect(env.KILO_CONFIG).toBe(profileConfigPath(home)); + expect( + JSON.parse(await fs.readFile(env.KILO_CONFIG, 'utf8')).command['long-review'].template + ).toBe(profile.kiloCommands[0].template); + expect((await fs.stat(env.KILO_CONFIG)).mode & 0o777).toBe(0o600); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } + }); + + it('replaces stale skill companion files and clears absent profile artifacts on a restored home', async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'runtime-profile-')); + try { + await materializeRuntimeProfile(home, { + runtimeSkills: [{ name: 'review', rawMarkdown: 'old', files: { 'obsolete.txt': 'stale' } }], + runtimeAgents: [{ slug: 'reviewer', name: 'Reviewer', config: {} }], + }); + await materializeRuntimeProfile(home, { + runtimeSkills: [{ name: 'review', rawMarkdown: 'new' }], + }); + expect(await fs.readFile(path.join(home, '.kilocode/skills/review/SKILL.md'), 'utf8')).toBe( + 'new' + ); + expect(existsSync(path.join(home, '.kilocode/skills/review/obsolete.txt'))).toBe(false); + expect(existsSync(profileConfigPath(home))).toBe(false); + await materializeRuntimeProfile(home, {}); + expect(existsSync(path.join(home, '.kilocode/skills/review/SKILL.md'))).toBe(false); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } + }); +}); + +describe('legacy skill writing', () => { + it('skips unsafe companion files while keeping the skill and its safe files', async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'runtime-profile-')); + try { + await writeRuntimeSkills(home, [ + { + name: 'review', + rawMarkdown: '# Review', + files: { + '../outside.txt': 'unsafe', + 'SKILL.md': 'override', + '': 'empty path', + ['x'.repeat(201)]: 'overlong path', + ['x'.repeat(200)]: 'valid boundary', + 'nested/guide.txt': 'safe', + }, + }, + ]); + const skillDir = path.join(home, '.kilocode/skills/review'); + expect(await fs.readFile(path.join(skillDir, 'SKILL.md'), 'utf8')).toBe('# Review'); + expect(await fs.readFile(path.join(skillDir, 'nested/guide.txt'), 'utf8')).toBe('safe'); + expect(await fs.readFile(path.join(skillDir, 'x'.repeat(200)), 'utf8')).toBe( + 'valid boundary' + ); + expect(existsSync(path.join(skillDir, 'x'.repeat(201)))).toBe(false); + expect(existsSync(path.join(home, '.kilocode/skills/outside.txt'))).toBe(false); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } + }); +}); diff --git a/services/cloud-agent-next/wrapper/src/control-plane/runtime-profile.ts b/services/cloud-agent-next/wrapper/src/control-plane/runtime-profile.ts new file mode 100644 index 00000000000..30bcfc07853 --- /dev/null +++ b/services/cloud-agent-next/wrapper/src/control-plane/runtime-profile.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { ControlPlaneRouteSpec } from '../../../src/shared/control-plane-protocol.js'; +import { + buildAgentEntryFromRuntimeAgent, + buildKiloCommandsConfiguration, +} from '../../../src/shared/runtime-profile-config.js'; +import { writeRuntimeSkills } from '../runtime-skills.js'; + +type RuntimeProfile = Pick< + ControlPlaneRouteSpec, + 'runtimeSkills' | 'runtimeAgents' | 'kiloCommands' +>; + +export function profileConfigPath(home: string): string { + return path.join(home, '.config', 'kilo', 'profile.json'); +} + +/** Disk-backed profile config keeps large prompts/templates out of the process environment. */ +export function withRuntimeProfileEnvironment( + env: Record, + profile: RuntimeProfile +): Record { + if (!profile.runtimeAgents?.length && !profile.kiloCommands?.length) return env; + return { ...env, KILO_CONFIG: profileConfigPath(env.HOME) }; +} + +export async function materializeRuntimeProfile( + home: string, + profile: RuntimeProfile +): Promise { + await fs.rm(path.join(home, '.kilocode', 'skills'), { recursive: true, force: true }); + await writeRuntimeSkills(home, profile.runtimeSkills); + const config = { + ...(profile.runtimeAgents?.length + ? { + agent: Object.fromEntries( + profile.runtimeAgents.map(agent => [agent.slug, buildAgentEntryFromRuntimeAgent(agent)]) + ), + } + : {}), + ...(profile.kiloCommands?.length + ? { command: buildKiloCommandsConfiguration(profile.kiloCommands) } + : {}), + }; + const file = profileConfigPath(home); + if (Object.keys(config).length === 0) { + await fs.rm(file, { force: true }); + return; + } + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, JSON.stringify(config), { mode: 0o600 }); +} diff --git a/services/cloud-agent-next/wrapper/src/runtime-skills.ts b/services/cloud-agent-next/wrapper/src/runtime-skills.ts new file mode 100644 index 00000000000..bbc66a4275c --- /dev/null +++ b/services/cloud-agent-next/wrapper/src/runtime-skills.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + isSafeSkillFilePath, + RuntimeSkillsSchema, + type RuntimeSkillInput, +} from '../../src/shared/runtime-profile.js'; + +/** Write validated skills under a session-owned home. */ +export async function writeRuntimeSkills( + home: string, + skills: readonly RuntimeSkillInput[] = [] +): Promise { + if (!skills.length) return; + // Legacy session/ready payloads can contain unsafe companion paths. Preserve + // their skip behavior; the control-plane request schema rejects these earlier. + const validated = RuntimeSkillsSchema.parse( + skills.map(skill => ({ + ...skill, + ...(skill.files === undefined + ? {} + : { + files: Object.fromEntries( + Object.entries(skill.files).filter(([name]) => isSafeSkillFilePath(name)) + ), + }), + })) + ); + const baseDir = path.join(home, '.kilocode', 'skills'); + + for (const skill of validated) { + const skillDir = path.join(baseDir, skill.name); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, 'SKILL.md'), skill.rawMarkdown); + for (const [relativePath, content] of Object.entries(skill.files ?? {})) { + const targetPath = path.join(skillDir, relativePath); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, content); + } + } +} diff --git a/services/cloud-agent-next/wrapper/src/session-bootstrap.ts b/services/cloud-agent-next/wrapper/src/session-bootstrap.ts index 3c2858d4228..76d7206ab39 100644 --- a/services/cloud-agent-next/wrapper/src/session-bootstrap.ts +++ b/services/cloud-agent-next/wrapper/src/session-bootstrap.ts @@ -1,3 +1,4 @@ +import { writeRuntimeSkills } from './runtime-skills.js'; import { randomUUID } from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; @@ -689,35 +690,6 @@ async function writeCloudAgentRules(request: WrapperSessionReadyRequest): Promis ); } -function isSafeSkillFilePath(relativePath: string): boolean { - if (relativePath.length === 0 || relativePath.length > 200) return false; - if (relativePath.startsWith('/')) return false; - if (relativePath.includes('..')) return false; - if (relativePath.includes('\\') || relativePath.includes('\0')) return false; - if (relativePath.toLowerCase() === 'skill.md') return false; - return /^[a-zA-Z0-9._\-/]+$/.test(relativePath); -} - -async function writeRuntimeSkills(request: WrapperSessionReadyRequest): Promise { - const skills = request.materialized.runtimeSkills; - if (!skills?.length) return; - - const baseDir = path.join(request.workspace.sessionHome, '.kilocode/skills'); - await fs.mkdir(baseDir, { recursive: true }); - - for (const skill of skills) { - const skillDir = path.join(baseDir, skill.name); - await fs.mkdir(skillDir, { recursive: true }); - await fs.writeFile(path.join(skillDir, 'SKILL.md'), skill.rawMarkdown); - for (const [relativePath, content] of Object.entries(skill.files ?? {})) { - if (!isSafeSkillFilePath(relativePath)) continue; - const targetPath = path.join(skillDir, relativePath); - await fs.mkdir(path.dirname(targetPath), { recursive: true }); - await fs.writeFile(targetPath, content); - } - } -} - async function bootstrapEmptyKiloSession( request: WrapperSessionReadyRequest, restore: typeof restoreSession @@ -1297,7 +1269,7 @@ async function prepareWrapperBootstrapWorkspaceWithinDeadline( `bootstrap branch preparation ready kiloSessionId=${request.kiloSessionId} branchName=${request.workspace.branchName}` ); - await writeRuntimeSkills(request); + await writeRuntimeSkills(request.workspace.sessionHome, request.materialized.runtimeSkills); progress?.( 'kilo_session',