Skip to content
Open
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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,10 @@ jobs:
if: matrix.part == 'unit'
run: pnpm --filter cloud-agent-next test

- name: Build runtime artifacts for Worker integration tests
if: matrix.part == 'workers'
run: pnpm --filter cloud-agent-next build:wrapper

- name: Run cloud-agent-next Workers tests
if: matrix.part == 'workers'
run: pnpm --filter cloud-agent-next test:integration
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/deploy-workers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ jobs:
node-version-file: '.nvmrc'
cache: 'pnpm'

- name: Setup Bun
if: inputs.worker == 'services/cloud-agent-next'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: 1.4.4

- name: Install dependencies
working-directory: ${{ inputs.worker }}
run: pnpm install --frozen-lockfile
Expand Down Expand Up @@ -338,6 +344,12 @@ jobs:
node-version-file: '.nvmrc'
cache: 'pnpm'

- name: Setup Bun
if: matrix.worker == 'services/cloud-agent-next'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: 1.4.4

- name: Install dependencies
working-directory: ${{ matrix.worker }}
run: pnpm install --frozen-lockfile
Expand Down
1 change: 1 addition & 0 deletions .kilo/skills/kilo-mcp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,5 +98,6 @@ exist as an org-scoped mirror under `organizations`, for example
- `user`
- `userExports`
- `userFeedback`
- `vercelCompute`
- `webhookTriggers`
- `workspaceFolders`
2 changes: 1 addition & 1 deletion ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,10 +471,10 @@ The key is team-scoped for all topics and valid in both the sandbox and producti
- `WORKSPACE_PATH` - Filesystem path of the agent workspace. [SERVER]
- `SESSION_ID` - Reserved session identifier for the `cloud-agent-next` runtime; reserved in `RESERVED_ENV_VARS`. [SERVER]
- `SANDBOX_SELECTION_IDS` - Comma-separated user or org IDs allowed to pick a Cloud Agent sandbox destination on the new-session page. Empty admits nobody. `*` includes personal accounts. Production `wrangler.jsonc` sets the allowlist, so a deploy overwrites the Cloudflare dashboard value. Wrangler `dev` and `.dev.vars.example` default to `*`. [SERVER]
- `VERCEL_SANDBOX_ORG_IDS` - Comma-separated org IDs routed to Vercel sandboxes. Empty is off. `*` includes personal accounts. [SERVER]
- `CLOUDFLARE_CONTAINERS_ORG_IDS` - Comma-separated org IDs enrolled for the DO-managed Cloudflare Containers runtime, which is also the default sandbox destination for isolated control-plane sessions. Empty is off. `*` includes every organization and personal account. Production `wrangler.jsonc` sets `*` so a deploy overwrites the Cloudflare dashboard value; wrangler `dev` also sets `*`. [SERVER]
- `PREVIEW_HOST_SUFFIX` - Hostname suffix the Cloud Agent Next Worker dispatches preview hosts under; a preview host is `p<port>-<workspace-uuid-hex>.<suffix>`. Unset or empty disables preview dispatch and the `/preview/open` hop, and the web app hides the preview button (it reads availability from the Worker's `getPreviewOptions`). The web app needs no preview setting. Production `wrangler.jsonc` sets `cloud-agent.kiloapps.ai`. `services/cloud-agent-next/.dev.vars.example` sets `localhost`. [SERVER]
- `PREVIEW_ENABLED_IDS` - Comma-separated user or org IDs that the Cloud Agent Next Worker shows the preview button and preview links to. `*` includes personal accounts. Unset or empty shows nobody. A UI gate only: `getPreviewOptions` answers `enabled: false` for other owners, but the `/preview/open` hop and the preview host do not check it. The org ID comes from the web route and is not verified. Production `wrangler.jsonc` sets `*`, so a deploy overwrites the Cloudflare dashboard value. Wrangler `dev` and `.dev.vars.example` default to `*`. [SERVER]
- `BYOC_VERCEL_IDS` - Comma-separated user or organization IDs enrolled in customer-paid Vercel compute. Empty is off; `*` enrolls both personal accounts and organizations. An enrolled owner fails closed when its ready credential is absent. [SERVER]
- `HOME` - Reserved in `RESERVED_ENV_VARS` for cloud-agent-next session home management. [SYSTEM]

### Gastown
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/app/(app)/organizations/[id]/cloud/compute/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { notFound, redirect } from 'next/navigation';
import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout';

export default async function OrganizationComputePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
return (
<OrganizationByPageLayout
params={params}
render={({ organization, role, isGlobalAdmin }) => {
if (!isGlobalAdmin && role !== 'owner' && role !== 'admin') notFound();
redirect(`/organizations/${organization.id}/cloud?settings=compute`);
}}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
import { timingSafeEqual } from '@kilocode/encryption';
import { and, eq, type SQL } from 'drizzle-orm';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { z } from 'zod';

import {
organization_vercel_compute_credentials,
type OrganizationVercelComputeCredential,
} from '@kilocode/db/schema';
import { db } from '@kilocode/web-shared/lib/drizzle';
import { INTERNAL_API_SECRET } from '@kilocode/web-shared/lib/config.server';
import { applyVercelComputeStatusProjection } from '@/lib/cloud-agent-next/vercel-compute-status';

const SetupStatusSchema = z.enum(['pending', 'building', 'ready', 'failed']);
const SetupStepSchema = z
.enum([
'validating_access',
'create_builder',
'install_system_dependencies',
'install_node_dependencies',
'upload_runtime_artifacts',
'verify_runtime_artifacts',
'snapshot_builder',
'create_validator',
'launch_validator_wrapper',
'verify_validator_call_home',
'stop_validator',
'confirm_terminal',
])
.nullable();

const CredentialOwnerSchema = z.union([
z.object({ organizationId: z.uuid(), userId: z.never().optional() }),
z.object({ organizationId: z.never().optional(), userId: z.string().min(1) }),
]);

const CredentialUpdateSchema = z
.object({
buildGeneration: z.uuid(),
setupStatus: SetupStatusSchema,
setupStep: SetupStepSchema,
setupError: z
.string()
.regex(/^[a-z0-9_]+$/)
.max(96)
.nullable(),
teamSlug: z.string().min(1).max(256).nullable(),
projectSlug: z.string().min(1).max(256).nullable(),
runtimeBuildId: z.string().min(1).max(256).nullable(),
runtimeSnapshotId: z.string().min(1).max(256).nullable(),
setupStartedAt: z.iso.datetime().nullable(),
setupCompletedAt: z.iso.datetime().nullable(),
runtimeWrapperVersion: z.string().min(1).max(64).nullable().optional(),
runtimeReleasedAt: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.nullable()
.optional(),
runtimeDigest: z.string().min(1).max(512).nullable().optional(),
})
.and(CredentialOwnerSchema)
.superRefine((value, ctx) => {
if (value.setupStatus !== 'ready') return;
const required: Array<keyof typeof value> = [
'projectSlug',
'runtimeSnapshotId',
'setupCompletedAt',
];
for (const field of required) {
if (!value[field]) {
ctx.addIssue({
code: 'custom',
path: [field],
message: 'Ready status requires the completed runtime fields',
});
}
}
});

function responseHeaders(): HeadersInit {
return { 'Cache-Control': 'no-store' };
}

function unauthorized(): NextResponse {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401, headers: responseHeaders() });
}

function isAuthorized(request: NextRequest): boolean {
const presented = request.headers.get('x-internal-api-key');
return Boolean(
INTERNAL_API_SECRET && presented && timingSafeEqual(presented, INTERNAL_API_SECRET)
);
}

function toCredentialResponse(row: OrganizationVercelComputeCredential) {
const response = {
credentialId: row.id,
tokenEncrypted: row.token_encrypted,
tokenScope: row.token_scope,
teamId: row.team_id,
projectId: row.project_id,
teamSlug: row.team_slug,
projectSlug: row.project_slug,
setupStatus: row.setup_status,
setupStep: row.setup_step,
setupError: row.setup_error,
buildGeneration: row.build_generation,
runtimeBuildId: row.runtime_build_id,
runtimeSnapshotId: row.runtime_snapshot_id,
runtimeWrapperVersion: row.runtime_wrapper_version,
runtimeReleasedAt: row.runtime_released_at,
runtimeDigest: row.runtime_digest,
upgradeStatus: row.upgrade_status,
upgradeStep: row.upgrade_step,
upgradeError: row.upgrade_error,
setupStartedAt: row.setup_started_at ? new Date(row.setup_started_at).toISOString() : null,
setupCompletedAt: row.setup_completed_at
? new Date(row.setup_completed_at).toISOString()
: null,
};

if (row.organization_id !== null && row.user_id === null) {
return { ...response, organizationId: row.organization_id };
}
if (row.organization_id === null && row.user_id !== null) {
return { ...response, userId: row.user_id };
}
throw new Error('Vercel compute credential has invalid ownership');
}

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ credentialId: string }> }
): Promise<NextResponse> {
if (!isAuthorized(request)) return unauthorized();

const { credentialId } = await params;
if (!z.uuid().safeParse(credentialId).success) {
return NextResponse.json(
{ error: 'Invalid request' },
{ status: 400, headers: responseHeaders() }
);
}

const organizationIdParam = request.nextUrl.searchParams.get('organizationId');
const userIdParam = request.nextUrl.searchParams.get('userId');
if ((organizationIdParam === null) === (userIdParam === null)) {
return NextResponse.json(
{ error: 'Invalid request' },
{ status: 400, headers: responseHeaders() }
);
}

let ownerFilter: SQL;
if (organizationIdParam !== null) {
const organizationIdResult = z.uuid().safeParse(organizationIdParam);
if (!organizationIdResult.success) {
return NextResponse.json(
{ error: 'Invalid request' },
{ status: 400, headers: responseHeaders() }
);
}
ownerFilter = eq(
organization_vercel_compute_credentials.organization_id,
organizationIdResult.data
);
} else {
const userIdResult = z.string().min(1).safeParse(userIdParam);
if (!userIdResult.success) {
return NextResponse.json(
{ error: 'Invalid request' },
{ status: 400, headers: responseHeaders() }
);
}
ownerFilter = eq(organization_vercel_compute_credentials.user_id, userIdResult.data);
}

const row = await db.query.organization_vercel_compute_credentials.findFirst({
where: and(eq(organization_vercel_compute_credentials.id, credentialId), ownerFilter),
});
if (!row) {
return NextResponse.json(
{ error: 'Credential not found' },
{ status: 404, headers: responseHeaders() }
);
}

return NextResponse.json(toCredentialResponse(row), { headers: responseHeaders() });
}

export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ credentialId: string }> }
): Promise<NextResponse> {
if (!isAuthorized(request)) return unauthorized();

const { credentialId } = await params;
if (!z.uuid().safeParse(credentialId).success) {
return NextResponse.json(
{ error: 'Invalid request' },
{ status: 400, headers: responseHeaders() }
);
}

const parsed = CredentialUpdateSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid status update' },
{ status: 400, headers: responseHeaders() }
);
}

const { data } = parsed;
let ownerFilter: SQL;
if (data.organizationId !== undefined) {
ownerFilter = eq(organization_vercel_compute_credentials.organization_id, data.organizationId);
} else if (data.userId !== undefined) {
ownerFilter = eq(organization_vercel_compute_credentials.user_id, data.userId);
} else {
return NextResponse.json(
{ error: 'Invalid status update' },
{ status: 400, headers: responseHeaders() }
);
}

const current = await db.query.organization_vercel_compute_credentials.findFirst({
where: and(
eq(organization_vercel_compute_credentials.id, credentialId),
ownerFilter,
eq(organization_vercel_compute_credentials.build_generation, data.buildGeneration)
),
});
if (!current) {
return NextResponse.json({ updated: false }, { headers: responseHeaders() });
}

const next = applyVercelComputeStatusProjection(current, {
setupStatus: data.setupStatus,
setupStep: data.setupStep,
setupError: data.setupError,
teamSlug: data.teamSlug,
projectSlug: data.projectSlug,
runtimeBuildId: data.runtimeBuildId,
runtimeSnapshotId: data.runtimeSnapshotId,
setupStartedAt: data.setupStartedAt,
setupCompletedAt: data.setupCompletedAt,
runtimeWrapperVersion: data.runtimeWrapperVersion ?? null,
runtimeReleasedAt: data.runtimeReleasedAt ?? null,
runtimeDigest: data.runtimeDigest ?? null,
});

const [updated] = await db
.update(organization_vercel_compute_credentials)
.set({
...next,
updated_at: new Date().toISOString(),
})
.where(
and(
eq(organization_vercel_compute_credentials.id, credentialId),
ownerFilter,
eq(organization_vercel_compute_credentials.build_generation, data.buildGeneration)
)
)
.returning({ id: organization_vercel_compute_credentials.id });

return NextResponse.json({ updated: Boolean(updated) }, { headers: responseHeaders() });
}
Loading
Loading