mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent owners configure the harness and computer used for that work. > - A legacy runner upgrade must first qualify an unverified sandbox. > - The upgrade called an instance-admin probe, so a company owner with agent configuration permission received HTTP 403. > - This pull request qualifies the saved computer through the agent's existing configuration authorization. > - Company owners can complete the upgrade while general instance environment management stays restricted. ## Linked Issues or Issue Description Refs #17361. Related: #17122. **What happened?** Upgrade runner displayed for an existing Claude agent on a Daytona Paperclip Computer. Clicking it returned “Instance admin access required” for a cloud company owner. The agent remained on the legacy runner. **Expected behavior** A caller permitted to update the agent can qualify that agent's saved computer and upgrade. Instance-admin access is not required for this agent-scoped operation. **Steps to reproduce** 1. Sign in as a cloud company owner with `agents:configure` and no instance-admin role. 2. Open an existing legacy Claude agent on an unqualified Daytona snapshot. 3. Select Upgrade runner. ## What Changed - Add `POST /api/agents/{id}/qualify-runner` with the existing `agent_config:update` guard. - Reuse the server's effective environment resolver. The submitted environment ID only detects stale state; it cannot select a different computer. - Preserve company binding checks, check binding again at lease acquisition, and record agent-scoped audit activity. - Return qualification status and the lease receipt without instance-level provider metadata or raw provider errors. - Use the new endpoint for the existing-agent upgrade. Keep the generic environment probe restricted to instance admins. - Disable Upgrade runner when an unsaved computer change resolves to a different target, with a save-first explanation. Other draft settings still accompany an upgrade. - Add real-database authorization regression tests and update the API contract and access maps. ## Verification - Red: the existing UI called the instance-wide probe and failed the new agent-scoped call assertions in eight upgrade cases. - Green: 349 related route, provider qualification, runner selection, and UI tests passed. - Green: 12 authorization cases passed with real PostgreSQL, real HTTP routes, real saved role grants, and a simulated provider boot. They cover the cloud owner, removed grants, foreign agent/computer, inherited targets, stale target, invalid body, archived/local target, redacted failure, audit scope, and unchanged agent settings. - Review regression: two cases first reproduced the enabled upgrade button after an unsaved computer change; both now pass, along with all 168 related UI tests. Qualified/unqualified computers and restoring the saved selection are covered. - All 26 OpenAPI cases passed; the 12 real-database authorization cases also passed after rebase. - The host exhausted PostgreSQL semaphores. For the 12 cases, a temporary local-only setup used disposable PostgreSQL 18 in Docker instead of the embedded database launcher. The committed tests use the normal embedded database helper. No authorization decisions were mocked. - `pnpm -r typecheck`, `pnpm build`, `pnpm check:token-gates`, map validation, module-boundary validation, and `git diff --check` passed. - `pnpm test:run` was attempted but did not establish a full local pass: the host could not start embedded PostgreSQL clusters. All current-head CI gates passed, including the full suite in CI. The commitperclip review workflow timed out before reporting any executed steps on its first attempt and passed on one retry. - Fresh staging acceptance remains pending deployment. Verify persisted native configuration and a successful runtime test on the existing Daytona computer after merge. Earlier live Daytona proof from #17361 is separate evidence. ## Risks - Qualification can start a fresh, temporary sandbox. It uses the existing probe lifecycle and cleanup contract. - The new path must remain scoped to the saved agent and its company. Denial and redaction tests cover that boundary. General instance probe authorization is unchanged. - No migration, role change, credential change, or automatic agent upgrade is included. ## Model Used - OpenAI GPT-6 through Codex, with reasoning, code execution, and browser tools. The exact deployed model variant and context-window size were not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
335 lines
16 KiB
TypeScript
335 lines
16 KiB
TypeScript
import type { Environment, EnvironmentProbeResult } from "@paperclipai/shared";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { ensureSshWorkspaceReady } from "@paperclipai/adapter-utils/ssh";
|
|
import {
|
|
parseEnvironmentDriverConfig,
|
|
resolveEnvironmentDriverConfigForRuntime,
|
|
type ParsedEnvironmentConfig,
|
|
} from "./environment-config.js";
|
|
import os from "node:os";
|
|
import { isBuiltinSandboxProvider, probeSandboxProvider } from "./sandbox-provider-runtime.js";
|
|
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";
|
|
import { isQualifiableDaytonaSnapshot, qualifiedSandboxSnapshotId, readSnapshotIdentity, type SandboxTemplateIdentity } from "./sandbox-snapshot-qualification.js";
|
|
|
|
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: SandboxTemplateIdentity } = { 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;
|
|
const template = selectedTemplate.value;
|
|
if (isQualifiableDaytonaSnapshot(parsed.config, template)) {
|
|
return { baseConfigFingerprint: fingerprint(environment.config), runtimeConfigFingerprint: fingerprint(parsed.config),
|
|
snapshot: parsed.config.snapshot, template, qualifiedSnapshotId: qualifiedSandboxSnapshotId(environment, companyId, parsed.config, template) };
|
|
}
|
|
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 };
|
|
}
|
|
|
|
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 (![1, 2].includes(Number(proof.v)) || proof.companyId !== companyId || proof.baseConfigFingerprint !== fingerprint(environment.config)) return null;
|
|
const binding = await sandboxBinding(db, environment, companyId);
|
|
if (!binding || (proof.v === 2 ? !("qualifiedSnapshotId" in binding && binding.qualifiedSnapshotId)
|
|
: !("image" in binding) || proof.image !== binding.image)
|
|
|| proof.runtimeConfigFingerprint !== binding.runtimeConfigFingerprint
|
|
|| 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,
|
|
environment: Environment,
|
|
options: {
|
|
companyId?: string | null;
|
|
pluginWorkerManager?: PluginWorkerManager;
|
|
resolvedConfig?: ParsedEnvironmentConfig;
|
|
applyCustomImageTemplate?: boolean;
|
|
acquireSandboxRuntimeLease?: boolean;
|
|
assertCompanyBinding?: boolean;
|
|
} = {},
|
|
): 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)
|
|
: resolvedCompanyId || options.applyCustomImageTemplate === true
|
|
? await resolveEnvironmentDriverConfigForRuntime(db, resolvedCompanyId, environment, {
|
|
applyCustomImageTemplate: options.applyCustomImageTemplate === true,
|
|
})
|
|
: parseEnvironmentDriverConfig(environment)
|
|
);
|
|
|
|
if (parsed.driver === "local") {
|
|
return {
|
|
ok: true,
|
|
driver: "local",
|
|
summary: "Local environment is available on this Paperclip host.",
|
|
details: {
|
|
hostname: os.hostname(),
|
|
cwd: process.cwd(),
|
|
},
|
|
};
|
|
}
|
|
|
|
if (parsed.driver === "sandbox") {
|
|
if (options.acquireSandboxRuntimeLease) {
|
|
if (!resolvedCompanyId) {
|
|
return {
|
|
ok: false,
|
|
driver: "sandbox",
|
|
summary: "Sandbox environment probe requires a companyId context.",
|
|
details: {
|
|
provider: parsed.config.provider,
|
|
},
|
|
};
|
|
}
|
|
|
|
const runtime = environmentRuntimeService(db, {
|
|
pluginWorkerManager: options.pluginWorkerManager,
|
|
});
|
|
const probeEnvironmentConfig = {
|
|
...environment,
|
|
config: {
|
|
...(environment.config ?? {}),
|
|
// Test probes should prove a fresh provider boot, not resume a retained
|
|
// agent lease and report success without provider-side activity.
|
|
reuseLease: false,
|
|
// Keep the probe sandbox inspectable in the provider dashboard
|
|
// (archived, provider-side expiry) instead of deleting it the moment
|
|
// the probe finishes.
|
|
archiveOnRelease: true,
|
|
},
|
|
};
|
|
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,
|
|
environment: probeEnvironmentConfig,
|
|
issueId: null,
|
|
agentId: null,
|
|
heartbeatRunId: null,
|
|
persistedExecutionWorkspace: null,
|
|
adapterType: null,
|
|
resolveSnapshotIdentity: true,
|
|
assertCompanyBinding: options.assertCompanyBinding,
|
|
applyCustomImageTemplate: options.applyCustomImageTemplate === true,
|
|
});
|
|
const metadata = leaseRecord.lease.metadata ?? {};
|
|
const provider = leaseRecord.lease.provider ?? parsed.config.provider;
|
|
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: "Platform qualification requires an immutable OCI image or a Daytona snapshot with a verified provider ID. Update the provider plugin and probe again, or select Legacy runner." };
|
|
if ((isImmutableImage(metadata.image) && !metadata.snapshot) || metadata.snapshotIdentity) {
|
|
try {
|
|
const binding = await sandboxBinding(db, environment, resolvedCompanyId);
|
|
const snapshotIdentity = binding && "snapshot" in binding ? readSnapshotIdentity(metadata.snapshotIdentity, binding.snapshot) : null;
|
|
if (binding && (("image" in binding && metadata.image === binding.image && !metadata.snapshot)
|
|
|| (snapshotIdentity && metadata.snapshot === binding.snapshot && provider === "daytona"))) {
|
|
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 ?? ""))) {
|
|
const { qualifiedSnapshotId: _qualifiedSnapshotId, ...recordedBinding } = { qualifiedSnapshotId: null, ...binding };
|
|
qualification = { v: snapshotIdentity ? 2 : 1, companyId: resolvedCompanyId, ...recordedBinding,
|
|
...(snapshotIdentity ? { snapshotIdentity } : {}), facts, probedAt: new Date().toISOString() };
|
|
delete details.platformQualificationDiagnostic;
|
|
} else details.platformQualificationDiagnostic = "The sandbox OS, architecture or libc could not be identified. Legacy remains the creation default.";
|
|
} else details.platformQualificationDiagnostic = "The live lease does not match the selected image or snapshot identity. 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,
|
|
};
|
|
} catch (error) {
|
|
releaseStatus = "failed";
|
|
return {
|
|
ok: false,
|
|
driver: "sandbox",
|
|
summary: `Sandbox environment probe failed for provider "${parsed.config.provider}".`,
|
|
details: {
|
|
provider: parsed.config.provider,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
};
|
|
} finally {
|
|
if (leaseRecord) {
|
|
const driver = runtime.getDriver(environment.driver);
|
|
try {
|
|
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.
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
`[environment-probe] Failed to release lease ${leaseRecord.lease.id} for provider "${parsed.config.provider}": ${releaseError instanceof Error ? releaseError.message : String(releaseError)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!isBuiltinSandboxProvider(parsed.config.provider)) {
|
|
if (!options.pluginWorkerManager) {
|
|
return {
|
|
ok: false,
|
|
driver: "sandbox",
|
|
summary: `Sandbox provider "${parsed.config.provider}" requires a running provider plugin.`,
|
|
details: {
|
|
provider: parsed.config.provider,
|
|
},
|
|
};
|
|
}
|
|
return await probePluginSandboxProviderDriver({
|
|
db,
|
|
workerManager: options.pluginWorkerManager,
|
|
companyId: resolvedCompanyId ?? "instance",
|
|
environmentId: environment.id,
|
|
provider: parsed.config.provider,
|
|
config: parsed.config as unknown as Record<string, unknown>,
|
|
});
|
|
}
|
|
return await probeSandboxProvider(parsed.config);
|
|
}
|
|
|
|
if (parsed.driver === "plugin") {
|
|
if (!options.pluginWorkerManager) {
|
|
return {
|
|
ok: false,
|
|
driver: "plugin",
|
|
summary: `Plugin environment probes require a plugin worker manager for "${parsed.config.pluginKey}:${parsed.config.driverKey}".`,
|
|
details: {
|
|
pluginKey: parsed.config.pluginKey,
|
|
driverKey: parsed.config.driverKey,
|
|
},
|
|
};
|
|
}
|
|
return await probePluginEnvironmentDriver({
|
|
db,
|
|
workerManager: options.pluginWorkerManager,
|
|
companyId: resolvedCompanyId ?? "instance",
|
|
environmentId: environment.id,
|
|
config: parsed.config,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const { remoteCwd } = await ensureSshWorkspaceReady(parsed.config);
|
|
|
|
return {
|
|
ok: true,
|
|
driver: "ssh",
|
|
summary: `Connected to ${parsed.config.username}@${parsed.config.host} and verified the remote workspace path.`,
|
|
details: {
|
|
host: parsed.config.host,
|
|
port: parsed.config.port,
|
|
username: parsed.config.username,
|
|
remoteWorkspacePath: parsed.config.remoteWorkspacePath,
|
|
remoteCwd,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
const stderr =
|
|
error && typeof error === "object" && "stderr" in error && typeof error.stderr === "string"
|
|
? error.stderr.trim()
|
|
: "";
|
|
const stdout =
|
|
error && typeof error === "object" && "stdout" in error && typeof error.stdout === "string"
|
|
? error.stdout.trim()
|
|
: "";
|
|
const code =
|
|
error && typeof error === "object" && "code" in error
|
|
? (error as { code?: unknown }).code
|
|
: null;
|
|
const message =
|
|
stderr ||
|
|
stdout ||
|
|
(error instanceof Error ? error.message : String(error)) ||
|
|
"SSH probe failed.";
|
|
|
|
return {
|
|
ok: false,
|
|
driver: "ssh",
|
|
summary: `SSH probe failed for ${parsed.config.username}@${parsed.config.host}.`,
|
|
details: {
|
|
host: parsed.config.host,
|
|
port: parsed.config.port,
|
|
username: parsed.config.username,
|
|
remoteWorkspacePath: parsed.config.remoteWorkspacePath,
|
|
error: message,
|
|
code,
|
|
},
|
|
};
|
|
}
|
|
}
|