mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
merge: reconcile measured sandbox qualification with Claude platforms
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
fa064d247d
33 files changed
+1104
-103
No files matched your search
@@ -173,6 +173,7 @@ describe("adapter routes", () => {
|
||||
[{ driver: "ssh", platform: "linux", architecture: "arm64" }, "paperclip"],
|
||||
[{ driver: "ssh", platform: "linux", architecture: "arm" }, "legacy"],
|
||||
[{ driver: "local", platform: "win32", architecture: "x64" }, "legacy"],
|
||||
[{ driver: "sandbox", platform: "linux", architecture: "arm64", libc: "glibc", glibcVersion: "2.39" }, "paperclip"],
|
||||
])("projects the selected environment once for the whole adapter inventory (%s)", async (target, defaultRunner) => {
|
||||
const resolveRunnerTarget = vi.fn(async () => target);
|
||||
const response = await request(createApp({}, { resolveRunnerTarget })).get("/api/adapters?companyId=company-1&environmentId=selected");
|
||||
@@ -187,7 +188,7 @@ describe("adapter routes", () => {
|
||||
const response = await request(createApp({}, { resolveRunnerTarget })).get("/api/adapters?companyId=company-1");
|
||||
expect(response.status).toBe(200);
|
||||
expect(resolveRunnerTarget).toHaveBeenCalledExactlyOnceWith("company-1", undefined);
|
||||
expect(response.body.find((adapter: any) => adapter.type === "codex_local")).toMatchObject({ defaultRunner: "paperclip" });
|
||||
expect(response.body.find((adapter: any) => adapter.type === "codex_local")).toMatchObject({ defaultRunner: "legacy" });
|
||||
});
|
||||
|
||||
it("keeps unscoped inventory independent of company target discovery", async () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ const mockEnvironmentService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnvironmentCustomImageService = vi.hoisted(() => ({
|
||||
@@ -129,6 +130,26 @@ function createApp(actor: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
describe("environment instance routes", () => {
|
||||
it.each(["post", "patch"])("rejects client-written sandbox platform evidence on %s", async method => {
|
||||
mockEnvironmentService.getById.mockResolvedValue(createEnvironment());
|
||||
const app = createApp({ type: "board", source: "local_implicit", isInstanceAdmin: true });
|
||||
const payload = { ...(method === "post" ? { name: "Fixture", driver: "local", config: {} } : {}), metadata: { paperclipSandboxPlatformProbes: { "company-1": { v: 1 } } } };
|
||||
const res = method === "post" ? await request(app).post("/api/companies/company-1/environments").send(payload)
|
||||
: await request(app).patch("/api/environments/env-1").send(payload);
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.details?.code ?? res.body.code).toBe("environment_platform_probe_reserved");
|
||||
expect(mockEnvironmentService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([null, { userNote: "replacement" }])("passes explicit user metadata replacement through without copying an old probe (%j)", async metadata => {
|
||||
const existing = createEnvironment({ metadata: { userNote: "old", paperclipSandboxPlatformProbes: { "company-1": { v: 1, marker: "stale" } } } });
|
||||
mockEnvironmentService.getById.mockResolvedValue(existing);
|
||||
mockEnvironmentService.update.mockResolvedValue({ ...existing, metadata });
|
||||
const app = createApp({ type: "board", source: "local_implicit", isInstanceAdmin: true });
|
||||
const res = await request(app).patch("/api/environments/env-1").send({ metadata });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockEnvironmentService.update).toHaveBeenCalledExactlyOnceWith("env-1", { metadata }, { db: expect.anything() });
|
||||
expect(res.body.metadata).toEqual(metadata);
|
||||
});
|
||||
beforeEach(() => {
|
||||
mockIssueService.clearExecutionWorkspaceEnvironmentSelection.mockReset();
|
||||
mockProjectService.clearExecutionWorkspaceEnvironmentSelection.mockReset();
|
||||
@@ -138,6 +159,7 @@ describe("environment instance routes", () => {
|
||||
mockEnvironmentService.list.mockReset();
|
||||
mockEnvironmentService.getById.mockReset();
|
||||
mockEnvironmentService.create.mockReset();
|
||||
mockEnvironmentService.update.mockReset();
|
||||
Object.values(mockEnvironmentCustomImageService).forEach((mock) => mock.mockReset());
|
||||
mockEnvironmentCustomImageService.getOverview.mockResolvedValue({
|
||||
activeTemplate: null,
|
||||
|
||||
@@ -6,12 +6,16 @@ const mockProbePluginSandboxProviderDriver = vi.hoisted(() => vi.fn());
|
||||
const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn());
|
||||
const mockRuntimeAcquireRunLease = vi.hoisted(() => vi.fn());
|
||||
const mockRuntimeReleaseRunLease = vi.hoisted(() => vi.fn());
|
||||
const mockRuntimeExecute = vi.hoisted(() => vi.fn());
|
||||
const mockRecordPlatformProbe = vi.hoisted(() => vi.fn());
|
||||
const mockEnvironmentRuntimeService = vi.hoisted(() => vi.fn(() => ({
|
||||
acquireRunLease: mockRuntimeAcquireRunLease,
|
||||
execute: mockRuntimeExecute,
|
||||
getDriver: vi.fn(() => ({
|
||||
releaseRunLease: mockRuntimeReleaseRunLease,
|
||||
})),
|
||||
})));
|
||||
vi.mock("../services/environments.js", () => ({ environmentService: () => ({ recordSandboxPlatformProbe: mockRecordPlatformProbe }) }));
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/ssh", () => ({
|
||||
ensureSshWorkspaceReady: mockEnsureSshWorkspaceReady,
|
||||
@@ -27,20 +31,120 @@ vi.mock("../services/environment-runtime.js", () => ({
|
||||
environmentRuntimeService: mockEnvironmentRuntimeService,
|
||||
}));
|
||||
|
||||
import { probeEnvironment } from "../services/environment-probe.ts";
|
||||
import { probeEnvironment, readSandboxPlatformQualification, SANDBOX_PLATFORM_PROBE_COMMAND } from "../services/environment-probe.ts";
|
||||
import * as configRuntime from "../services/environment-config.js";
|
||||
import { SANDBOX_PLATFORM_PROBE_METADATA_KEY, resolveActiveEnvironmentCustomImageTemplateForRuntime } from "../services/environment-custom-image-runtime.js";
|
||||
|
||||
describe("probeEnvironment", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockRecordPlatformProbe.mockReset().mockResolvedValue(true);
|
||||
mockRuntimeExecute.mockReset();
|
||||
mockEnsureSshWorkspaceReady.mockReset();
|
||||
mockProbePluginEnvironmentDriver.mockReset();
|
||||
mockProbePluginSandboxProviderDriver.mockReset();
|
||||
mockResolvePluginSandboxProviderDriverByKey.mockReset();
|
||||
mockResolvePluginSandboxProviderDriverByKey.mockResolvedValue(null);
|
||||
mockRuntimeAcquireRunLease.mockReset();
|
||||
mockRuntimeReleaseRunLease.mockReset();
|
||||
mockRuntimeReleaseRunLease.mockReset().mockResolvedValue({ id: "lease", companyId: "company", environmentId: "env", status: "released", cleanupStatus: "success" });
|
||||
mockEnvironmentRuntimeService.mockClear();
|
||||
});
|
||||
|
||||
it.each(["2.38", "2.39", "musl"])("records actual immutable-image facts for %s and reads them without another lease", async libc => {
|
||||
const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`;
|
||||
const environment = { id: "env", driver: "sandbox", status: "active", config: { provider: "daytona", image, reuseLease: false }, metadata: null } as any;
|
||||
vi.spyOn(configRuntime, "resolveEnvironmentDriverConfigForRuntime").mockResolvedValue({ driver: "sandbox", config: environment.config });
|
||||
mockRuntimeAcquireRunLease.mockResolvedValue({ lease: { id: "lease", provider: "daytona", metadata: { image, remoteCwd: "/workspace" } } });
|
||||
mockRuntimeExecute.mockResolvedValue({ exitCode: 0, stdout: `Linux\naarch64\n${libc === "musl" ? "musl" : `glibc\nglibc ${libc}`}\n` });
|
||||
const result = await probeEnvironment({} as any, environment, { companyId: "company", acquireSandboxRuntimeLease: true });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.details?.platformQualificationRecorded).toBe(true);
|
||||
expect(mockRuntimeExecute).toHaveBeenCalledWith(expect.objectContaining({ command: "sh", args: ["-c", SANDBOX_PLATFORM_PROBE_COMMAND], timeoutMs: 10_000, bypassSession: true }));
|
||||
const proof = mockRecordPlatformProbe.mock.calls[1]![0].proof;
|
||||
const measured = { platform: "linux", architecture: "arm64", libc: libc === "musl" ? "musl" : "glibc", ...(libc === "musl" ? {} : { glibcVersion: `glibc ${libc}` }) };
|
||||
expect(proof.facts).toEqual(measured);
|
||||
environment.metadata = { [SANDBOX_PLATFORM_PROBE_METADATA_KEY]: { company: proof } };
|
||||
expect(await readSandboxPlatformQualification({} as any, environment, "company")).toEqual(measured);
|
||||
expect(mockRuntimeAcquireRunLease).toHaveBeenCalledTimes(1);
|
||||
expect(await readSandboxPlatformQualification({} as any, environment, "foreign-company")).toBeNull();
|
||||
expect(await readSandboxPlatformQualification({} as any, { ...environment, config: { ...environment.config, image: `registry.example.test/runtime@sha256:${"b".repeat(64)}` } }, "company")).toBeNull();
|
||||
vi.mocked(configRuntime.resolveEnvironmentDriverConfigForRuntime).mockImplementation(async (_db, _company, _env, options) => {
|
||||
options?.onResolvedCustomImageTemplate?.({ id: "changed-template", capturedAt: null, templateKind: "image", bindingField: "image" });
|
||||
return { driver: "sandbox", config: environment.config };
|
||||
});
|
||||
expect(await readSandboxPlatformQualification({} as any, environment, "company")).toBeNull();
|
||||
expect(mockRuntimeAcquireRunLease).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each(["measurement", "cleanup", "config-race"])("does not claim qualification after %s failure while retaining connection readiness", async failure => {
|
||||
const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`;
|
||||
const environment = { id: "env", driver: "sandbox", status: "active", config: { provider: "daytona", image }, metadata: null } as any;
|
||||
vi.spyOn(configRuntime, "resolveEnvironmentDriverConfigForRuntime").mockResolvedValue({ driver: "sandbox", config: environment.config });
|
||||
mockRuntimeAcquireRunLease.mockResolvedValue({ lease: { id: "lease", provider: "daytona", metadata: { image } } });
|
||||
mockRuntimeExecute.mockResolvedValue({ exitCode: failure === "measurement" ? 1 : 0, stdout: "Linux\nx86_64\nglibc\nglibc 2.39\n" });
|
||||
if (failure === "cleanup") mockRuntimeReleaseRunLease.mockRejectedValueOnce(new Error("owned cleanup failed"));
|
||||
if (failure === "config-race") mockRecordPlatformProbe.mockResolvedValue(false);
|
||||
const result = await probeEnvironment({} as any, environment, { companyId: "company", acquireSandboxRuntimeLease: true });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.details?.platformQualificationRecorded).toBe(false);
|
||||
expect(mockRecordPlatformProbe.mock.calls[0]![0].proof).toBeNull();
|
||||
if (failure !== "config-race") expect(mockRecordPlatformProbe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each([
|
||||
null,
|
||||
{ id: "lease", companyId: "company", environmentId: "env", status: "released", cleanupStatus: "failed" },
|
||||
{ id: "lease", companyId: "company", environmentId: "env", status: "pending_cleanup", cleanupStatus: "failed" },
|
||||
{ id: "other-lease", companyId: "company", environmentId: "env", status: "released", cleanupStatus: "success" },
|
||||
{ id: "lease", companyId: "foreign-company", environmentId: "env", status: "released", cleanupStatus: "success" },
|
||||
{ id: "lease", companyId: "company", environmentId: "other-environment", status: "released", cleanupStatus: "success" },
|
||||
])("does not record platform evidence without an owned successful cleanup receipt (%j)", async receipt => {
|
||||
const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`;
|
||||
const environment = { id: "env", driver: "sandbox", status: "active", config: { provider: "daytona", image }, metadata: null } as any;
|
||||
vi.spyOn(configRuntime, "resolveEnvironmentDriverConfigForRuntime").mockResolvedValue({ driver: "sandbox", config: environment.config });
|
||||
mockRuntimeAcquireRunLease.mockResolvedValue({ lease: { id: "lease", provider: "daytona", metadata: { image } } });
|
||||
mockRuntimeExecute.mockResolvedValue({ exitCode: 0, stdout: "Linux\nx86_64\nglibc\nglibc 2.39\n" });
|
||||
mockRuntimeReleaseRunLease.mockResolvedValue(receipt);
|
||||
const result = await probeEnvironment({} as any, environment, { companyId: "company", acquireSandboxRuntimeLease: true });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.details?.platformQualificationRecorded).toBe(false);
|
||||
expect(mockRecordPlatformProbe).toHaveBeenCalledTimes(1);
|
||||
expect(mockRecordPlatformProbe.mock.calls[0]![0].proof).toBeNull();
|
||||
});
|
||||
it("reads an effective custom image without touching its usage timestamp", async () => {
|
||||
const update = vi.fn();
|
||||
const capturedAt = new Date("2026-10-12T00:00:00Z");
|
||||
const row = { id: "template", provider: "daytona", templateKind: "image", templateRef: `registry.example.test/runtime@sha256:${"a".repeat(64)}`, capturedAt, metadata: null };
|
||||
const db = { select: () => ({ from: () => ({ where: () => ({ orderBy: () => Promise.resolve([row]) }) }) }), update };
|
||||
const identity = vi.fn();
|
||||
const result = await resolveActiveEnvironmentCustomImageTemplateForRuntime(db as never, { environmentId: "env", baseConfig: { provider: "daytona", reuseLease: false }, runtimeConfig: { provider: "daytona", reuseLease: false }, readOnly: true, onResolvedTemplate: identity });
|
||||
expect(result).toMatchObject({ image: row.templateRef });
|
||||
expect(identity).toHaveBeenCalledWith({ id: "template", capturedAt: capturedAt.toISOString(), templateKind: "image", bindingField: "image" });
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["provider_template", "unknown"])("does not qualify a named active %s boot binding over a pinned base image", async templateKind => {
|
||||
const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`;
|
||||
const environment = { id: "env", driver: "sandbox", status: "active", config: { provider: "daytona", image }, metadata: null } as any;
|
||||
vi.spyOn(configRuntime, "resolveEnvironmentDriverConfigForRuntime").mockImplementation(async (_db, _company, _env, options) => {
|
||||
const bindingField = templateKind === "provider_template" ? "template" : "templateRef";
|
||||
options?.onResolvedCustomImageTemplate?.({ id: "active-template", capturedAt: null, templateKind, bindingField });
|
||||
return { driver: "sandbox", config: { ...environment.config, [bindingField]: "mutable-template-name" } };
|
||||
});
|
||||
mockRuntimeAcquireRunLease.mockResolvedValue({ lease: { id: "lease", provider: "daytona", metadata: { image } } });
|
||||
const result = await probeEnvironment({} as any, environment, { companyId: "company", acquireSandboxRuntimeLease: true });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.details?.platformQualificationRecorded).toBe(false);
|
||||
expect(mockRuntimeExecute).not.toHaveBeenCalled();
|
||||
expect(mockRecordPlatformProbe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("returns false when an atomic probe record loses its config comparison", async () => {
|
||||
const { environmentService } = await vi.importActual<typeof import("../services/environments.js")>("../services/environments.js");
|
||||
const returning = vi.fn().mockResolvedValue([]);
|
||||
const where = vi.fn(() => ({ returning }));
|
||||
const db = { update: vi.fn(() => ({ set: vi.fn(() => ({ where })) })) };
|
||||
expect(await environmentService(db as never).recordSandboxPlatformProbe({ environmentId: "env", companyId: "company", config: { provider: "daytona" }, proof: { v: 1 } })).toBe(false);
|
||||
expect(where).toHaveBeenCalledOnce();
|
||||
expect(returning).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports local environments as immediately available", async () => {
|
||||
const result = await probeEnvironment({} as any, {
|
||||
id: "env-1",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, type SQL } from "drizzle-orm";
|
||||
import type { UpdateEnvironment } from "@paperclipai/shared";
|
||||
import { SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "../services/environment-custom-image-runtime.js";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
@@ -130,6 +132,65 @@ describeEmbeddedPostgres("environmentService leases", () => {
|
||||
return companyId;
|
||||
}
|
||||
|
||||
it("clears user metadata to null when no private platform proof exists", async () => {
|
||||
const created = await svc.create({ name: "User metadata clear", driver: "sandbox", config: { provider: "daytona" }, metadata: { userNote: "remove" } });
|
||||
expect((await svc.update(created.id, { metadata: null }))?.metadata).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["clear", "replace", "unrelated", "status", "config", "driver"] as const)(
|
||||
"handles %s saves atomically after a concurrent platform probe record", async operation => {
|
||||
const companyId = await seedCompany();
|
||||
const config = { provider: "daytona", image: `registry.example.test/runtime@sha256:${"a".repeat(64)}` };
|
||||
const staleProof = { v: 1, marker: "old" };
|
||||
const freshProof = { v: 1, marker: "new" };
|
||||
const created = await svc.create({ name: "Concurrent metadata", driver: "sandbox", config,
|
||||
metadata: { userNote: "old", _paperclipManagedArchiveToken: "old-archive-token",
|
||||
[SANDBOX_PLATFORM_PROBE_METADATA_KEY]: { [companyId]: staleProof } } });
|
||||
const patches: Record<typeof operation, UpdateEnvironment> = {
|
||||
clear: { metadata: null },
|
||||
replace: { metadata: { userNote: "replacement", [SANDBOX_PLATFORM_PROBE_METADATA_KEY]: { [companyId]: staleProof } } },
|
||||
unrelated: { description: "unrelated save" },
|
||||
status: { status: "active" },
|
||||
config: { config: { ...config, image: `registry.example.test/runtime@sha256:${"b".repeat(64)}` } },
|
||||
driver: { driver: "ssh", config: { host: "fixture.example.test", username: "fixture" } },
|
||||
};
|
||||
// Interleave the real probe UPDATE after the save is prepared but before its
|
||||
// real UPDATE executes. This reproduces the stale route-read/write window.
|
||||
const delayedWriteDb = {
|
||||
update(table: typeof environments) {
|
||||
const prepared = db.update(table);
|
||||
return {
|
||||
set(values: Parameters<typeof prepared.set>[0]) {
|
||||
const statement = prepared.set(values);
|
||||
return {
|
||||
where(condition: SQL) {
|
||||
const pending = statement.where(condition);
|
||||
return {
|
||||
async returning() {
|
||||
expect(await svc.recordSandboxPlatformProbe({
|
||||
environmentId: created.id, companyId, config, proof: freshProof,
|
||||
})).toBe(true);
|
||||
return pending.returning();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const saved = await svc.update(created.id, patches[operation], { db: delayedWriteDb as never });
|
||||
if (operation === "config" || operation === "driver") {
|
||||
expect(saved?.metadata).not.toHaveProperty(SANDBOX_PLATFORM_PROBE_METADATA_KEY);
|
||||
} else {
|
||||
expect(saved?.metadata?.[SANDBOX_PLATFORM_PROBE_METADATA_KEY]).toEqual({ [companyId]: freshProof });
|
||||
}
|
||||
if (operation === "clear") expect(saved?.metadata).toEqual({ [SANDBOX_PLATFORM_PROBE_METADATA_KEY]: { [companyId]: freshProof } });
|
||||
else expect(saved?.metadata?.userNote).toBe(operation === "replace" ? "replacement" : "old");
|
||||
if (operation !== "unrelated") expect(saved?.metadata).not.toHaveProperty("_paperclipManagedArchiveToken");
|
||||
},
|
||||
);
|
||||
|
||||
it("acquires and releases a lease for a run", async () => {
|
||||
const { companyId, environmentId, runId } = await seedEnvironment();
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
|
||||
import { assertBoardOrgAccess, getActorInfo } from "./authz.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
import { environmentService } from "../services/environments.js";
|
||||
import { SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "../services/environment-custom-image-runtime.js";
|
||||
import { environmentRuntimeService } from "../services/environment-runtime.js";
|
||||
import { executionWorkspaceService } from "../services/execution-workspaces.js";
|
||||
import { closeWarmNativeSessionsForEnvironment } from "../services/native-runtime/native-session-executor.js";
|
||||
@@ -311,6 +312,9 @@ async function assertPlatformProvisionedEnvironmentWritable(
|
||||
* Clearing (null/false) is allowed so stale markers can be removed.
|
||||
*/
|
||||
function assertNoClientPlatformProvisionedMarkers(metadata: unknown): void {
|
||||
if (isPlainRecord(metadata) && Object.prototype.hasOwnProperty.call(metadata, SANDBOX_PLATFORM_PROBE_METADATA_KEY)) {
|
||||
throw unprocessable("Sandbox platform qualification is recorded only by an explicit environment probe.", { code: "environment_platform_probe_reserved" });
|
||||
}
|
||||
if (!isCloudManagedInstance() || !isPlainRecord(metadata)) return;
|
||||
for (const key of PLATFORM_PROVISIONED_MARKER_KEYS) {
|
||||
if (metadata[key] !== undefined && metadata[key] !== null && metadata[key] !== false) {
|
||||
@@ -396,6 +400,10 @@ export function environmentRoutes(
|
||||
envVars?: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}>(req: Request, environment: T): T {
|
||||
if (environment.metadata) {
|
||||
const { [SANDBOX_PLATFORM_PROBE_METADATA_KEY]: _probe, ...metadata } = environment.metadata;
|
||||
environment = { ...environment, metadata };
|
||||
}
|
||||
// Floor: on cloud-managed instances, platform-provisioned rows use the
|
||||
// floored view — credential-shaped config keys are never echoed to any
|
||||
// reader, structural config stays visible, and tenant env vars on the
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION, createAgentSchema, updateAgentSchema, testAdapterEnvironmentSchema } from "@paperclipai/shared";
|
||||
const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set<string>(), settings: {} as Record<string, unknown>, getEnvironment: vi.fn(), bindings: vi.fn(), managedEnvironment: vi.fn(), ssh: vi.fn(), resolveEnvironment: vi.fn() }));
|
||||
const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set<string>(), settings: {} as Record<string, unknown>, getEnvironment: vi.fn(), bindings: vi.fn(), managedEnvironment: vi.fn(), ssh: vi.fn(), resolveEnvironment: vi.fn(), sandboxQualification: vi.fn() }));
|
||||
vi.mock("../adapters/registry.js", () => ({ findActiveServerAdapter: (type: string) => ({ type }), hasActiveAdapterOverride: (type: string) => state.overridden.has(type), waitForExternalAdapters: async () => {} }));
|
||||
vi.mock("./adapter-plugin-store.js", () => ({ getDisabledAdapterTypes: () => state.disabled }));
|
||||
vi.mock("./instance-settings.js", () => ({ instanceSettingsService: () => ({ get: async () => state.settings }) }));
|
||||
vi.mock("./environments.js", () => ({ environmentService: () => ({ getById: state.getEnvironment, listBoundCompanyIds: state.bindings, findManagedSandboxEnvironment: state.managedEnvironment, findKubernetesEnvironment: state.managedEnvironment }) }));
|
||||
vi.mock("./environment-probe.js", () => ({ readSandboxPlatformQualification: state.sandboxQualification }));
|
||||
vi.mock("./environment-config.js", () => ({ resolveEnvironmentDriverConfigForRuntime: state.resolveEnvironment }));
|
||||
vi.mock("@paperclipai/adapter-utils/ssh", () => ({ runSshCommand: state.ssh }));
|
||||
import { AGENT_RUNNER_SSH_PLATFORM_COMMAND, agentRunnerAvailability, resolveAgentRunnerTargetForCompany, resolveNewAgentRunner, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
|
||||
@@ -16,7 +17,7 @@ const originalArchitecture = Object.getOwnPropertyDescriptor(process, "arch")!;
|
||||
afterEach(() => { vi.restoreAllMocks(); Object.defineProperty(process, "platform", originalPlatform); Object.defineProperty(process, "arch", originalArchitecture); });
|
||||
|
||||
describe("server-owned Codex runner selection", () => {
|
||||
beforeEach(() => { state.disabled = []; state.overridden.clear(); state.settings = {}; state.getEnvironment.mockReset(); state.bindings.mockReset().mockResolvedValue([]); state.managedEnvironment.mockReset(); state.ssh.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n" }); state.resolveEnvironment.mockReset().mockResolvedValue({ driver: "ssh", config: {} }); });
|
||||
beforeEach(() => { state.disabled = []; state.overridden.clear(); state.settings = {}; state.getEnvironment.mockReset(); state.bindings.mockReset().mockResolvedValue([]); state.managedEnvironment.mockReset(); state.ssh.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n" }); state.sandboxQualification.mockReset().mockResolvedValue(null); state.resolveEnvironment.mockReset().mockResolvedValue({ driver: "ssh", config: {} }); });
|
||||
it.each(["x64", "arm64"])("keeps detected musl/unknown Linux %s legacy and preserves saved execution", architecture => {
|
||||
for (const libc of ["musl", "unknown"] as const) {
|
||||
const target = { driver: "local", platform: "linux", architecture, libc };
|
||||
@@ -166,10 +167,30 @@ describe("server-owned Codex runner selection", () => {
|
||||
expect(state.getEnvironment).not.toHaveBeenCalled();
|
||||
expect(state.ssh).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([
|
||||
[{ platform: "linux", architecture: "x64", libc: "glibc", glibcVersion: "2.39" }, "paperclip_runner"],
|
||||
[{ platform: "linux", architecture: "arm64", libc: "glibc", glibcVersion: "3.0" }, "paperclip_runner"],
|
||||
[{ platform: "linux", architecture: "arm64", libc: "glibc", glibcVersion: "2.38" }, "codex_local"],
|
||||
[{ platform: "linux", architecture: "x64", libc: "glibc" }, "codex_local"],
|
||||
[{ platform: "linux", architecture: "arm64", libc: "musl" }, "codex_local"],
|
||||
[{ platform: "linux", architecture: "unknown", libc: "glibc", glibcVersion: "2.39" }, "codex_local"],
|
||||
])("uses cached selected-image measurements for sandbox creation/discovery (%j)", async (facts, expected) => {
|
||||
state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", config: { provider: "daytona" } });
|
||||
state.sandboxQualification.mockResolvedValue(facts);
|
||||
const selectedTarget = await resolveAgentRunnerTargetForCompany({} as never, "company", "sandbox");
|
||||
expect(agentRunnerAvailability("codex_local", selectedTarget).defaultRunner).toBe(expected === "paperclip_runner" ? "paperclip" : "legacy");
|
||||
const result = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "sandbox" });
|
||||
expect(result.adapterType).toBe(expected);
|
||||
expect(state.ssh).not.toHaveBeenCalled();
|
||||
expect(state.resolveEnvironment).not.toHaveBeenCalled();
|
||||
if (expected === "codex_local") await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", runner: "paperclip", defaultEnvironmentId: "sandbox" })).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
it("does not infer a sandbox image architecture from its driver", async () => {
|
||||
state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", config: { provider: "daytona" } });
|
||||
const result = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "sandbox" });
|
||||
expect(result.adapterType).toBe("paperclip_runner"); expect(state.ssh).not.toHaveBeenCalled();
|
||||
expect(result.adapterType).toBe("codex_local"); expect(state.ssh).not.toHaveBeenCalled();
|
||||
expect(state.sandboxQualification).toHaveBeenCalledWith({}, expect.objectContaining({ id: "sandbox" }), "company");
|
||||
await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", runner: "paperclip", defaultEnvironmentId: "sandbox" })).rejects.toMatchObject({ status: 422, details: { code: "agent_runner_unavailable" } });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ function runnerAdaptersAvailable(harness: string, driver: string): boolean {
|
||||
|
||||
export function agentRunnerAvailability(harness: string, target: RunnerTarget = localAgentRunnerTarget()): AgentRunnerAvailability {
|
||||
const supported = runnerAdaptersAvailable(harness, target.driver)
|
||||
&& (target.driver !== "sandbox" || Boolean(target.platform && target.architecture
|
||||
&& (target.platform !== "linux" || (target.libc === "glibc" && target.glibcVersion))))
|
||||
&& (harness === "claude_local"
|
||||
? !!target.platform && paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion)
|
||||
: !target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion));
|
||||
@@ -108,7 +110,9 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp
|
||||
return { adapterType: "codex_local", adapterConfig: { ...config, dangerouslyBypassApprovalsAndSandbox: config.dangerouslyBypassApprovalsAndSandbox ?? true } };
|
||||
}
|
||||
if (native && harness !== "codex_local" && choice === "paperclip") return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
|
||||
if (!availability.supportedRunners.includes("paperclip")) throw unprocessable("Paperclip Runner is unavailable for this harness and environment. Select Legacy runner in Advanced.", { code: "agent_runner_unavailable" });
|
||||
if (!availability.supportedRunners.includes("paperclip")) throw unprocessable(input.target?.driver === "sandbox"
|
||||
? "Paperclip Runner is unavailable for this sandbox. Choose an immutable OCI image digest and run its environment connection probe to qualify the OS, architecture and libc, or select Legacy runner in Advanced."
|
||||
: "Paperclip Runner is unavailable for this harness and environment. Select Legacy runner in Advanced.", { code: "agent_runner_unavailable" });
|
||||
if (native) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
|
||||
if (harness === "claude_local") {
|
||||
config = resolveClaudeRunnerConfig(config);
|
||||
@@ -174,7 +178,12 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri
|
||||
}
|
||||
const driver = environment?.driver ?? "local";
|
||||
let target: RunnerTarget = driver === "local" ? localAgentRunnerTarget()
|
||||
: { driver }; // Sandbox platform/artifact identity is verified by selected-runtime setup; do not guess an image's architecture.
|
||||
: { driver };
|
||||
if (driver === "sandbox" && environment) {
|
||||
const { readSandboxPlatformQualification } = await import("./environment-probe.js");
|
||||
const qualified = await readSandboxPlatformQualification(db, environment, companyId);
|
||||
if (qualified) target = { driver, ...qualified };
|
||||
}
|
||||
if (driver === "ssh" && environment && ["codex_local", "claude_local"].some(harness => runnerAdaptersAvailable(harness, driver))) {
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment);
|
||||
if (parsed.driver !== "ssh") throw unprocessable("The selected SSH environment is unavailable.");
|
||||
|
||||
@@ -660,6 +660,8 @@ export async function resolveEnvironmentDriverConfigForRuntime(
|
||||
// but must still resolve the active custom image as prepared runtime
|
||||
// configuration and tooling so the test reflects what real agent runs use.
|
||||
applyCustomImageTemplate?: boolean;
|
||||
readOnlyCustomImageTemplate?: boolean;
|
||||
onResolvedCustomImageTemplate?: (identity: { id: string; capturedAt: string | null; templateKind: string; bindingField: string }) => void;
|
||||
},
|
||||
): Promise<ParsedEnvironmentConfig> {
|
||||
const parsed = parseEnvironmentDriverConfig(environment);
|
||||
@@ -729,6 +731,8 @@ export async function resolveEnvironmentDriverConfigForRuntime(
|
||||
// are excluded when the template's source fingerprint is computed,
|
||||
// so they must be excluded when re-checking it here.
|
||||
secretRefExcludePaths: collectSecretRefPaths(schema),
|
||||
readOnly: context?.readOnlyCustomImageTemplate,
|
||||
onResolvedTemplate: context?.onResolvedCustomImageTemplate,
|
||||
})
|
||||
: runtimeConfig,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@ import { readConfigValueAtPath, writeConfigValueAtPath } from "./json-schema-sec
|
||||
type TemplateRow = typeof environmentCustomImageTemplates.$inferSelect;
|
||||
|
||||
export const ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY = "runtimeConfigBinding";
|
||||
/** Measured platform evidence is reserved to explicit server-side environment probes. */
|
||||
export const SANDBOX_PLATFORM_PROBE_METADATA_KEY = "paperclipSandboxPlatformProbes";
|
||||
export const ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS = [
|
||||
"timeoutMs",
|
||||
"reuseLease",
|
||||
@@ -557,6 +559,8 @@ export async function resolveActiveEnvironmentCustomImageTemplateForRuntime(
|
||||
runtimeConfig: SandboxEnvironmentConfig;
|
||||
secretRefExcludePaths?: Iterable<string>;
|
||||
now?: Date;
|
||||
readOnly?: boolean;
|
||||
onResolvedTemplate?: (identity: { id: string; capturedAt: string | null; templateKind: string; bindingField: string }) => void;
|
||||
},
|
||||
): Promise<SandboxEnvironmentConfig> {
|
||||
const row = await db
|
||||
@@ -587,7 +591,9 @@ export async function resolveActiveEnvironmentCustomImageTemplateForRuntime(
|
||||
// match. Runtime-only knobs such as lease reuse, timeouts, and resource hints
|
||||
// are excluded from the fingerprint so those edits do not discard the capture.
|
||||
const now = input.now ?? new Date();
|
||||
await db
|
||||
input.onResolvedTemplate?.({ id: row.id, capturedAt: row.capturedAt?.toISOString() ?? null,
|
||||
templateKind: row.templateKind ?? "unknown", bindingField: resolveEnvironmentCustomImageRuntimeConfigBinding(row).field });
|
||||
if (!input.readOnly) await db
|
||||
.update(environmentCustomImageTemplates)
|
||||
.set({ lastUsedAt: now, updatedAt: now })
|
||||
.where(eq(environmentCustomImageTemplates.id, active.id));
|
||||
|
||||
@@ -11,6 +11,56 @@ import { isBuiltinSandboxProvider, probeSandboxProvider } from "./sandbox-provid
|
||||
import { probePluginEnvironmentDriver, probePluginSandboxProviderDriver } from "./plugin-environment-driver.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
import { environmentRuntimeService } from "./environment-runtime.js";
|
||||
import { environmentService } from "./environments.js";
|
||||
import { fingerprintEnvironmentSandboxProviderConfig, SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "./environment-custom-image-runtime.js";
|
||||
import type { SandboxEnvironmentConfig } from "@paperclipai/shared";
|
||||
|
||||
export const SANDBOX_PLATFORM_PROBE_COMMAND = "uname -s; uname -m; if version=$(getconf GNU_LIBC_VERSION 2>/dev/null) && [ -n \"$version\" ]; then printf 'glibc\\n%s\\n' \"$version\"; else case \"$(ldd --version 2>&1)\" in *musl*) printf 'musl\\n' ;; *) printf 'unknown\\n' ;; esac; fi";
|
||||
type PlatformFacts = { platform: string; architecture: string; libc: "glibc" | "musl" | "unknown"; glibcVersion?: string };
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
function fingerprint(config: Record<string, unknown>): string {
|
||||
return fingerprintEnvironmentSandboxProviderConfig(config as SandboxEnvironmentConfig);
|
||||
}
|
||||
function isImmutableImage(value: unknown): value is string {
|
||||
return typeof value === "string" && /^[^\s@]+@sha256:[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
export function parseSandboxPlatformFacts(output: string): PlatformFacts {
|
||||
const [os, arch, libc, version] = output.trim().split(/\r?\n/);
|
||||
return { platform: os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : "unknown",
|
||||
architecture: arch === "x86_64" ? "x64" : arch === "aarch64" || arch === "arm64" ? "arm64" : "unknown",
|
||||
libc: libc === "glibc" || libc === "musl" ? libc : "unknown",
|
||||
...(libc === "glibc" ? { glibcVersion: version?.trim() ?? "unknown" } : {}) };
|
||||
}
|
||||
async function sandboxBinding(db: Db, environment: Environment, companyId: string) {
|
||||
const selectedTemplate: { value: { id: string; capturedAt: string | null; templateKind: string; bindingField: string } | null } = { value: null };
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment, {
|
||||
applyCustomImageTemplate: true, readOnlyCustomImageTemplate: true,
|
||||
onResolvedCustomImageTemplate: identity => { selectedTemplate.value = identity; },
|
||||
});
|
||||
if (parsed.driver !== "sandbox") return null;
|
||||
const image = parsed.config.image;
|
||||
// Names, mutable tags, provider defaults and snapshot names are not immutable identities.
|
||||
const template = selectedTemplate.value;
|
||||
if (!isImmutableImage(image) || ["snapshot", "template", "templateRef"].some(key => record(parsed.config)[key])
|
||||
|| (template && (template.templateKind !== "image" || template.bindingField !== "image"))) return null;
|
||||
return { baseConfigFingerprint: fingerprint(environment.config), runtimeConfigFingerprint: fingerprint(parsed.config as unknown as Record<string, unknown>), image, template };
|
||||
}
|
||||
|
||||
/** Read measured, company-scoped image evidence without leasing or contacting a provider. */
|
||||
export async function readSandboxPlatformQualification(db: Db, environment: Environment, companyId: string): Promise<PlatformFacts | null> {
|
||||
const proof = record(record(environment.metadata?.[SANDBOX_PLATFORM_PROBE_METADATA_KEY])[companyId]);
|
||||
if (proof.v !== 1 || proof.companyId !== companyId || proof.baseConfigFingerprint !== fingerprint(environment.config)) return null;
|
||||
const binding = await sandboxBinding(db, environment, companyId);
|
||||
if (!binding || proof.runtimeConfigFingerprint !== binding.runtimeConfigFingerprint || proof.image !== binding.image
|
||||
|| JSON.stringify(proof.template) !== JSON.stringify(binding.template)) return null;
|
||||
const facts = record(proof.facts);
|
||||
if (typeof facts.platform !== "string" || typeof facts.architecture !== "string"
|
||||
|| !["glibc", "musl", "unknown"].includes(String(facts.libc))) return null;
|
||||
return { platform: facts.platform, architecture: facts.architecture, libc: facts.libc as PlatformFacts["libc"],
|
||||
...(typeof facts.glibcVersion === "string" ? { glibcVersion: facts.glibcVersion } : {}) };
|
||||
}
|
||||
|
||||
export async function probeEnvironment(
|
||||
db: Db,
|
||||
@@ -24,6 +74,10 @@ export async function probeEnvironment(
|
||||
} = {},
|
||||
): Promise<EnvironmentProbeResult> {
|
||||
const resolvedCompanyId = options.companyId ?? null;
|
||||
const savedSandboxProbe = environment.driver === "sandbox" && options.acquireSandboxRuntimeLease === true && resolvedCompanyId;
|
||||
const environments = environmentService(db);
|
||||
if (savedSandboxProbe) await environments.recordSandboxPlatformProbe({ environmentId: environment.id,
|
||||
companyId: savedSandboxProbe, config: environment.config, proof: null });
|
||||
const parsed = options.resolvedConfig ?? (
|
||||
options.acquireSandboxRuntimeLease === true
|
||||
? parseEnvironmentDriverConfig(environment)
|
||||
@@ -77,6 +131,8 @@ export async function probeEnvironment(
|
||||
};
|
||||
let leaseRecord: Awaited<ReturnType<typeof runtime.acquireRunLease>> | null = null;
|
||||
let releaseStatus: "released" | "failed" = "released";
|
||||
let qualification: Record<string, unknown> | null = null;
|
||||
let details: Record<string, unknown> | null = null;
|
||||
try {
|
||||
leaseRecord = await runtime.acquireRunLease({
|
||||
companyId: resolvedCompanyId,
|
||||
@@ -93,19 +149,37 @@ export async function probeEnvironment(
|
||||
const sandboxName = typeof metadata.sandboxName === "string" && metadata.sandboxName.trim().length > 0
|
||||
? metadata.sandboxName.trim()
|
||||
: null;
|
||||
details = { provider, providerLeaseId: leaseRecord.lease.providerLeaseId, leaseId: leaseRecord.lease.id,
|
||||
leasePolicy: leaseRecord.lease.leasePolicy, metadata, platformQualificationRecorded: false,
|
||||
platformQualificationDiagnostic: "An immutable OCI image digest is required; this connection probe does not qualify a mutable image or snapshot name." };
|
||||
if (isImmutableImage(metadata.image) && !metadata.snapshot) {
|
||||
try {
|
||||
const binding = await sandboxBinding(db, environment, resolvedCompanyId);
|
||||
if (binding && metadata.image === binding.image) {
|
||||
const platform = await runtime.execute({ environment: probeEnvironmentConfig, lease: leaseRecord.lease,
|
||||
command: "sh", args: ["-c", SANDBOX_PLATFORM_PROBE_COMMAND],
|
||||
bypassSession: true, timeoutMs: 10_000, commandTimeoutMs: 10_000 });
|
||||
if (platform.exitCode !== 0) throw new Error("platform_probe_failed");
|
||||
const facts = parseSandboxPlatformFacts(platform.stdout);
|
||||
details.platform = facts;
|
||||
if (facts.platform !== "unknown" && facts.architecture !== "unknown"
|
||||
&& (facts.platform !== "linux" || facts.libc !== "unknown")
|
||||
&& (facts.libc !== "glibc" || /^(?:glibc\s+)?\d+\.\d+(?:\.\d+)?$/.test(facts.glibcVersion ?? ""))) {
|
||||
qualification = { v: 1, companyId: resolvedCompanyId, ...binding, facts, probedAt: new Date().toISOString() };
|
||||
delete details.platformQualificationDiagnostic;
|
||||
} else details.platformQualificationDiagnostic = "The image's OS, architecture or libc could not be identified. Legacy remains the creation default.";
|
||||
} else details.platformQualificationDiagnostic = "The live lease does not match the selected immutable image configuration. Probe again.";
|
||||
} catch {
|
||||
details.platformQualificationDiagnostic = "Platform qualification could not complete; connection readiness does not establish native compatibility. Check the image and probe again.";
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
driver: "sandbox",
|
||||
summary: sandboxName
|
||||
? `Connected to ${provider} sandbox ${sandboxName}.`
|
||||
: `Connected to ${provider} sandbox environment.`,
|
||||
details: {
|
||||
provider,
|
||||
providerLeaseId: leaseRecord.lease.providerLeaseId,
|
||||
leaseId: leaseRecord.lease.id,
|
||||
leasePolicy: leaseRecord.lease.leasePolicy,
|
||||
metadata,
|
||||
},
|
||||
details,
|
||||
};
|
||||
} catch (error) {
|
||||
releaseStatus = "failed";
|
||||
@@ -122,11 +196,25 @@ export async function probeEnvironment(
|
||||
if (leaseRecord) {
|
||||
const driver = runtime.getDriver(environment.driver);
|
||||
try {
|
||||
await driver?.releaseRunLease({
|
||||
const releasedLease = await driver?.releaseRunLease({
|
||||
environment: probeEnvironmentConfig,
|
||||
lease: leaseRecord.lease,
|
||||
status: releaseStatus,
|
||||
});
|
||||
if (qualification && driver && savedSandboxProbe
|
||||
&& releasedLease?.id === leaseRecord.lease.id
|
||||
&& releasedLease.companyId === resolvedCompanyId
|
||||
&& releasedLease.environmentId === environment.id
|
||||
&& releasedLease.status === "released" && releasedLease.cleanupStatus === "success") {
|
||||
const recorded = await environments.recordSandboxPlatformProbe({ environmentId: environment.id, companyId: savedSandboxProbe,
|
||||
config: environment.config, proof: qualification });
|
||||
if (details) {
|
||||
details.platformQualificationRecorded = recorded;
|
||||
if (!recorded) details.platformQualificationDiagnostic = "The environment changed during the probe. Probe its current configuration again.";
|
||||
}
|
||||
} else if (qualification && details) {
|
||||
details.platformQualificationDiagnostic = "The probe lease cleanup could not be verified. Resolve pending cleanup and probe again.";
|
||||
}
|
||||
} catch (releaseError) {
|
||||
// Cleanup failures must not mask the connection result shown to
|
||||
// the operator, but a leaked sandbox should still be traceable.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, desc, eq, inArray, isNull, ne, sql } from "drizzle-orm";
|
||||
|
||||
import { SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "./environment-custom-image-runtime.js";
|
||||
import { and, desc, eq, inArray, isNull, ne, sql, type SQL } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
@@ -859,6 +861,21 @@ export function environmentService(db: Db) {
|
||||
};
|
||||
|
||||
return {
|
||||
recordSandboxPlatformProbe: async (input: {
|
||||
environmentId: string;
|
||||
companyId: string;
|
||||
config: Record<string, unknown>;
|
||||
proof: Record<string, unknown> | null;
|
||||
}): Promise<boolean> => {
|
||||
const existing = sql`coalesce(${environments.metadata}->${SANDBOX_PLATFORM_PROBE_METADATA_KEY}, '{}'::jsonb)`;
|
||||
const probes = input.proof === null
|
||||
? sql`${existing} - ${input.companyId}`
|
||||
: sql`${existing} || ${JSON.stringify({ [input.companyId]: input.proof })}::jsonb`;
|
||||
const rows = await db.update(environments).set({
|
||||
metadata: sql`jsonb_set(coalesce(${environments.metadata}, '{}'::jsonb), ARRAY[${SANDBOX_PLATFORM_PROBE_METADATA_KEY}]::text[], ${probes}, true)`,
|
||||
}).where(and(eq(environments.id, input.environmentId), eq(environments.driver, "sandbox"), eq(environments.config, input.config))).returning({ id: environments.id });
|
||||
return rows.length > 0;
|
||||
},
|
||||
list: async (
|
||||
companyIdOrFilters?: string | EnvironmentListFilters,
|
||||
maybeFilters?: EnvironmentListFilters,
|
||||
@@ -1118,20 +1135,30 @@ export function environmentService(db: Db) {
|
||||
if ("envVars" in patch && patch.envVars !== undefined) {
|
||||
values.envVars = (patch.envVars ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
const changesTarget = patch.config !== undefined || patch.driver !== undefined;
|
||||
let metadataUpdate: SQL | undefined;
|
||||
if (patch.metadata !== undefined) {
|
||||
values.metadata = withoutManagedEnvironmentArchiveToken(patch.metadata ?? null);
|
||||
} else if (patch.status !== undefined) {
|
||||
const existingMetadata = await writeDb
|
||||
.select({ metadata: environments.metadata })
|
||||
.from(environments)
|
||||
.where(eq(environments.id, id))
|
||||
.then((rows) => rows[0]?.metadata ?? null);
|
||||
values.metadata = withoutManagedEnvironmentArchiveToken(existingMetadata);
|
||||
const userMetadata = withoutManagedEnvironmentArchiveToken(patch.metadata ?? null);
|
||||
if (userMetadata) delete userMetadata[SANDBOX_PLATFORM_PROBE_METADATA_KEY];
|
||||
if (changesTarget) {
|
||||
values.metadata = userMetadata;
|
||||
} else {
|
||||
const replacement = userMetadata === null ? sql`null::jsonb` : sql`${JSON.stringify(userMetadata)}::jsonb`;
|
||||
// Preserve the current private subtree in the same statement. A route's
|
||||
// earlier read must never replace a newer concurrent probe receipt.
|
||||
metadataUpdate = sql`case when ${environments.metadata} ? ${SANDBOX_PLATFORM_PROBE_METADATA_KEY}
|
||||
then coalesce(${replacement}, '{}'::jsonb) || jsonb_build_object(
|
||||
${SANDBOX_PLATFORM_PROBE_METADATA_KEY}::text, ${environments.metadata}->${SANDBOX_PLATFORM_PROBE_METADATA_KEY})
|
||||
else ${replacement} end`;
|
||||
}
|
||||
} else if (changesTarget || patch.status !== undefined) {
|
||||
metadataUpdate = sql`${environments.metadata} - ${MANAGED_ENVIRONMENT_ARCHIVE_TOKEN_METADATA_KEY}`;
|
||||
if (changesTarget) metadataUpdate = sql`${metadataUpdate} - ${SANDBOX_PLATFORM_PROBE_METADATA_KEY}`;
|
||||
}
|
||||
|
||||
const row = await writeDb
|
||||
.update(environments)
|
||||
.set(values)
|
||||
.set({ ...values, ...(metadataUpdate ? { metadata: metadataUpdate } : {}) })
|
||||
.where(eq(environments.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null)
|
||||
|
||||
Reference in new issue
Block a user