diff --git a/packages/api-client/src/schema.d.ts b/packages/api-client/src/schema.d.ts index cc74eb6..7cf448b 100644 --- a/packages/api-client/src/schema.d.ts +++ b/packages/api-client/src/schema.d.ts @@ -1158,6 +1158,52 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/vm-image-verifications": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Verify a public VM image + * @description Starts a tenant-scoped background verification. It creates a temporary Factorize-owned VM, checks SSH and the selected harness prerequisites, then deletes the VM. It never executes a job prompt. + * + * Requires the jobs:write scope. + * + * Accepts a scoped bearer token or interactive owner session. + */ + post: operations["post_api_v1_vm_image_verifications"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/vm-image-verifications/{verificationId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get VM image verification progress + * @description Requires the jobs:read scope. + * + * Accepts a scoped bearer token or interactive owner session. + */ + get: operations["get_api_v1_vm_image_verifications_verificationId"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/jobs": { parameters: { query?: never; @@ -1905,6 +1951,7 @@ export interface components { concurrencyLimit: number; executionConfigId: string; tags?: string[]; + vmImage?: string; model?: string; effort?: string; /** @default [] */ @@ -4621,6 +4668,7 @@ export interface operations { workspace?: string; cwd?: string; agentKind: string; + image?: string; }; executionConfigId: string; agentKind: string; @@ -4778,6 +4826,95 @@ export interface operations { 503: components["responses"]["Error503"]; }; }; + post_api_v1_vm_image_verifications: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + connectionId: string; + image: string; + tags: string[]; + /** @enum {string} */ + agentKind: "codex" | "claude" | "pi"; + }; + }; + }; + responses: { + /** @description Verification operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** Format: uuid */ + id: string; + /** @enum {string} */ + status: "queued" | "creating" | "checking_ssh" | "checking_agent" | "cleaning_up" | "succeeded" | "failed" | "cleanup_failed"; + phase: string; + errorKind: string | null; + error: string | null; + createdAt: string; + updatedAt: string; + }; + }; + }; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 409: components["responses"]["Error409"]; + 415: components["responses"]["Error415"]; + 429: components["responses"]["Error429"]; + 500: components["responses"]["Error500"]; + 503: components["responses"]["Error503"]; + }; + }; + get_api_v1_vm_image_verifications_verificationId: { + parameters: { + query?: never; + header?: never; + path: { + verificationId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Verification operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** Format: uuid */ + id: string; + /** @enum {string} */ + status: "queued" | "creating" | "checking_ssh" | "checking_agent" | "cleaning_up" | "succeeded" | "failed" | "cleanup_failed"; + phase: string; + errorKind: string | null; + error: string | null; + createdAt: string; + updatedAt: string; + }; + }; + }; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 409: components["responses"]["Error409"]; + 429: components["responses"]["Error429"]; + 500: components["responses"]["Error500"]; + 503: components["responses"]["Error503"]; + }; + }; get_api_v1_jobs: { parameters: { query?: never; @@ -4811,6 +4948,7 @@ export interface operations { workspace?: string; cwd?: string; agentKind: string; + image?: string; }; executionConfigId: string; agentKind: string; @@ -5004,6 +5142,7 @@ export interface operations { workspace?: string; cwd?: string; agentKind: string; + image?: string; }; executionConfigId: string; agentKind: string; @@ -5369,6 +5508,7 @@ export interface operations { workspace?: string; cwd?: string; agentKind: string; + image?: string; }; executionConfigId: string; agentKind: string; @@ -5547,6 +5687,7 @@ export interface operations { concurrencyLimit?: number; executionConfigId: string; tags?: string[]; + vmImage?: string; model?: string; effort?: string; /** @default [] */ @@ -5677,6 +5818,7 @@ export interface operations { workspace?: string; cwd?: string; agentKind: string; + image?: string; }; executionConfigId: string; agentKind: string; diff --git a/packages/api/migrations/0024_vm_image_verifications.sql b/packages/api/migrations/0024_vm_image_verifications.sql new file mode 100644 index 0000000..142100e --- /dev/null +++ b/packages/api/migrations/0024_vm_image_verifications.sql @@ -0,0 +1,15 @@ +CREATE TABLE app.vm_image_verifications ( + tenant_id uuid NOT NULL REFERENCES app.tenants(id) ON DELETE CASCADE, + id uuid NOT NULL, + job_id uuid, + status text NOT NULL CHECK (status IN ('queued','creating','checking_ssh','checking_agent','cleaning_up','succeeded','failed','cleanup_failed')), + phase text NOT NULL, + config jsonb NOT NULL, + error_kind text, + error text, + vm_name text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id,id) +); +CREATE INDEX vm_image_verifications_status_idx ON app.vm_image_verifications(status,updated_at); diff --git a/packages/api/src/api-contract.ts b/packages/api/src/api-contract.ts index 989f3dc..50fc4a0 100644 --- a/packages/api/src/api-contract.ts +++ b/packages/api/src/api-contract.ts @@ -14,6 +14,8 @@ import { jobConditionsTestSchema, listRunsSchema, scopeSchema, + vmImageVerificationInput, + vmImageVerificationResponse, } from "./flow-schemas"; import { traceReplaySchema } from "./trace-replay-schemas"; import document from "./api-document.json"; @@ -661,6 +663,8 @@ export const API_ROUTES: RouteContract[] = [ route("DELETE", "/api/v1/skills/{skillId}", "skills:write", { summary: "Delete an unused global skill", responses: { "200": { description: "Deleted" } } }, (service, { params, query }) => service.deleteSkill(params.skillId, query.expectedRevision), { query: z.object({ expectedRevision: z.coerce.number().int().positive() }) }), route("GET", "/api/v1/skills/{skillId}/jobs", "skills:read", { summary: "List jobs using a skill", responses: { "200": { description: "Jobs", content: { "application/json": { schema: jsonSchema(skillJobsResponse, "output") } } } } }, (service, { params }) => service.listSkillJobs(params.skillId)), route("POST", "/api/v1/jobs/{jobId}/project", "flows:write", { summary: "Move a job to a project", responses: { "200": { description: "Moved", content: { "application/json": { schema: jsonSchema(jobResponse, "output") } } } } }, (service, { params, body }) => service.moveJob(params.jobId, body.projectId, body.expectedUpdatedAt), { body: z.object({ projectId: z.guid(), expectedUpdatedAt: z.iso.datetime().optional() }) }), + route("POST", "/api/v1/vm-image-verifications", "flows:write", { summary: "Verify a public VM image", description: "Starts a tenant-scoped background verification. It creates a temporary Factorize-owned VM, checks SSH and the selected harness prerequisites, then deletes the VM. It never executes a job prompt.", responses: { "200": { description: "Verification operation", content: { "application/json": { schema: jsonSchema(vmImageVerificationResponse, "output") } } } } }, (service, { body }) => service.startVmImageVerification(body), { body: vmImageVerificationInput }), + route("GET", "/api/v1/vm-image-verifications/{verificationId}", "flows:read", { summary: "Get VM image verification progress", responses: { "200": { description: "Verification operation", content: { "application/json": { schema: jsonSchema(vmImageVerificationResponse, "output") } } } } }, (service, { params }) => service.getVmImageVerification(params.verificationId)), route( "GET", "/api/v1/jobs", diff --git a/packages/api/src/exe-vm-backend.ts b/packages/api/src/exe-vm-backend.ts index 52c38b6..210b965 100644 --- a/packages/api/src/exe-vm-backend.ts +++ b/packages/api/src/exe-vm-backend.ts @@ -24,6 +24,9 @@ function harnessCommand(harness: NonNullable): string } export class ExeIntegrationError extends Error {} +export class ExeImageVerificationError extends ExeIntegrationError { + constructor(public kind: "image_pull" | "permission" | "capacity" | "timeout", message: string) { super(message); } +} export interface ExePermissionTest { ok: boolean; missingPermissions: string[]; tags: string[]; checks: BackendCommandResult[] } export interface ExeAgentValidation { models: string[]; command: BackendCommandResult } @@ -40,11 +43,19 @@ export function tagsFromInventory(inventory: unknown): string[] { return [...tags].sort((a, b) => a.localeCompare(b)); } +function inventoryHasOwnedVm(body: string, vm: string): boolean { + try { + const value: unknown = JSON.parse(body); + const visit = (item: unknown): boolean => Array.isArray(item) ? item.some(visit) : !!item && typeof item === "object" && ((item as any).name === vm || (item as any).vm_name === vm) && (item as any).comment === `Factorize VM ${vm}` || !!item && typeof item === "object" && Object.values(item as Record).some(visit); + return visit(value); + } catch { return false; } +} + export class ExeVmBackend implements ExecutionBackend { readonly kind = "exe-vm"; readonly capabilities = capabilities; - constructor(private readonly connection: Pick | ExeRunConnection) {} + constructor(private readonly connection: Pick & { image?: string } | ExeRunConnection) {} private async api(command: string): Promise { const response = await fetch(API, { method: "POST", headers: { Authorization: `Bearer ${this.connection.apiToken}`, "Content-Type": "text/plain" }, body: command }); @@ -95,6 +106,45 @@ export class ExeVmBackend implements ExecutionBackend { } } + async verifyImage(verificationId: string, image: string, agentKind: "codex" | "claude" | "pi", progress: (phase: string) => Promise = async () => {}): Promise { + const vm = vmNameFor(`verify-${verificationId}`); + const tags = [...new Set(this.connection.tags)].map(tag => `--tag=${shellAtom(tag)}`).join(" "); + const command = `new --name=${shellAtom(vm)} --no-email --comment=${shellAtom(`Factorize VM ${vm}`)}${tags ? ` ${tags}` : ""} --image=${shellAtom(image)}`; + let created: BackendCommandResult | undefined, vmMayExist = false; + try { created = await this.api(command); } + catch { + // The HTTPS API has a 30-second timeout. Reconcile by the deterministic + // Factorize name and ownership marker before reporting an ambiguous create. + const inventory = await this.api("ls --json").catch(() => null); + vmMayExist = Boolean(inventory?.ok && inventoryHasOwnedVm(inventory.body, vm)); + if (!vmMayExist) throw new ExeImageVerificationError("timeout", "Image creation timed out and no owned VM was found during reconciliation. Retry verification after checking exe.dev."); + } + if (created?.ok) vmMayExist = true; + else if (created && !created.ok) { + const inventory = await this.api("ls --json").catch(() => null); + vmMayExist = Boolean(inventory?.ok && inventoryHasOwnedVm(inventory.body, vm)); + if (!vmMayExist) { + const kind = created.status === 403 ? "permission" : [429, 507].includes(created.status) ? "capacity" : [408, 504].includes(created.status) ? "timeout" : created.status === 503 && /capacity/i.test(created.body) ? "capacity" : "image_pull"; + const detail = this.validationFailure("Image creation or pull failed. Check that the image is public, its reference is correct, and exe.dev has VM capacity and new permission", created); + throw new ExeImageVerificationError(kind, detail.message); + } + } + try { + await progress("checking_ssh"); + let ssh = await this.api(`ssh ${shellAtom(vm)} ${shellAtom("true")}`).catch(() => null); + for (let attempt = 1; (!ssh || !ssh.ok) && attempt < 6; attempt++) ssh = await this.api(`ssh ${shellAtom(vm)} ${shellAtom("true")}`).catch(() => null); + if (!ssh?.ok) throw new ExeIntegrationError(`ssh: VM was created from the image, but SSH access failed (${ssh?.status ?? "timeout"}). Ensure the image boots an exe.dev-compatible system with SSH enabled.`); + await progress("checking_agent"); + const check = `command -v ${shellAtom(agentKind)} >/dev/null 2>&1 && command -v curl >/dev/null 2>&1 && command -v jq >/dev/null 2>&1`; + const agent = await this.api(`ssh ${shellAtom(vm)} ${shellAtom(check)}`).catch(() => null); + if (!agent?.ok) throw new ExeIntegrationError(`agent: SSH works, but the selected ${agentKind} harness prerequisites are missing. Use an image with ${agentKind}, curl, and jq installed.`); + } finally { + await progress("cleaning_up"); + const removed = await this.deleteVm(vm); + if (!removed.ok) throw new ExeIntegrationError(`cleanup: Temporary VM ${vm} could not be deleted. It will be retried; remove it in exe.dev if it remains.`); + } + } + async launch(request: LaunchRequest): Promise { const connection = this.connection as ExeRunConnection; if (!connection.agentKind) throw new Error("The job is missing its agent configuration"); @@ -102,7 +152,8 @@ export class ExeVmBackend implements ExecutionBackend { if (request.traceSources) validateTraceSources(request.traceSources); const vm = vmNameFor(request.runId); const tags = [...new Set(this.connection.tags)].map(tag => `--tag=${shellAtom(tag)}`).join(" "); - const created = await this.api(`new --name=${shellAtom(vm)} --no-email --comment=${shellAtom(`Factorize VM ${vm}`)} ${tags}`.trim()); + const image = connection.image; + const created = await this.api(`new --name=${shellAtom(vm)} --no-email --comment=${shellAtom(`Factorize VM ${vm}`)} ${tags}${image ? ` --image=${shellAtom(image)}` : ""}`.trim()); if (!created.ok) { await this.deleteVm(vm).catch(() => undefined); throw new Error(`exe.dev VM creation failed (${created.status}): ${created.body.slice(0, 500)}`); diff --git a/packages/api/src/exe.ts b/packages/api/src/exe.ts index 400dd15..1cb46c4 100644 --- a/packages/api/src/exe.ts +++ b/packages/api/src/exe.ts @@ -33,6 +33,7 @@ export interface ExeRunConnection extends ExeConnection { model?: string; effort?: string; efforts?: string[]; + image?: string; } /** Single shell atom; prevents user-controlled values from becoming syntax. */ diff --git a/packages/api/src/flow-schemas.ts b/packages/api/src/flow-schemas.ts index a0cf91f..953b62a 100644 --- a/packages/api/src/flow-schemas.ts +++ b/packages/api/src/flow-schemas.ts @@ -43,11 +43,19 @@ export const jobInputSchema = z.object({ concurrencyLimit: z.number().int().min(1).max(50).default(1), executionConfigId: z.string().min(1), tags: z.array(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/)).max(20).optional(), + vmImage: z.string().trim().max(512).regex(/^(?:(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?)?$/, "Enter a public Docker/OCI image reference.").optional(), model: z.string().trim().max(120).optional(), effort: z.string().trim().max(40).optional(), triggers: z.array(triggerSchema).max(50).default([]), skillIds: z.array(z.guid()).max(50).optional(), }).strict(); +export const vmImageVerificationInput = z.object({ + connectionId: z.string().min(1).max(200), + image: z.string().trim().min(1).max(512).regex(/^(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?$/), + tags: z.array(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/)).max(20), + agentKind: z.enum(["codex", "claude", "pi"]), +}).strict(); +export const vmImageVerificationResponse = z.object({ id: z.guid(), status: z.enum(["queued", "creating", "checking_ssh", "checking_agent", "cleaning_up", "succeeded", "failed", "cleanup_failed"]), phase: z.string(), errorKind: z.string().nullable(), error: z.string().nullable(), createdAt: z.string(), updatedAt: z.string() }); export const jobIdSchema = z.object({ jobId: z.string().min(1) }); export const manualInvocationSchema = z.object({ prompt: z.string().max(50_000).default(""), diff --git a/packages/api/src/flow-service.ts b/packages/api/src/flow-service.ts index f3cf718..d9c8340 100644 --- a/packages/api/src/flow-service.ts +++ b/packages/api/src/flow-service.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import { runPageResponse, fullRunResponse } from "./run-read-contracts"; import { triggerContextCatalog } from "./trigger-context"; import type { Env, OAuthProps } from "./types"; -import type { JobInput, ManualInvocationInput } from "./flow-schemas"; +import { vmImageVerificationInput, type JobInput, type ManualInvocationInput } from "./flow-schemas"; import { nextOccurrence, validateScheduleConfig } from "./schedule"; import { databaseFor } from "./postgres/database"; import { IdentityRepository } from "./postgres/identity-repository"; @@ -33,6 +33,7 @@ import { TraceProjectionRepository } from "./postgres/trace-projection-repositor import { replayTrace, traceReplaySchema, traceReplayRunIdSchema, TraceReplayError } from "./trace-replay"; import { ProjectRepository } from "./postgres/project-repository"; import { effortOptions, selectableExeTags, type ExeConnection } from "./exe"; +import { ExeImageVerificationError, ExeVmBackend } from "./exe-vm-backend"; import { SkillRepository, SkillConflict, SkillReferenced } from "./postgres/skill-repository"; import { skillInput, skillUpdateInput, skillPage, skillResponse, skillJobsResponse, previewSkillInput, composePrompt } from "./skills"; @@ -54,7 +55,7 @@ type Scope = "projects:read" | "projects:write" | "jobs:read" | "jobs:write" | " export class ApiService { authorizationDuration = 0; - constructor(private env: Env, private auth: OAuthProps) {} + constructor(private env: Env, private auth: OAuthProps, private ctx?: ExecutionContext) {} private jobs() { return new PostgresJobRepository(databaseFor(this.env), this.auth.tenantId, value => encrypt(value, this.env.CREDENTIAL_ENCRYPTION_KEY), value => decrypt(value, this.env.CREDENTIAL_ENCRYPTION_KEY)); } private connections() { return new ConnectionRepository(databaseFor(this.env), this.auth.tenantId, this.env.CREDENTIAL_ENCRYPTION_KEY); } @@ -231,7 +232,7 @@ export class ApiService { const triggers = this.normalizedTriggers(input.triggers).map(trigger => ({ ...trigger, jobId: id })); const projectId = input.projectId ?? await this.projects().defaultProjectId(); const selectedSkills = input.skillIds ? await this.skillSelections(input.skillIds) : []; - const job: Job = { id, projectId, name: input.name, slug: input.slug, promptTemplate: input.promptTemplate, runNameTemplate: input.runNameTemplate ?? "", tags: input.tags ?? [], model: input.model ?? "", ...(input.effort ? { effort: input.effort } : {}), executionConfig: await this.executionConfig(input.executionConfigId), concurrencyLimit: input.concurrencyLimit, enabled: true, triggers, selectedSkills, createdAt: timestamp, updatedAt: timestamp }; + const job: Job = { id, projectId, name: input.name, slug: input.slug, promptTemplate: input.promptTemplate, runNameTemplate: input.runNameTemplate ?? "", tags: input.tags ?? [], model: input.model ?? "", ...(input.effort ? { effort: input.effort } : {}), executionConfig: { ...await this.executionConfig(input.executionConfigId), ...(input.vmImage ? { image: input.vmImage } : {}) }, concurrencyLimit: input.concurrencyLimit, enabled: true, triggers, selectedSkills, createdAt: timestamp, updatedAt: timestamp }; try { return this.presentJob(await this.jobs().create(job)); } catch (error: any) { if (error?.code === "23505") throw new ServiceError(409, "conflict", "Job slug is already in use"); throw error; } } async updateJob(jobId: string, input: JobInput & { expectedUpdatedAt?: string }) { @@ -239,10 +240,44 @@ export class ApiService { await this.validateExecutionOptions(input.executionConfigId, input.model ?? "", input.effort ?? "", input.tags ?? []); const triggers = this.normalizedTriggers(input.triggers, current.triggers).map(trigger => ({ ...trigger, jobId })); const selectedSkills = input.skillIds === undefined ? current.selectedSkills : await this.skillSelections(input.skillIds); - const updated = await repository.update({ ...current, projectId: input.projectId ?? current.projectId, name: input.name, slug: input.slug, promptTemplate: input.promptTemplate, runNameTemplate: input.runNameTemplate ?? "", tags: input.tags ?? [], model: input.model ?? "", effort: input.effort, executionConfig: await this.executionConfig(input.executionConfigId), concurrencyLimit: input.concurrencyLimit, triggers, selectedSkills, updatedAt: new Date().toISOString() }, input.expectedUpdatedAt).catch(error => { if (error instanceof StaleJobEdit) throw new ServiceError(409, "stale_job", "This job changed. Reload its configuration before saving."); throw error; }); + const updated = await repository.update({ ...current, projectId: input.projectId ?? current.projectId, name: input.name, slug: input.slug, promptTemplate: input.promptTemplate, runNameTemplate: input.runNameTemplate ?? "", tags: input.tags ?? [], model: input.model ?? "", effort: input.effort, executionConfig: { ...await this.executionConfig(input.executionConfigId), ...(input.vmImage ? { image: input.vmImage } : {}) }, concurrencyLimit: input.concurrencyLimit, triggers, selectedSkills, updatedAt: new Date().toISOString() }, input.expectedUpdatedAt).catch(error => { if (error instanceof StaleJobEdit) throw new ServiceError(409, "stale_job", "This job changed. Reload its configuration before saving."); throw error; }); await this.wakeScheduler(); return this.presentJob(updated); } + async startVmImageVerification(input: unknown) { + await this.authorize("jobs:write"); + const value = vmImageVerificationInput.parse(input), connection = await this.connections().get(`exe:${value.connectionId}`); + if (!connection) throw new ServiceError(404, "not_found", "exe.dev integration not found"); + if (connection.agentKind !== value.agentKind) throw new ServiceError(400, "invalid_request", "The selected agent does not match this exe.dev integration."); + const allowedTags = new Set(selectableExeTags(connection)); + if (value.tags.some(tag => !allowedTags.has(tag))) throw new ServiceError(400, "invalid_request", "VM tags must be selected from the available exe.dev labels."); + const id = crypto.randomUUID(), db = databaseFor(this.env), config = { connectionId: value.connectionId, image: value.image, tags: value.tags, agentKind: value.agentKind }; + await db.pool.query("INSERT INTO app.vm_image_verifications(tenant_id,id,status,phase,config) VALUES ($1,$2,'queued','queued',$3)", [this.auth.tenantId, id, config]); + const task = this.runVmImageVerification(id, connection, value); + if (this.ctx) this.ctx.waitUntil(task); else void task.catch(error => console.error("VM image verification failed", error)); + return { id, status: "queued", phase: "queued", errorKind: null, error: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; + } + private async runVmImageVerification(id: string, connection: ExeConnection, input: z.infer) { + const db = databaseFor(this.env), update = async (status: string, phase = status, errorKind: string | null = null, error: string | null = null) => { + await db.pool.query("UPDATE app.vm_image_verifications SET status=$3,phase=$4,error_kind=$5,error=$6,updated_at=now() WHERE tenant_id=$1 AND id=$2", [this.auth.tenantId, id, status, phase, errorKind, error?.slice(0, 500) ?? null]); + }; + const backend = new ExeVmBackend({ ...connection, tags: input.tags }); + try { + await update("creating"); + await backend.verifyImage(id, input.image, input.agentKind, phase => update(phase)); + await update("succeeded", "Verification passed. Image pull, SSH, and agent prerequisites worked at this time."); + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Image verification failed."; + const cleanup = message.startsWith("cleanup:"), kind = cleanup ? "cleanup" : message.startsWith("ssh:") ? "ssh" : message.startsWith("agent:") ? "agent" : cause instanceof ExeImageVerificationError ? cause.kind : "image_pull"; + await update(cleanup ? "cleanup_failed" : "failed", "Verification failed", kind, message); + } + } + async getVmImageVerification(id: string) { + await this.authorize("jobs:read"); + const result = await databaseFor(this.env).pool.query("SELECT id,status,phase,error_kind,error,created_at,updated_at FROM app.vm_image_verifications WHERE tenant_id=$1 AND id=$2", [this.auth.tenantId, id]); + const row = result.rows[0]; if (!row) throw new ServiceError(404, "not_found", "VM image verification not found"); + return { id: row.id, status: row.status, phase: row.phase, errorKind: row.error_kind, error: row.error, createdAt: row.created_at.toISOString(), updatedAt: row.updated_at.toISOString() }; + } async moveJob(jobId: string, projectId: string, expectedUpdatedAt?: string) { await this.authorize("projects:write"); try { return this.presentJob(await this.jobs().move(jobId, projectId, expectedUpdatedAt)); } catch (error) { if (error instanceof StaleJobEdit) throw new ServiceError(409, "stale_job", "This job changed. Reload it before moving it."); if (error instanceof Error && error.message === "Project not found") throw new ServiceError(404, "not_found", "Project not found"); if (error instanceof Error && error.message === "Job not found") throw new ServiceError(404, "not_found", "Job not found"); throw error; } } async deleteJob(jobId: string) { await this.authorize("jobs:write"); diff --git a/packages/api/src/job-contracts.ts b/packages/api/src/job-contracts.ts index 1858841..ab62a72 100644 --- a/packages/api/src/job-contracts.ts +++ b/packages/api/src/job-contracts.ts @@ -36,7 +36,7 @@ export const triggerResponse = z.discriminatedUnion("kind", [ export const jobResponse = z.object({ id: z.guid(), projectId: z.guid().optional(), name: z.string(), slug: z.string(), enabled: z.boolean(), promptTemplate: z.string(), runNameTemplate: z.string(), tags: z.array(z.string()).optional(), model: z.string(), effort: z.string().optional(), - executionConfig: z.object({ connectionId: z.string(), workspace: z.string().optional(), cwd: z.string().optional(), agentKind: z.string() }), + executionConfig: z.object({ connectionId: z.string(), workspace: z.string().optional(), cwd: z.string().optional(), agentKind: z.string(), image: z.string().optional() }), executionConfigId: z.string(), agentKind: z.string(), concurrencyLimit: z.number().int(), runningCount: z.number().int(), currentRuns: z.number().int(), maxConcurrency: z.number().int(), lastRunState: z.enum(["queued", "reserved", "starting", "running", "blocked", "stopping", "succeeded", "failed", "stopped", "done", "ignored"]).nullable(), triggers: z.array(triggerResponse), diff --git a/packages/api/src/job-domain.ts b/packages/api/src/job-domain.ts index 1826e4b..5a6cd76 100644 --- a/packages/api/src/job-domain.ts +++ b/packages/api/src/job-domain.ts @@ -12,6 +12,7 @@ export interface ExecutionConfig { cwd: string; agentKind: string; tags?: string[]; + image?: string; } export interface Trigger { diff --git a/packages/api/src/postgres/run-scheduler.ts b/packages/api/src/postgres/run-scheduler.ts index 667b168..46dbb3f 100644 --- a/packages/api/src/postgres/run-scheduler.ts +++ b/packages/api/src/postgres/run-scheduler.ts @@ -34,7 +34,7 @@ export class RunScheduler { const id = String(run.executionConfig.connectionId ?? ""); if (run.executionConfig.agentKind === "Amp") { const connection = await this.connections(run).get(`amp:${id}`); if (!connection) throw new Error("Amp connection is unavailable"); return new AmpBackend(connection); } const connection = await this.connections(run).get(`exe:${id}`); if (!connection) throw new Error("exe.dev connection is unavailable"); - return new ExeVmBackend({ ...connection, tags: Array.isArray(run.executionConfig.tags) ? run.executionConfig.tags : [], model: String(run.executionConfig.model ?? ""), effort: String(run.executionConfig.effort ?? "") }); + return new ExeVmBackend({ ...connection, tags: Array.isArray(run.executionConfig.tags) ? run.executionConfig.tags : [], image: typeof run.executionConfig.image === "string" ? run.executionConfig.image : undefined, model: String(run.executionConfig.model ?? ""), effort: String(run.executionConfig.effort ?? "") }); } private async launch(run: PersistedRun) { diff --git a/packages/api/src/postgres/schema.ts b/packages/api/src/postgres/schema.ts index c7174ae..ed270cc 100644 --- a/packages/api/src/postgres/schema.ts +++ b/packages/api/src/postgres/schema.ts @@ -48,6 +48,9 @@ export const projects = app.table("projects", { tenantId: uuid("tenant_id").notN export const jobs = app.table("jobs", { tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }), id: uuid("id").notNull(), projectId: uuid("project_id").notNull(), name: text("name").notNull(), slug: text("slug").notNull(), encryptedPromptTemplate: text("encrypted_prompt_template").notNull(), encryptedRunNameTemplate: text("encrypted_run_name_template").notNull().default(""), executionConfig: jsonb("execution_config").$type().notNull(), tags: jsonb("tags").$type().notNull().default([]), model: text("model").notNull().default(""), effort: text("effort").notNull().default(""), concurrencyLimit: integer("concurrency_limit").notNull(), enabled: boolean("enabled").notNull().default(true), createdAt: createdAt(), updatedAt: updatedAt(), }, t => [primaryKey({ columns: [t.tenantId, t.id] }), unique().on(t.tenantId, t.slug), foreignKey({ columns: [t.tenantId, t.projectId], foreignColumns: [projects.tenantId, projects.id] }).onDelete("restrict"), index("jobs_tenant_project_updated").on(t.tenantId, t.projectId, t.updatedAt), check("jobs_concurrency_limit_check", sql`${t.concurrencyLimit} >= 1`)]); +export const vmImageVerifications = app.table("vm_image_verifications", { + tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }), id: uuid("id").notNull(), jobId: uuid("job_id"), status: text("status").notNull(), phase: text("phase").notNull(), config: jsonb("config").$type>().notNull(), errorKind: text("error_kind"), error: text("error"), vmName: text("vm_name"), createdAt: createdAt(), updatedAt: updatedAt(), +}, t => [primaryKey({ columns: [t.tenantId, t.id] }), check("vm_image_verifications_status_check", sql`${t.status} IN ('queued','creating','checking_ssh','checking_agent','cleaning_up','succeeded','failed','cleanup_failed')`), index("vm_image_verifications_status_idx").on(t.status, t.updatedAt)]); export const skills = app.table("skills", { tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }), id: uuid("id").notNull(), key: text("key").notNull(), encryptedBody: text("encrypted_body").notNull(), revision: integer("revision").notNull().default(1), createdAt: createdAt(), updatedAt: updatedAt(), }, t => [primaryKey({ columns: [t.tenantId, t.id] }), unique().on(t.tenantId, t.key)]); @@ -130,7 +133,7 @@ export const runRelations = relations(runs, ({ one, many }) => ({ export const databaseSchema = { schemaMigrations, tenants, oauthConnections, oauthConsentPreviews, authUsers, authAccounts, authResetTokens, authSessions, oauthDeviceAuthorizations, members, accessTokens, - connections, linearWorkspaces, githubInstallations, githubSetupStates, projects, jobs, skills, jobSkills, triggers, webhookConditionsCutover, invocations, jobRuns, runs, runArtifacts, + connections, linearWorkspaces, githubInstallations, githubSetupStates, projects, jobs, vmImageVerifications, skills, jobSkills, triggers, webhookConditionsCutover, invocations, jobRuns, runs, runArtifacts, runTraceEvents, runActivity, activeClaims, scheduleState, automaticWakes, lifecycleDeliveries, jobEditDeliveries, jobEvents, webhookDeliveries, webhookDeliveryEvents, pendingVerifications, tailFingerprints, wakeHints, runTraceCursors, runTraceChunks, runTraceProjections, traceReplayLimits, traceReplayOperations, diff --git a/packages/api/src/protected-api.ts b/packages/api/src/protected-api.ts index 9c8898b..f4abd0b 100644 --- a/packages/api/src/protected-api.ts +++ b/packages/api/src/protected-api.ts @@ -86,7 +86,7 @@ export async function protectedApiFetch(request: Request, env: Env, auth: OAuthP body = route.body.parse(body); } if (route.authOperation) return timed(await executeAuth(route.authOperation, request, env, body as Record)); - service = new ApiService(env, auth!); + service = new ApiService(env, auth!, ctx); const query = route.query?.parse(queryInput(url)); return timed(Response.json(await route.execute(service, { params: route.parameters.parse(params) as Record, body, query, url }), { status: route.status })); } catch (error) { return timed(errorResponse(error)); } diff --git a/packages/api/test/api-contract.test.ts b/packages/api/test/api-contract.test.ts index 1396b76..c64ba24 100644 --- a/packages/api/test/api-contract.test.ts +++ b/packages/api/test/api-contract.test.ts @@ -8,7 +8,7 @@ import { isDeclaredApiOperation, matchApiOperation, } from "../src/api-contract"; -import { listRunsSchema } from "../src/flow-schemas"; +import { listRunsSchema, jobInputSchema, vmImageVerificationInput } from "../src/flow-schemas"; export const openapi = parse( readFileSync(new URL("../../docs/openapi.yaml", import.meta.url), "utf8"), @@ -33,6 +33,16 @@ describe("versioned API contract", () => { expect(listRunsSchema.safeParse({ state: ["failed", "stopped"] }).success).toBe(true); expect(listRunsSchema.safeParse({ state: ["failed", "not-a-state"] }).success).toBe(false); }); + it("accepts public image references and publishes tenant-scoped verification operations", () => { + const job = { name: "Image job", slug: "image-job", promptTemplate: "Run", executionConfigId: "exe", vmImage: "ghcr.io/org/image:tag", triggers: [] }; + expect(jobInputSchema.safeParse(job).success).toBe(true); + expect(jobInputSchema.safeParse({ ...job, vmImage: "https://private.example/image" }).success).toBe(false); + expect(vmImageVerificationInput.safeParse({ connectionId: "exe", image: "ghcr.io/org/image:tag", tags: [], agentKind: "codex" }).success).toBe(true); + const post = API_ROUTES.find(route => route.path === "/api/v1/vm-image-verifications" && route.method === "POST"); + const get = API_ROUTES.find(route => route.path === "/api/v1/vm-image-verifications/{verificationId}" && route.method === "GET"); + expect(post?.scope).toBe("jobs:write"); expect(get?.scope).toBe("jobs:read"); + expect(openapi.paths["/api/v1/vm-image-verifications"].post.requestBody).toBeDefined(); + }); it("has full bidirectional method/path equality with the executable catalog", () => { const documented = Object.entries(openapi.paths).flatMap(([path, item]) => Object.keys(item as object) diff --git a/packages/api/test/execution-backend.test.ts b/packages/api/test/execution-backend.test.ts index d5f198b..e19fa1a 100644 --- a/packages/api/test/execution-backend.test.ts +++ b/packages/api/test/execution-backend.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { ExeVmBackend, isOwnedVmName, tagsFromInventory, vmNameFor } from "../src/exe-vm-backend"; +import { ExeImageVerificationError, ExeVmBackend, isOwnedVmName, tagsFromInventory, vmNameFor } from "../src/exe-vm-backend"; import type { ExecutionBackend, ExecutionObservation, LaunchRequest, RunHandle } from "../src/execution"; import { effortOptions, selectableExeTags, type ExeRunConnection } from "../src/exe"; import { agentDriver } from "../src/agent-driver"; @@ -92,6 +92,60 @@ describe("ExeVmBackend", () => { expect(requests[1]).toContain("systemctl show"); }); + it("quotes a snapshotted public image in the exe.dev VM creation command", async () => { + const requests: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => { requests.push(String(init.body)); return new Response(requests.length === 1 ? "{}" : "started", { headers: { "X-Exe-Exit": "0" } }); })); + await new ExeVmBackend({ ...connection, image: "ghcr.io/acme/base:release'candidate" }).launch({ runId: "run-1", prompt: "work", harness: agentDriver("codex").launch("run-1", {}) }); + expect(requests[0]).toContain("--image='ghcr.io/acme/base:release'\"'\"'candidate'"); + }); + + it("verifies an image over SSH and deletes only its marked temporary VM", async () => { + const commands: string[] = [], phases: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => { + const command = String(init.body); commands.push(command); + if (command.startsWith("new ")) return new Response("created", { headers: { "X-Exe-Exit": "0" } }); + if (command.startsWith("ls ")) return new Response('{"vms":[{"name":"factorize-verify-verification-1","comment":"Factorize VM factorize-verify-verification-1"}]}', { headers: { "X-Exe-Exit": "0" } }); + return new Response("ok", { headers: { "X-Exe-Exit": "0" } }); + })); + await new ExeVmBackend(connection).verifyImage("verification-1", "ghcr.io/acme/agent:stable", "codex", async phase => { phases.push(phase); }); + expect(commands[0]).toContain("--image='ghcr.io/acme/agent:stable'"); + expect(commands.some(command => command.includes("factorize-prompt"))).toBe(false); + expect(commands.at(-1)).toBe("rm 'factorize-verify-verification-1'"); + expect(phases).toEqual(["checking_ssh", "checking_agent", "cleaning_up"]); + }); + + it.each([[403, "permission"], [429, "capacity"], [404, "image_pull"]] as const)("classifies image creation failure %s as %s", async (status, kind) => { + vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => String(init.body) === "ls --json" ? new Response('{"vms":[]}') : new Response("failed", { status }))); + await expect(new ExeVmBackend(connection).verifyImage("verification-1", "ghcr.io/acme/agent:bad", "codex")).rejects.toMatchObject({ kind }); + }); + + it("reconciles an ambiguous create timeout by the Factorize ownership marker", async () => { + const commands: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => { + const command = String(init.body); commands.push(command); + if (command.startsWith("new ")) throw new Error("timeout"); + if (command === "ls --json") return new Response('{"vms":[{"name":"factorize-verify-verification-1","comment":"Factorize VM factorize-verify-verification-1"}]}', { headers: { "X-Exe-Exit": "0" } }); + return new Response("ok", { headers: { "X-Exe-Exit": "0" } }); + })); + await new ExeVmBackend(connection).verifyImage("verification-1", "ghcr.io/acme/agent:stable", "codex"); + expect(commands.filter(command => command.startsWith("new "))).toHaveLength(1); + expect(commands.at(-1)).toBe("rm 'factorize-verify-verification-1'"); + }); + + it.each(["ssh", "agent"] as const)("distinguishes %s prerequisite failure and still cleans up", async failedCheck => { + const commands: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => { + const command = String(init.body); commands.push(command); + if (command.startsWith("new ")) return new Response("created", { headers: { "X-Exe-Exit": "0" } }); + if (command === "ls --json") return new Response('{"vms":[{"name":"factorize-verify-verification-1","comment":"Factorize VM factorize-verify-verification-1"}]}', { headers: { "X-Exe-Exit": "0" } }); + if (failedCheck === "ssh" && command.startsWith("ssh ")) return new Response("no ssh", { status: 503 }); + if (failedCheck === "agent" && command.includes("command -v")) return new Response("missing", { headers: { "X-Exe-Exit": "1" } }); + return new Response("ok", { headers: { "X-Exe-Exit": "0" } }); + })); + await expect(new ExeVmBackend(connection).verifyImage("verification-1", "ghcr.io/acme/agent:stable", "codex")).rejects.toThrow(failedCheck === "ssh" ? "ssh:" : "agent:"); + expect(commands.at(-1)).toBe("rm 'factorize-verify-verification-1'"); + }); + it("routes Codex JSON stdout exclusively to its stream and records the guest CLI version", async () => { const requests: string[] = []; vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => { diff --git a/packages/app/src/features/jobs/editor-state.ts b/packages/app/src/features/jobs/editor-state.ts index 5a76532..18e2dbd 100644 --- a/packages/app/src/features/jobs/editor-state.ts +++ b/packages/app/src/features/jobs/editor-state.ts @@ -2,9 +2,9 @@ import type { components, operations } from "factorize-api-client"; export type JobInput = components["schemas"]["JobInput"]; export type Job = operations["get_api_v1_jobs_jobId"]["responses"][200]["content"]["application/json"]; export type Trigger = JobInput["triggers"][number]; -export const emptyJob = (): JobInput => ({ name: "", slug: "", promptTemplate: "", runNameTemplate: "", executionConfigId: "", concurrencyLimit: 1, tags: [], model: "", effort: "", skillIds: [], triggers: [{ kind: "manual", slug: "trigger-1", enabled: true, config: {} }] }); +export const emptyJob = (): JobInput => ({ name: "", slug: "", promptTemplate: "", runNameTemplate: "", executionConfigId: "", concurrencyLimit: 1, tags: [], vmImage: "", model: "", effort: "", skillIds: [], triggers: [{ kind: "manual", slug: "trigger-1", enabled: true, config: {} }] }); export function editableJob(job: Job): JobInput { - return { projectId: job.projectId, name: job.name, slug: job.slug, promptTemplate: job.promptTemplate, runNameTemplate: job.runNameTemplate, executionConfigId: job.executionConfigId, concurrencyLimit: job.concurrencyLimit, tags: job.tags ?? [], model: job.model, effort: job.effort ?? "", skillIds: job.skills?.map(skill => skill.id) ?? [], triggers: job.triggers.map(trigger => { + return { projectId: job.projectId, name: job.name, slug: job.slug, promptTemplate: job.promptTemplate, runNameTemplate: job.runNameTemplate, executionConfigId: job.executionConfigId, concurrencyLimit: job.concurrencyLimit, tags: job.tags ?? [], vmImage: job.executionConfig?.image ?? "", model: job.model, effort: job.effort ?? "", skillIds: job.skills?.map(skill => skill.id) ?? [], triggers: job.triggers.map(trigger => { const { id, slug, kind, enabled } = trigger; const config = trigger.kind === "webhook" ? Object.fromEntries(Object.entries(trigger.config).filter(([key]) => !["destination", "secretConfigured"].includes(key))) : trigger.config; return { id, slug, kind, enabled, config } as Trigger; @@ -34,6 +34,7 @@ export function validateJobInput(input: JobInput): string | undefined { if (!Number.isInteger(input.concurrencyLimit) || input.concurrencyLimit < 1 || input.concurrencyLimit > 50) return "Concurrency limit must be between 1 and 50."; if (!input.executionConfigId) return "Choose an execution integration."; if ((input.tags?.length ?? 0) > 20 || input.tags?.some(tag => !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(tag))) return "VM tags are invalid."; + if (input.vmImage && !/^(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?$/.test(input.vmImage.trim())) return "Enter a public Docker/OCI image reference."; if (input.triggers.length > 50) return "A job can have at most 50 triggers."; for (const trigger of input.triggers) { if (!trigger.slug || !/^trigger-[1-9][0-9]*$/.test(trigger.slug)) return "Each trigger needs a valid stable identifier."; diff --git a/packages/app/src/features/jobs/editor.tsx b/packages/app/src/features/jobs/editor.tsx index b8078b2..088ee6d 100644 --- a/packages/app/src/features/jobs/editor.tsx +++ b/packages/app/src/features/jobs/editor.tsx @@ -37,12 +37,20 @@ function EditorForm({ initial }: { initial?: Job }) { const project = useQuery({ ...projectQuery(initial?.projectId ?? ""), enabled: !!initial?.projectId }); const projects = useQuery({ ...projectsQuery, enabled: !initial }); const target = targets.data?.find(t => t.id === draft.executionConfigId); + const verificationFingerprint = JSON.stringify([draft.executionConfigId, draft.vmImage?.trim() ?? "", draft.tags ?? [], target?.agentKind ?? ""]); + const verificationKey = `factorize-vm-image-verification:${initial?.id ?? "new"}`; + const [verificationRef, setVerificationRef] = useState<{ fingerprint: string; id: string } | null>(() => { try { return JSON.parse(localStorage.getItem(verificationKey) ?? "null"); } catch { return null; } }); + const currentVerificationFingerprint = useRef(verificationFingerprint); currentVerificationFingerprint.current = verificationFingerprint; + const verificationGeneration = useRef(0); + const activeVerificationId = verificationRef?.fingerprint === verificationFingerprint ? verificationRef.id : ""; + const verification = useQuery({ queryKey: ["vm-image-verification", activeVerificationId], enabled: !!activeVerificationId, queryFn: async ({ signal }) => { const { data, error } = await api.GET("/api/v1/vm-image-verifications/{verificationId}", { params: { path: { verificationId: activeVerificationId } }, signal }); if (!data || error) throw new Error(messageOf(error)); return data; }, refetchInterval: query => query.state.data && ["succeeded", "failed", "cleanup_failed"].includes(query.state.data.status) ? false : 1500, refetchIntervalInBackground: true }); + const verifyImage = useMutation({ retry: false, mutationFn: async (snapshot: { fingerprint: string; generation: number }) => { const { data, error } = await api.POST("/api/v1/vm-image-verifications", { body: { connectionId: draft.executionConfigId, image: draft.vmImage?.trim() ?? "", tags: draft.tags ?? [], agentKind: target?.agentKind as "codex" | "claude" | "pi" } }); if (!data || error) throw new Error(messageOf(error)); return { ...snapshot, id: data.id }; }, onSuccess: value => { if (currentVerificationFingerprint.current !== value.fingerprint || verificationGeneration.current !== value.generation) return; const record = { fingerprint: value.fingerprint, id: value.id }; setVerificationRef(record); localStorage.setItem(verificationKey, JSON.stringify(record)); } }); const projectId = new URLSearchParams(location.searchStr).get("projectId") ?? undefined; const selectedProjectId = initial?.projectId ?? draft.projectId ?? projectId; const [projectDialog, setProjectDialog] = useState(false); const onlyProject = projects.data?.length === 1 ? projects.data[0] : undefined; useEffect(() => { if (!initial && !draft.projectId && !projectId && onlyProject) setDraft(previous => ({ ...previous, projectId: onlyProject.id })); }, [initial, draft.projectId, projectId, onlyProject]); - const update = (key: K, value: JobInput[K]) => { setSaved(false); setDraft(previous => ({ ...previous, [key]: value })); }; + const update = (key: K, value: JobInput[K]) => { setSaved(false); if (["vmImage", "executionConfigId", "tags"].includes(key)) { verificationGeneration.current++; setVerificationRef(null); localStorage.removeItem(verificationKey); } setDraft(previous => ({ ...previous, [key]: value })); }; const refreshConflict = useMutation({ retry: false, mutationFn: async () => { if (!initial) throw new Error("No job selected"); const { data, error } = await api.GET("/api/v1/jobs/{jobId}", { params: { path: { jobId: initial.id } } }); @@ -58,7 +66,7 @@ function EditorForm({ initial }: { initial?: Job }) { throw new Error(validationMessage(result.error)); } return result.data; - }, onSuccess: async value => { savingNavigation.current = true; setSaved(true); setBase(editableJob(value)); setDraft(editableJob(value)); setRevision(value.updatedAt); cache.setQueryData(jobQuery(value.id).queryKey, value); await cache.invalidateQueries({ queryKey: ["jobs"] }); if (!initial) void navigate({ to: "/jobs/$jobId", params: { jobId: value.id } }); else savingNavigation.current = false; } }); + }, onSuccess: async value => { savingNavigation.current = true; setSaved(true); if (!initial && verificationRef && verificationRef.fingerprint === currentVerificationFingerprint.current) localStorage.setItem(`factorize-vm-image-verification:${value.id}`, JSON.stringify(verificationRef)); setBase(editableJob(value)); setDraft(editableJob(value)); setRevision(value.updatedAt); cache.setQueryData(jobQuery(value.id).queryKey, value); await cache.invalidateQueries({ queryKey: ["jobs"] }); if (!initial) void navigate({ to: "/jobs/$jobId", params: { jobId: value.id } }); else savingNavigation.current = false; } }); const autosave = useDebouncedAutosave({ value: draft, enabled: Boolean(initial) && dirty && !invalidForm && !remote && !reconciling && !conflicts.length, validate: validateJobInput, save: async () => { await save.mutateAsync(); } }); const acceptRemote = () => { if (!remote) return; const value = editableJob(remote); setDraft(value); setBase(value); setRevision(remote.updatedAt); setRemote(undefined); setConflicts([]); save.reset(); }; const merge = () => { if (!remote) return; const value = editableJob(remote), result = reconcile(base, draft, value); setDraft(result.merged); setConflicts(result.conflicts); setBase(value); setRevision(remote.updatedAt); if (!result.conflicts.length) setRemote(undefined); save.reset(); }; @@ -80,6 +88,7 @@ function EditorForm({ initial }: { initial?: Job }) {

Execution

Choose an integration for this job, then configure its VM tags, model, effort, and concurrency.

{targets.isPending &&

Loading integrations…

}{targets.error &&

{targets.error.message}

}{targets.data?.length === 0 &&

Connect an execution provider in settings before creating a job.

} + {target?.kind !== "amp" &&
{draft.vmImage &&

{verification.data?.status === "succeeded" && activeVerificationId ? `Verified: ${verification.data?.phase}` : verification.data?.status === "failed" && activeVerificationId ? `${verification.data?.errorKind}: ${verification.data?.error}` : verification.data?.status === "cleanup_failed" && activeVerificationId ? `Cleanup unresolved: ${verification.data?.error}` : activeVerificationId ? `Verification ${verification.data?.status}: ${verification.data?.phase}` : "Not verified"}

}{draft.vmImage && }{verifyImage.error &&

{verifyImage.error.message}

}
} diff --git a/packages/app/test/editor.browser.test.mjs b/packages/app/test/editor.browser.test.mjs index c56cd63..6e9f2e1 100644 --- a/packages/app/test/editor.browser.test.mjs +++ b/packages/app/test/editor.browser.test.mjs @@ -51,6 +51,28 @@ test("create uses bounded lifecycle selector, lazy provider reads, schedule prev await page.getByRole("button", { name: "Create job", exact: true }).click(); await page.waitForURL(`**/jobs/${id}`); assert.equal(body.name, "Created job"); assert.equal(body.projectId, projectId); assert.deepEqual(body.triggers[2].config.sourceJobIds, [id]); assert.equal(body.expectedUpdatedAt, undefined); await context.close(); }); +test("image verification is explicit, autosave never provisions, and stale verification results are ignored", async () => { + const { context, page } = await setup(); let current = { ...initial, executionConfig: { ...initial.executionConfig, image: "ghcr.io/acme/base:old" } }, createCount = 0, releaseCreate; + await page.route("**/api/v1/integrations", route => route.fulfill({ json: { exeConnections: [{ connectionId: "vm", agentKind: "codex", accountTags: ["github"], models: ["gpt-6"] }], ampConnections: [] } })); + await page.route(`**/api/v1/jobs/${id}`, route => { + if (route.request().method() === "GET") return route.fulfill({ json: current }); + const body = route.request().postDataJSON(); current = { ...current, ...body, executionConfig: { ...current.executionConfig, image: body.vmImage || undefined } }; return route.fulfill({ json: current }); + }); + await page.route("**/api/v1/vm-image-verifications", async route => { + createCount++; + if (createCount === 1) { await new Promise(resolve => { releaseCreate = resolve; }); return route.fulfill({ json: { id: "00000000-0000-4000-8000-000000000111", status: "queued", phase: "queued", errorKind: null, error: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } }); } + return route.fulfill({ json: { id: "00000000-0000-4000-8000-000000000112", status: "queued", phase: "queued", errorKind: null, error: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } }); + }); + await page.route("**/api/v1/vm-image-verifications/*", route => route.fulfill({ json: { id: "00000000-0000-4000-8000-000000000112", status: "succeeded", phase: "Verification passed. Image pull, SSH, and agent prerequisites worked at this time.", errorKind: null, error: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } })); + await page.goto(origin + `/jobs/${id}/edit`); await page.getByLabel("VM image").fill("ghcr.io/acme/base:one"); + await page.getByRole("status").filter({ hasText: "Job settings saved." }).waitFor(); assert.equal(createCount, 0); + await page.getByRole("button", { name: "Verify image" }).click(); await page.getByText("Starting verification…").waitFor(); await page.getByLabel("VM image").fill("ghcr.io/acme/base:two"); releaseCreate(); + await page.getByRole("status").filter({ hasText: "Not verified" }).waitFor(); assert.equal(createCount, 1); + await page.getByRole("button", { name: "Verify image" }).click(); + await page.getByRole("status").filter({ hasText: "Verification passed" }).waitFor(); assert.equal(createCount, 2); + await page.getByLabel("VM tags").selectOption(["github"]); + await page.getByRole("status").filter({ hasText: "Not verified" }).waitFor(); await context.close(); +}); test("template insertion escapes values and navigation protects unsaved work", async () => { const { context, page } = await setup(); await page.route(`**/api/v1/jobs/${id}`, route => route.fulfill({ json: initial })); await page.goto(origin + `/jobs/${id}/edit`); await page.getByText("Insert a prompt template variable", { exact: true }).click(); await page.getByLabel("Prompt template", { exact: true }).focus(); await page.getByLabel("Prompt template", { exact: true }).press("End"); await page.getByRole("button", { name: "trigger-1.prompt", exact: true }).click(); diff --git a/packages/docs/llms-full.txt b/packages/docs/llms-full.txt index 0488045..a079828 100644 --- a/packages/docs/llms-full.txt +++ b/packages/docs/llms-full.txt @@ -133,6 +133,8 @@ Ask: “List execution integrations and jobs, then create a review job for this `update_job` **replaces configuration**, rather than patching one field. Always call `get_job` first. Build a write object containing `jobId`, `name`, `slug`, `promptTemplate`, `executionConfigId`, `runNameTemplate`, `concurrencyLimit`, `model`, optional `effort`, and the full intended `triggers` array. Preserve each existing trigger's `id`, `slug`, `kind`, `enabled`, and writable `config`. Strip read-only response fields (`jobId` inside triggers, timestamps, reflection, secretConfigured and destination). Do not pass the entire read response unchanged. +Jobs using exe.dev may set optional `vmImage` to a public Docker/OCI image reference such as `ghcr.io/org/image:tag`. Leaving it blank uses exe.dev's default image. In the job editor, **Verify image** creates a temporary owned VM, checks SSH and the selected agent prerequisites, and deletes the VM; it never runs the job prompt. Verification is explicit and autosaving does not create VMs. Private images and registry credentials are not supported. Verification records what worked at that time; mutable tags may later resolve to a different image. + Supply `expectedUpdatedAt` from the fetched job's `updatedAt` to protect against concurrent edits. On `stale_job`, fetch again and reapply the desired changes. Omitting it retains last-writer-wins behavior. Omitted optional configuration resets to defaults; omitted triggers remove the configured automatic triggers. Preserve all triggers you want to keep. After saving, call `get_job` and compare prompt, target and triggers. `enable_job` and `disable_job` change enabled state without rebuilding configuration. Disabling automation does not stop an existing run. Job saves can trigger `edited` lifecycle subscribers. Deleting a job removes its execution history/artifacts; inspect and retain anything needed first. @@ -457,6 +459,7 @@ The inventory is validated against every registered implementation tool. All req | `slug` | Required string matching `^[a-z][a-z0-9_-]{0,29}$`; unique within workspace | | `promptTemplate` | Required string, 1–50,000 characters | | `executionConfigId` | Required non-empty ID from target discovery | +| `vmImage` | Optional public Docker/OCI image reference for exe.dev jobs. Omit or send an empty value to use the exe.dev default. Verification is available in the browser job editor. | | `runNameTemplate` | Optional string, max 500; defaults to empty | | `concurrencyLimit` | Optional integer 1–50; defaults to 1 | | `model` | Optional trimmed string, max 120; defaults to empty/provider default | diff --git a/packages/docs/mcp/tools.mdx b/packages/docs/mcp/tools.mdx index fb3cbaf..2ba8dcb 100644 --- a/packages/docs/mcp/tools.mdx +++ b/packages/docs/mcp/tools.mdx @@ -65,6 +65,7 @@ The inventory is validated against every registered implementation tool. All req | `slug` | Required string matching `^[a-z][a-z0-9_-]{0,29}$`; unique within workspace | | `promptTemplate` | Required string, 1–50,000 characters | | `executionConfigId` | Required non-empty ID from target discovery | +| `vmImage` | Optional public Docker/OCI image reference for exe.dev jobs. Omit or send an empty value to use the exe.dev default. Verification is available in the browser job editor. | | `runNameTemplate` | Optional string, max 500; defaults to empty | | `concurrencyLimit` | Optional integer 1–50; defaults to 1 | | `model` | Optional trimmed string, max 120; defaults to empty/provider default | diff --git a/packages/docs/openapi.yaml b/packages/docs/openapi.yaml index 994a48a..8aecd78 100644 --- a/packages/docs/openapi.yaml +++ b/packages/docs/openapi.yaml @@ -839,6 +839,10 @@ components: items: type: string pattern: ^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$ + vmImage: + type: string + maxLength: 512 + pattern: ^(?:(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?)?$ model: type: string maxLength: 120 @@ -5899,6 +5903,8 @@ paths: type: string agentKind: type: string + image: + type: string required: - connectionId - agentKind @@ -6416,6 +6422,205 @@ paths: pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ required: - projectId + /api/v1/vm-image-verifications: + post: + summary: Verify a public VM image + description: |- + Starts a tenant-scoped background verification. It creates a temporary Factorize-owned VM, checks SSH and the selected harness prerequisites, then deletes the VM. It never executes a job prompt. + + Requires the jobs:write scope. + + Accepts a scoped bearer token or interactive owner session. + responses: + "200": + description: Verification operation + content: + application/json: + schema: + type: object + properties: + id: + type: string + format: uuid + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$ + status: + type: string + enum: + - queued + - creating + - checking_ssh + - checking_agent + - cleaning_up + - succeeded + - failed + - cleanup_failed + phase: + type: string + errorKind: + type: + - string + - "null" + error: + type: + - string + - "null" + createdAt: + type: string + updatedAt: + type: string + required: + - id + - status + - phase + - errorKind + - error + - createdAt + - updatedAt + additionalProperties: false + "400": + $ref: "#/components/responses/Error400" + "401": + $ref: "#/components/responses/Error401" + "403": + $ref: "#/components/responses/Error403" + "404": + $ref: "#/components/responses/Error404" + "409": + $ref: "#/components/responses/Error409" + "415": + $ref: "#/components/responses/Error415" + "429": + $ref: "#/components/responses/Error429" + "500": + $ref: "#/components/responses/Error500" + "503": + $ref: "#/components/responses/Error503" + operationId: post_api_v1_vm_image_verifications + tags: + - vm-image-verifications + security: + - bearerAuth: [] + - cookieAuth: [] + x-required-scope: jobs:write + x-owner-session-required: false + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + connectionId: + type: string + minLength: 1 + maxLength: 200 + image: + type: string + minLength: 1 + maxLength: 512 + pattern: ^(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?$ + tags: + maxItems: 20 + type: array + items: + type: string + pattern: ^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$ + agentKind: + type: string + enum: + - codex + - claude + - pi + required: + - connectionId + - image + - tags + - agentKind + additionalProperties: false + /api/v1/vm-image-verifications/{verificationId}: + get: + summary: Get VM image verification progress + responses: + "200": + description: Verification operation + content: + application/json: + schema: + type: object + properties: + id: + type: string + format: uuid + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$ + status: + type: string + enum: + - queued + - creating + - checking_ssh + - checking_agent + - cleaning_up + - succeeded + - failed + - cleanup_failed + phase: + type: string + errorKind: + type: + - string + - "null" + error: + type: + - string + - "null" + createdAt: + type: string + updatedAt: + type: string + required: + - id + - status + - phase + - errorKind + - error + - createdAt + - updatedAt + additionalProperties: false + "400": + $ref: "#/components/responses/Error400" + "401": + $ref: "#/components/responses/Error401" + "403": + $ref: "#/components/responses/Error403" + "404": + $ref: "#/components/responses/Error404" + "409": + $ref: "#/components/responses/Error409" + "429": + $ref: "#/components/responses/Error429" + "500": + $ref: "#/components/responses/Error500" + "503": + $ref: "#/components/responses/Error503" + description: |- + Requires the jobs:read scope. + + Accepts a scoped bearer token or interactive owner session. + operationId: get_api_v1_vm_image_verifications_verificationId + tags: + - vm-image-verifications + security: + - bearerAuth: [] + - cookieAuth: [] + x-required-scope: jobs:read + x-owner-session-required: false + parameters: + - name: verificationId + in: path + required: true + schema: + type: string + minLength: 1 /api/v1/jobs: get: summary: List jobs @@ -6466,6 +6671,8 @@ paths: type: string agentKind: type: string + image: + type: string required: - connectionId - agentKind @@ -7004,6 +7211,8 @@ paths: type: string agentKind: type: string + image: + type: string required: - connectionId - agentKind @@ -7832,6 +8041,8 @@ paths: type: string agentKind: type: string + image: + type: string required: - connectionId - agentKind @@ -8383,6 +8594,8 @@ paths: type: string agentKind: type: string + image: + type: string required: - connectionId - agentKind @@ -8919,6 +9132,10 @@ paths: items: type: string pattern: ^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$ + vmImage: + type: string + maxLength: 512 + pattern: ^(?:(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?)?$ model: type: string maxLength: 120 diff --git a/packages/docs/work/jobs.mdx b/packages/docs/work/jobs.mdx index 8ef6bf7..8683d43 100644 --- a/packages/docs/work/jobs.mdx +++ b/packages/docs/work/jobs.mdx @@ -7,6 +7,8 @@ Ask: “List execution integrations and jobs, then create a review job for this `update_job` **replaces configuration**, rather than patching one field. Always call `get_job` first. Build a write object containing `jobId`, `name`, `slug`, `promptTemplate`, `executionConfigId`, `runNameTemplate`, `concurrencyLimit`, `model`, optional `effort`, and the full intended `triggers` array. Preserve each existing trigger's `id`, `slug`, `kind`, `enabled`, and writable `config`. Strip read-only response fields (`jobId` inside triggers, timestamps, reflection, secretConfigured and destination). Do not pass the entire read response unchanged. +Jobs using exe.dev may set optional `vmImage` to a public Docker/OCI image reference such as `ghcr.io/org/image:tag`. Leaving it blank uses exe.dev's default image. In the job editor, **Verify image** creates a temporary owned VM, checks SSH and the selected agent prerequisites, and deletes the VM; it never runs the job prompt. Verification is explicit and autosaving does not create VMs. Private images and registry credentials are not supported. Verification records what worked at that time; mutable tags may later resolve to a different image. + Supply `expectedUpdatedAt` from the fetched job's `updatedAt` to protect against concurrent edits. On `stale_job`, fetch again and reapply the desired changes. Omitting it retains last-writer-wins behavior. Omitted optional configuration resets to defaults; omitted triggers remove the configured automatic triggers. Preserve all triggers you want to keep. After saving, call `get_job` and compare prompt, target and triggers. `enable_job` and `disable_job` change enabled state without rebuilding configuration. Disabling automation does not stop an existing run. Job saves can trigger `edited` lifecycle subscribers. Deleting a job removes its execution history/artifacts; inspect and retain anything needed first.