Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions services/cloud-agent-next/docs/control-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
121 changes: 121 additions & 0 deletions services/cloud-agent-next/src/control-plane/sandbox/frame-env.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
64 changes: 64 additions & 0 deletions services/cloud-agent-next/src/control-plane/sandbox/frame-env.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | undefined;
/** The DO-private encrypted-secrets snapshot, opaque to the shared schema. */
encryptedSecrets: Record<string, unknown> | undefined;
privateKey: string | undefined;
};

export type FrameEnvResult = {
/** The merged env; the same object when there are no secrets. */
env: Record<string, string> | 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<string>();
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 };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
22 changes: 15 additions & 7 deletions services/cloud-agent-next/src/control-plane/sandbox/repo-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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<RepoKeyInput, 'repoUrl' | 'directory'>
route: Pick<RepoKeyInput, 'repoUrl' | 'directory'> & { 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' };
}
Expand All @@ -58,16 +64,18 @@ export function repoSnapshotEligibility(

export function repoSnapshotEligible(
gate: RepoSnapshotGate,
route: Pick<RepoKeyInput, 'repoUrl' | 'directory'>
route: Pick<RepoKeyInput, 'repoUrl' | 'directory'> & { 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<string | null> {
if (input.secret === null || input.repoUrl === undefined) return null;
Expand Down
Loading
Loading