mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 16:35:48 +01:00
merge: integrate agent lifecycle ownership from master
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
ad5d8826ad
159 files changed
+7173
-2805
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import {
|
||||
createHash,
|
||||
generateKeyPairSync,
|
||||
@@ -2640,6 +2641,7 @@ export function accessRoutes(
|
||||
const access = accessService(db);
|
||||
const boardAuth = boardAuthService(db);
|
||||
const agents = agentService(db);
|
||||
const agentsLifecycle = createAgentLifecycle(db);
|
||||
const routeInviteResolutionNetwork = opts.inviteResolutionNetwork
|
||||
? { ...defaultInviteResolutionNetwork, ...opts.inviteResolutionNetwork }
|
||||
: inviteResolutionNetwork;
|
||||
@@ -4268,7 +4270,7 @@ export function accessRoutes(
|
||||
}))
|
||||
);
|
||||
|
||||
const created = await agents.create(companyId, {
|
||||
const created = await agentsLifecycle.requestHire(companyId, {
|
||||
name: agentName,
|
||||
role: "general",
|
||||
title: null,
|
||||
|
||||
+60
-563
@@ -1,7 +1,9 @@
|
||||
import { cancellationRequestId } from "../services/native-runtime/native-cancellation-request.js";
|
||||
import { aiRoutingHarness } from "@paperclipai/shared";
|
||||
import { agentHarnessType, type AgentRunnerChoice } from "@paperclipai/shared";
|
||||
import { resolveNewAgentRunnerForCompany } from "../services/agent-runner-selection.js";
|
||||
import { agentEnvironmentTestService } from "../services/agent-environment-test.js";
|
||||
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
|
||||
import { agentIdentityService } from "../services/agent-identity.js";
|
||||
import { aiConnectionRouterService, poolMemberRuntimeConfig } from "../services/ai-connection-router.js";
|
||||
import { connectionIntentService } from "../services/connection-intents.js";
|
||||
@@ -13,12 +15,17 @@ import { agentFileStore, agentFileTokenFromHash } from "../services/agent-file-s
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared";
|
||||
import { listOpenRouterModels } from "../services/openrouter-models.js";
|
||||
import { prepareManagedAiRuntime, withManagedAiProbe, assertManagedAiProjectAuth, stripAiAuthBindings } from "../services/ai-connection-runtime.js";
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE, AI_CONNECTION_CAPABILITIES, aiConnectionBindingSchema, type AiConnectionBinding, aiRuntimeConnectionBindingSchema, type AiRuntimeConnectionBinding } from "@paperclipai/shared";
|
||||
import { withManagedAiProbe, stripAiAuthBindings } from "../services/ai-connection-runtime.js";
|
||||
import {
|
||||
ADAPTER_AUTH_MISSING_CHECK_CODE,
|
||||
aiConnectionBindingSchema,
|
||||
aiRuntimeConnectionBindingSchema,
|
||||
type AiRuntimeConnectionBinding,
|
||||
} from "@paperclipai/shared";
|
||||
import { toolConnections } from "@paperclipai/db";
|
||||
import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import { defaultAiConnectionForHire } from "../services/agent-ai-connection-default.js";
|
||||
import { assertAiConnectionCreateAccess, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest, validateAiApiKey } from "./ai-connections.js";
|
||||
import { assertAiConnectionCreateAccess, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest } from "./ai-connections.js";
|
||||
import { isAiConnectionCompatible } from "@paperclipai/shared";
|
||||
import { applyConnectorSkills, resolveConnectorAssignments, annotateConnectorSkills, isConnectorSkill } from "../services/connector-runtime.js";
|
||||
import { getExecutionBlocker } from "../services/execution-blocker.js";
|
||||
@@ -116,7 +123,7 @@ import {
|
||||
} from "./workspace-command-authz.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
import { environmentService } from "../services/environments.js";
|
||||
import { resolveEnvironmentExecutionTarget } from "../services/environment-execution-target.js";
|
||||
|
||||
import { environmentRuntimeService } from "../services/environment-runtime.js";
|
||||
import { resolvePluginSandboxProviderDriverByKey } from "../services/plugin-environment-driver.js";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
@@ -563,10 +570,12 @@ export function agentRoutes(
|
||||
|
||||
const router = Router();
|
||||
const svc = agentService(db, { cancelWorkForScope: scope => heartbeat.cancelBudgetScopeWork(scope) });
|
||||
const svcLifecycle = createAgentLifecycle(db, { cancelWorkForScope: scope => heartbeat.cancelBudgetScopeWork(scope) });
|
||||
const access = accessService(db);
|
||||
const approvalsSvc = approvalService(db);
|
||||
const budgets = budgetService(db);
|
||||
const environmentsSvc = environmentService(db);
|
||||
const { resolveAdapterTestEnvironmentId, resolveAdapterTestExecutionContext, assertAdapterTestEnvironmentForCompany, testManagedEnvironment } = agentEnvironmentTestService(db, options.pluginWorkerManager);
|
||||
const environmentRuntime = environmentRuntimeService(db, {
|
||||
pluginWorkerManager: options.pluginWorkerManager,
|
||||
});
|
||||
@@ -1223,415 +1232,6 @@ export function agentRoutes(
|
||||
return rows.filter((_, index) => decisions[index]?.allowed);
|
||||
}
|
||||
|
||||
// A null agent override inherits the instance default, just like dispatch.
|
||||
// Resolve this before secrets or probes so a default remote environment can
|
||||
// never accidentally validate the account on the control-plane host.
|
||||
async function resolveAdapterTestEnvironmentId(companyId: string, environmentId: string | null | undefined) {
|
||||
if (environmentId) return environmentId;
|
||||
const settings = await instanceSettings.get();
|
||||
if (settings.defaultEnvironmentId) return settings.defaultEnvironmentId;
|
||||
if ((await instanceSettings.getExperimental()).enableManagedSandboxOnly === true) {
|
||||
const managed = await environmentsSvc.findManagedSandboxEnvironment(companyId);
|
||||
if (!managed) {
|
||||
throw unprocessable("The managed sandbox is unavailable. Restore Paperclip Computer and retry.", {
|
||||
code: "managed_sandbox_unavailable",
|
||||
});
|
||||
}
|
||||
return managed.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the execution target the adapter should run its test probes against.
|
||||
*
|
||||
* - No environmentId / local environment → returns a local target so the
|
||||
* adapter probes the Paperclip host (legacy behavior).
|
||||
* - SSH environment → builds an SSH execution target from the environment
|
||||
* config so the adapter probes the remote box. No lease is required:
|
||||
* the SSH spec is fully derived from the saved environment config.
|
||||
* - Sandbox / plugin environments → acquires an ad-hoc lease, realizes the
|
||||
* workspace, and resolves a sandbox execution target wired to the runtime
|
||||
* so the adapter probe runs inside the sandbox the same way a heartbeat
|
||||
* would. The returned `release` callback rolls the lease back when the
|
||||
* route is done.
|
||||
*
|
||||
* The caller MUST always invoke `release()` (typically in a `finally` block).
|
||||
*/
|
||||
async function resolveAdapterTestExecutionContext(input: {
|
||||
companyId: string;
|
||||
adapterType: string;
|
||||
environmentId: string | null;
|
||||
}): Promise<{
|
||||
executionTarget: AdapterExecutionTarget | null;
|
||||
environmentName: string | null;
|
||||
fallbackChecks: AdapterEnvironmentCheck[];
|
||||
sandboxIdentityCheck?: AdapterEnvironmentCheck | null;
|
||||
release: (status?: "released" | "failed") => Promise<void>;
|
||||
}> {
|
||||
const noopRelease = async () => {};
|
||||
|
||||
if (!input.environmentId) {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: null,
|
||||
fallbackChecks: [],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
const requestedEnvironment = await environmentsSvc.getById(input.environmentId);
|
||||
if (!requestedEnvironment) {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: null,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_not_found",
|
||||
level: "warn",
|
||||
message: "Selected environment was not found. The test did not run.",
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
// Managed-sandbox-only policy: redirect a Test that would run on the local
|
||||
// host onto the platform-managed sandbox, the same as a real run does
|
||||
// (resolveExecutionWorkspaceEnvironmentId in heartbeat). Without this
|
||||
// redirect the Test probes the local host while the run executes in the
|
||||
// managed sandbox, so a passing Test validates the wrong execution target.
|
||||
// With no active managed sandbox the Test fails closed — never local.
|
||||
let environment = requestedEnvironment;
|
||||
if (requestedEnvironment.driver === "local") {
|
||||
const managedSandboxOnly =
|
||||
(await instanceSettings.getExperimental()).enableManagedSandboxOnly === true;
|
||||
if (managedSandboxOnly) {
|
||||
const managedSandboxEnvironment = await environmentsSvc.findManagedSandboxEnvironment(
|
||||
input.companyId,
|
||||
);
|
||||
if (!managedSandboxEnvironment) {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: requestedEnvironment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "managed_sandbox_unavailable",
|
||||
level: "error",
|
||||
message:
|
||||
"This instance runs agents only in its platform-managed sandbox, but no active managed sandbox environment exists. The test did not run.",
|
||||
hint: "Restore the managed sandbox environment, then test again.",
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
environment = managedSandboxEnvironment;
|
||||
}
|
||||
}
|
||||
|
||||
if (environment.driver === "local") {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
if (environment.driver === "ssh") {
|
||||
try {
|
||||
const target = await resolveEnvironmentExecutionTarget({
|
||||
db,
|
||||
companyId: input.companyId,
|
||||
adapterType: input.adapterType,
|
||||
environment: {
|
||||
id: environment.id,
|
||||
driver: environment.driver,
|
||||
config: environment.config ?? null,
|
||||
},
|
||||
leaseMetadata: null,
|
||||
});
|
||||
if (target) {
|
||||
return {
|
||||
executionTarget: target,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_target_unavailable",
|
||||
level: "warn",
|
||||
message:
|
||||
`Could not resolve an execution target for environment "${environment.name}". The test did not run.`,
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_target_failed",
|
||||
level: "warn",
|
||||
message:
|
||||
`Could not connect to environment "${environment.name}" to run the test.`,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// sandbox / plugin / other remote drivers: spin up an ad-hoc lease, realize
|
||||
// the workspace inside the box, and run the same probe SSH uses against
|
||||
// a sandbox execution target wired to the environment runtime.
|
||||
//
|
||||
// We pass `heartbeatRunId: null` because there's no heartbeat run for an
|
||||
// operator-initiated `Test` invocation — the leases table FKs heartbeat
|
||||
// run id to heartbeat_runs.id, and we don't want to manufacture a fake
|
||||
// run row. Cleanup goes through the driver's `releaseRunLease` directly
|
||||
// (by lease record), since the batch helper queries by heartbeatRunId.
|
||||
//
|
||||
// Sandbox tests boot a fresh throwaway sandbox (never resume a retained
|
||||
// agent lease) and archive it on release instead of deleting it, so the
|
||||
// operator can inspect the exact sandbox from the provider dashboard while
|
||||
// provider-side expiry reaps it later.
|
||||
const testEnvironment = environment.driver === "sandbox"
|
||||
? {
|
||||
...environment,
|
||||
config: {
|
||||
...(environment.config ?? {}),
|
||||
reuseLease: false,
|
||||
archiveOnRelease: true,
|
||||
},
|
||||
}
|
||||
: environment;
|
||||
let leaseRecord: Awaited<ReturnType<typeof environmentRuntime.acquireRunLease>>;
|
||||
try {
|
||||
leaseRecord = await environmentRuntime.acquireRunLease({
|
||||
companyId: input.companyId,
|
||||
environment: testEnvironment,
|
||||
issueId: null,
|
||||
heartbeatRunId: null,
|
||||
persistedExecutionWorkspace: null,
|
||||
// Re-check the company binding atomically at lease time. The route
|
||||
// guard already rejected a foreign environment, but the binding could
|
||||
// change between the guard check and the lease acquire. This closes
|
||||
// that check-to-lease race so a foreign sandbox never gets a lease.
|
||||
assertCompanyBinding: true,
|
||||
// Apply the active custom-image template so the Test boots with the
|
||||
// operator's captured sandbox customizations and prepared image state,
|
||||
// matching what real agent runs use. Without this the test would
|
||||
// silently fall back to the base image.
|
||||
applyCustomImageTemplate: true,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_lease_acquire_failed",
|
||||
level: "error",
|
||||
message: `Could not acquire a lease for environment "${environment.name}".`,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
hint: "Check the environment's provider credentials and quota.",
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
const driver = environmentRuntime.getDriver(environment.driver);
|
||||
const releaseLease = async (status: "released" | "failed" = "released") => {
|
||||
try {
|
||||
if (driver) {
|
||||
await driver.releaseRunLease({
|
||||
environment: testEnvironment,
|
||||
lease: leaseRecord.lease,
|
||||
status,
|
||||
});
|
||||
} else {
|
||||
await environmentsSvc.releaseLease(leaseRecord.lease.id, status);
|
||||
}
|
||||
} catch (err) {
|
||||
// Cleanup failures must not mask the test result.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[adapter-test] Failed to release lease ${leaseRecord.lease.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let realizedCwd: string | null = null;
|
||||
try {
|
||||
const realized = await environmentRuntime.realizeWorkspace({
|
||||
environment: testEnvironment,
|
||||
lease: leaseRecord.lease,
|
||||
// No host workspace to copy for a Test invocation; sandbox/plugin
|
||||
// realize implementations use the lease metadata's remoteCwd to
|
||||
// create the working directory inside the box.
|
||||
workspace: {},
|
||||
});
|
||||
realizedCwd =
|
||||
typeof realized.cwd === "string" && realized.cwd.trim().length > 0
|
||||
? realized.cwd.trim()
|
||||
: null;
|
||||
} catch (err) {
|
||||
await releaseLease("failed");
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_workspace_realize_failed",
|
||||
level: "error",
|
||||
message: `Could not realize a workspace inside "${environment.name}".`,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
let target: AdapterExecutionTarget | null;
|
||||
try {
|
||||
// Prefer the cwd the realize step returned; fall back to lease metadata.
|
||||
const leaseMetadataForTarget: Record<string, unknown> | null =
|
||||
realizedCwd
|
||||
? { ...(leaseRecord.lease.metadata ?? {}), remoteCwd: realizedCwd }
|
||||
: (leaseRecord.lease.metadata as Record<string, unknown> | null) ?? null;
|
||||
|
||||
target = await resolveEnvironmentExecutionTarget({
|
||||
db,
|
||||
companyId: input.companyId,
|
||||
adapterType: input.adapterType,
|
||||
environment: {
|
||||
id: testEnvironment.id,
|
||||
driver: testEnvironment.driver,
|
||||
config: testEnvironment.config ?? null,
|
||||
},
|
||||
leaseId: leaseRecord.lease.id,
|
||||
leaseMetadata: leaseMetadataForTarget,
|
||||
lease: leaseRecord.lease,
|
||||
environmentRuntime,
|
||||
});
|
||||
} catch (err) {
|
||||
await releaseLease("failed");
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_target_failed",
|
||||
level: "error",
|
||||
message: `Could not resolve an execution target for "${environment.name}".`,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
await releaseLease("failed");
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_target_unsupported",
|
||||
level: "warn",
|
||||
message:
|
||||
`Adapter "${input.adapterType}" is not allowed in "${environment.name}" environments.`,
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
executionTarget: target,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [],
|
||||
sandboxIdentityCheck: buildSandboxIdentityCheck({
|
||||
environmentName: environment.name,
|
||||
lease: leaseRecord.lease,
|
||||
}),
|
||||
release: releaseLease,
|
||||
};
|
||||
}
|
||||
|
||||
function readMetadataString(metadata: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = metadata[key];
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildSandboxIdentityCheck(input: {
|
||||
environmentName: string;
|
||||
lease: {
|
||||
id: string;
|
||||
provider?: string | null;
|
||||
providerLeaseId?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
}): AdapterEnvironmentCheck {
|
||||
const metadata = input.lease.metadata ?? {};
|
||||
const provider = input.lease.provider ?? readMetadataString(metadata, ["provider"]);
|
||||
const sandboxId = readMetadataString(metadata, ["sandboxId", "sandboxID", "sandbox_id", "id"]);
|
||||
const sandboxName = readMetadataString(metadata, ["sandboxName", "sandbox_name", "name"]);
|
||||
const snapshotRef = readMetadataString(metadata, [
|
||||
"snapshot",
|
||||
"snapshotId",
|
||||
"snapshotID",
|
||||
"snapshotRef",
|
||||
"snapshot_ref",
|
||||
"templateRef",
|
||||
"template_ref",
|
||||
"templateId",
|
||||
"templateID",
|
||||
"image",
|
||||
"imageId",
|
||||
"imageID",
|
||||
"imageRef",
|
||||
"image_ref",
|
||||
]);
|
||||
const templateKind = readMetadataString(metadata, [
|
||||
"templateKind",
|
||||
"template_kind",
|
||||
"templateRefKind",
|
||||
"template_ref_kind",
|
||||
]);
|
||||
const detailParts = [
|
||||
`paperclipLeaseId=${input.lease.id}`,
|
||||
input.lease.providerLeaseId ? `providerLeaseId=${input.lease.providerLeaseId}` : null,
|
||||
provider ? `provider=${provider}` : null,
|
||||
sandboxId ? `sandboxId=${sandboxId}` : null,
|
||||
sandboxName ? `sandboxName=${sandboxName}` : null,
|
||||
snapshotRef ? `${templateKind ? `${templateKind}Ref` : "snapshotOrTemplateRef"}=${snapshotRef}` : null,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
|
||||
return {
|
||||
code: "sandbox_test_identity",
|
||||
level: "info",
|
||||
message: `Environment test identity for "${input.environmentName}".`,
|
||||
detail: detailParts.join("; "),
|
||||
hint: "Use these provider-neutral IDs when comparing model-test output with provider logs or refreshed environment snapshots.",
|
||||
};
|
||||
}
|
||||
|
||||
async function getCurrentUserRedactionOptions() {
|
||||
return {
|
||||
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
|
||||
@@ -3419,105 +3019,6 @@ export function agentRoutes(
|
||||
res.json(detected);
|
||||
});
|
||||
|
||||
// The environment drivers the adapter Test route accepts. A local, SSH, or
|
||||
// sandbox environment can host a probe; a plugin environment cannot.
|
||||
const ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS = ["local", "ssh", "sandbox"];
|
||||
|
||||
// The fail-closed tenant-binding guard for the adapter Test route. A caller
|
||||
// may name any instance environment by id, so the route must reject an
|
||||
// environment that binds to another company before it resolves secrets,
|
||||
// merges env, resolves the target, leases a sandbox, or runs the adapter
|
||||
// test. The guard checks the company binding BEFORE it validates the status
|
||||
// or the driver, so it never reveals the status or the driver of a foreign
|
||||
// environment. A same-company or an instance-global environment then gets the
|
||||
// shared driver and status validation.
|
||||
async function assertAdapterTestEnvironmentForCompany(
|
||||
companyId: string,
|
||||
environmentId: string,
|
||||
): Promise<void> {
|
||||
const environment = await environmentsSvc.getById(environmentId);
|
||||
if (!environment) {
|
||||
// A missing environment leaks no tenant state. The execution-context
|
||||
// resolver surfaces the existing environment_not_found check.
|
||||
return;
|
||||
}
|
||||
const boundCompanyIds = await environmentsSvc.listBoundCompanyIds(environmentId);
|
||||
if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) {
|
||||
throw forbidden("The selected environment belongs to another company.", {
|
||||
code: "environment_company_mismatch",
|
||||
});
|
||||
}
|
||||
await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, {
|
||||
allowedDrivers: ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS,
|
||||
});
|
||||
}
|
||||
|
||||
async function testManagedEnvironment(adapterType: string, context: Parameters<ReturnType<typeof requireServerAdapter>["testEnvironment"]>[0], binding: AiConnectionBinding, managed: Awaited<ReturnType<typeof prepareManagedAiRuntime>>, agentId?: string) {
|
||||
const startedAt = new Date();
|
||||
const managedAiCredentialHome = adapterType === "paperclip_runner" && context.config.provider === "codex" && managed.home
|
||||
? path.join(managed.home, "provider") : undefined;
|
||||
const result = await probeManagedEnvironment(adapterType, { ...context, ...(managedAiCredentialHome ? { managedAiCredentialHome } : {}) }, binding);
|
||||
// A provider rejection invalidates the tested credential generation. A
|
||||
// missing CLI, unavailable environment, or other runtime error does not.
|
||||
if (result.status === "fail" && result.checks.some(check =>
|
||||
check.code === ADAPTER_AUTH_MISSING_CHECK_CODE || /_hello_probe_auth_required$/.test(check.code)
|
||||
|| check.code === "ai_connection_api_key_rejected",
|
||||
)) {
|
||||
await aiConnectionService(db).markAuthenticationFailed({
|
||||
companyId: context.companyId, agentId, runStartedAt: startedAt,
|
||||
attribution: { ...managed.attribution, identity: managed.identity },
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function probeManagedEnvironment(adapterType: string, context: Parameters<ReturnType<typeof requireServerAdapter>["testEnvironment"]>[0], binding: AiConnectionBinding) {
|
||||
await assertManagedAiProjectAuth(context.config, binding.provider, context.executionTarget);
|
||||
const result = await requireServerAdapter(adapterType).testEnvironment(context);
|
||||
if (result.status === "fail") return result;
|
||||
// The resolved method, not binding.method — on a responsible_user binding
|
||||
// that field is wire-compat only and the default connection decides.
|
||||
const resolvedMethod = (context.config as { managedAiConnection?: { method?: string } }).managedAiConnection?.method;
|
||||
// An api_key account does not take the CLI hello probe below: a key
|
||||
// travels as an env var any engine understands, and the engine's own test
|
||||
// above judged whether this runtime can execute with it — the ACP lane
|
||||
// deliberately runs no hello probe when a key is configured. Demanding one
|
||||
// anyway forced the CLI lane, whose probe needs a provider CLI on PATH,
|
||||
// and a clean install has none: that walled off onboarding's API-key path
|
||||
// on exactly the machines the release smoke exists to guard. The account
|
||||
// is still verified live here — the same provider-endpoint check the save
|
||||
// performed — so a key revoked since its save fails adoption rather than
|
||||
// producing an agent that cannot authenticate at runtime. The hello-probe
|
||||
// requirement stays for subscriptions: a stored login is a file layout
|
||||
// only a provider CLI reads, so proving the runtime lane can consume it
|
||||
// takes a real hello turn.
|
||||
if (resolvedMethod === "api_key" && !context.config.managedAiRouting) {
|
||||
const envKey = AI_CONNECTION_CAPABILITIES[binding.provider].methods.api_key?.envKey;
|
||||
const key = envKey ? parseObject(context.config.env)[envKey] : undefined;
|
||||
try {
|
||||
if (typeof key !== "string" || !key) throw unprocessable("The selected account's API key was not available to verify.");
|
||||
await validateAiApiKey(binding.provider, key);
|
||||
result.checks.push({ code: "ai_connection_api_key_reverified", level: "info", message: "The provider verified this API key for adoption." });
|
||||
} catch (error) {
|
||||
result.status = "fail";
|
||||
const rejected = error instanceof HttpError && asRecord(error.details)?.code === "ai_connection_api_key_rejected";
|
||||
result.checks.push({ code: rejected ? "ai_connection_api_key_rejected" : "ai_connection_verification_failed", level: "error", message: error instanceof HttpError ? error.message : "Could not verify the account. Try again." });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (!result.checks.some(check => check.code.includes("hello_probe"))) {
|
||||
const providerAdapter = context.config.managedAiRouting ? aiRoutingHarness(adapterType, context.config.provider, context.config.acpxAgent) : { anthropic: "claude_local", openai: "codex_local", openrouter: "opencode_local", xai: "grok_local", google: "gemini_local" }[binding.provider];
|
||||
const probe = await requireServerAdapter(providerAdapter).testEnvironment({ ...context, adapterType: providerAdapter, config: { ...context.config, engine: "cli" } });
|
||||
result.checks.push(...probe.checks);
|
||||
result.status = probe.status === "fail" ? "fail" : result.status === "warn" || probe.status === "warn" ? "warn" : "pass";
|
||||
}
|
||||
if (!result.checks.some(check => /hello_probe_(passed|succeeded)$/.test(check.code))) {
|
||||
result.status = "fail";
|
||||
result.checks.push({ code: "ai_connection_validation_incomplete", level: "error", message: "The selected account has not completed a provider hello test. Retry before adopting it." });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function validateManagedAgentBinding(req: Request, companyId: string, agentId: string, adapterType: string, config: Record<string, unknown>, binding: AiRuntimeConnectionBinding, environmentId: string | null | undefined, test: boolean, newAgent = false) {
|
||||
if (binding.mode === "router") {
|
||||
const settings = await import("../services/instance-settings.js");
|
||||
@@ -3565,7 +3066,9 @@ export function agentRoutes(
|
||||
try {
|
||||
if (!target.executionTarget && target.fallbackChecks.length > 0) throw unprocessable("The agent environment is not available for adoption");
|
||||
await withManagedAiProbe(db, { companyId, agentId, responsibleUserId: userId, adapterType, binding, config, allowUninstalledPersonal: newAgent, allowUninstalledShared, allowLegacyValidation: true }, async managed => {
|
||||
const result = await testManagedEnvironment(adapterType, { companyId, adapterType, config: managed.config, executionTarget: target.executionTarget, environmentName: target.environmentName }, binding, managed, agentId);
|
||||
const managedAiCredentialHome = adapterType === "paperclip_runner" && asRecord(managed.config)?.provider === "codex" && managed.home
|
||||
? path.join(managed.home, "provider") : undefined;
|
||||
const result = await testManagedEnvironment(adapterType, { companyId, adapterType, config: managed.config, managedAiCredentialHome, executionTarget: target.executionTarget, environmentName: target.environmentName }, binding, managed, agentId);
|
||||
if (result.status === "fail" || result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)) throw unprocessable("The selected AI connection failed validation in this agent’s environment. Run the agent test to see the failing checks.", {
|
||||
code: "ai_connection_validation_failed",
|
||||
checks: result.checks.filter(check => check.level === "error" || check.code === ADAPTER_AUTH_MISSING_CHECK_CODE).map(check => ({ code: check.code, level: check.level })),
|
||||
@@ -3788,7 +3291,9 @@ export function agentRoutes(
|
||||
}
|
||||
}
|
||||
const result = aiBinding ? await withManagedAiProbe(db, { companyId, agentId: req.body.agentId ?? "", responsibleUserId: responsibleUserForAiRequest(req), adapterType: type, binding: aiBinding, config: effectiveAdapterConfig, allowUninstalledPersonal: !req.body.agentId, allowUninstalledShared: !req.body.agentId && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, aiBinding) }, async managed => {
|
||||
const tested = await testManagedEnvironment(type, { companyId, adapterType: type, config: managed.config, executionTarget, environmentName }, aiBinding, managed, savedAgentId ?? undefined);
|
||||
const managedAiCredentialHome = type === "paperclip_runner" && asRecord(managed.config)?.provider === "codex" && managed.home
|
||||
? path.join(managed.home, "provider") : undefined;
|
||||
const tested = await testManagedEnvironment(type, { companyId, adapterType: type, config: managed.config, managedAiCredentialHome, executionTarget, environmentName }, aiBinding, managed, savedAgentId ?? undefined);
|
||||
tested.checks.unshift({ code: "ai_connection_tested", level: "info", message: `Tested ${managed.accountName} — ${managed.accountOwnerUserId ? managed.accountOwnerUserId === responsibleUserForAiRequest(req) ? "your personal account" : "the owner’s account authorized for this agent" : "company-shared account"}. Responsible user: ${req.actor.type === "agent" ? responsibleUserForAiRequest(req) ?? "unavailable" : "the signed-in user"}.` });
|
||||
return tested;
|
||||
}) : await adapter.testEnvironment({ companyId, adapterType: type, config: effectiveAdapterConfig, executionTarget, environmentName });
|
||||
@@ -4858,7 +4363,7 @@ export function agentRoutes(
|
||||
const status = requiresApproval ? "pending_approval" : "idle";
|
||||
const managedHireBinding = normalizedHireInput.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(normalizedHireInput.runtimeConfig.aiConnection) : undefined;
|
||||
const managedHireConnection = managedHireBinding ? await validateManagedAgentBinding(req, companyId, hiredAgentId, normalizedHireInput.adapterType, normalizedHireInput.adapterConfig, managedHireBinding, normalizedHireInput.defaultEnvironmentId, false, true) : undefined;
|
||||
const createdAgent = await svc.create(
|
||||
const createdAgent = await svcLifecycle.requestHire(
|
||||
companyId,
|
||||
{
|
||||
id: hiredAgentId,
|
||||
@@ -4870,6 +4375,7 @@ export function agentRoutes(
|
||||
{
|
||||
createdByUserId: req.actor.type === "board" ? req.actor.userId : null,
|
||||
runnerResolved: true,
|
||||
responsibleUserId: responsibleUserForAiRequest(req),
|
||||
aiConnectionInstall: managedHireConnection ? { ...managedHireConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined,
|
||||
claudeLogin: {
|
||||
storedSessionId: hireStoredSessionId ?? null,
|
||||
@@ -5122,7 +4628,7 @@ export function agentRoutes(
|
||||
|
||||
const managedBinding = normalizedRuntimeConfig.aiConnection ? aiRuntimeConnectionBindingSchema.parse(normalizedRuntimeConfig.aiConnection) : undefined;
|
||||
const managedConnection = managedBinding ? await validateManagedAgentBinding(req, companyId, agentId, createInput.adapterType, normalizedAdapterConfig, managedBinding, createInput.defaultEnvironmentId, false, true) : undefined;
|
||||
const createdAgent = await svc.create(
|
||||
const createdAgent = await svcLifecycle.requestHire(
|
||||
companyId,
|
||||
{
|
||||
id: agentId,
|
||||
@@ -5136,6 +4642,7 @@ export function agentRoutes(
|
||||
{
|
||||
createdByUserId: req.actor.type === "board" ? req.actor.userId : null,
|
||||
runnerResolved: true,
|
||||
responsibleUserId: responsibleUserForAiRequest(req),
|
||||
aiConnectionInstall: managedConnection ? { ...managedConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined,
|
||||
claudeLogin: {
|
||||
storedSessionId: createStoredSessionId ?? null,
|
||||
@@ -5680,6 +5187,9 @@ export function agentRoutes(
|
||||
const patchData = { ...(req.body as Record<string, unknown>) };
|
||||
const runner = patchData.runner as AgentRunnerChoice | undefined;
|
||||
delete patchData.runner;
|
||||
const requestedStatus = patchData.status;
|
||||
delete patchData.status;
|
||||
if (requestedStatus !== undefined && !["paused", "idle", "terminated"].includes(String(requestedStatus))) throw badRequest("Use an agent lifecycle command to change status");
|
||||
const replaceAdapterConfig = patchData.replaceAdapterConfig === true;
|
||||
delete patchData.replaceAdapterConfig;
|
||||
// The apply-existing flag is not an agent column. The server binds the fixed
|
||||
@@ -5871,7 +5381,7 @@ export function agentRoutes(
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const agent = await svc.update(id, patchData, {
|
||||
const updateOptions = {
|
||||
recordRevision: {
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
@@ -5884,7 +5394,11 @@ export function agentRoutes(
|
||||
applyExistingWithoutClaim:
|
||||
req.actor.type !== "agent" && applyStoredClaudeLogin,
|
||||
},
|
||||
});
|
||||
};
|
||||
const command = requestedStatus === "paused" ? "pause" : requestedStatus === "idle" ? "resume" : requestedStatus === "terminated" ? "terminate" : null;
|
||||
const agent = command
|
||||
? await svcLifecycle.updateAndTransition(id, command, patchData, updateOptions)
|
||||
: await svc.update(id, patchData, updateOptions);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
@@ -5906,13 +5420,28 @@ export function agentRoutes(
|
||||
res.json(redactAgentRowForResponse(agent));
|
||||
});
|
||||
|
||||
router.post("/agents/:id/lifecycle/retry", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleAgent(req, res, id);
|
||||
if (!existing) return;
|
||||
await assertCanUpdateAgent(req, existing);
|
||||
const agent = await svcLifecycle.retry(id);
|
||||
if (!agent) { res.status(404).json({ error: "Agent not found" }); return; }
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, { companyId: agent.companyId, actorType: actor.actorType, actorId: actor.actorId,
|
||||
agentId: actor.agentId, runId: actor.runId, agentApiKeyId: actor.agentApiKeyId,
|
||||
action: "agent.lifecycle_retried", entityType: "agent", entityId: agent.id });
|
||||
res.json(redactAgentRowForResponse(agent));
|
||||
});
|
||||
|
||||
router.post("/agents/:id/pause", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
if (!(await getAccessibleAgent(req, res, id))) {
|
||||
return;
|
||||
}
|
||||
const agent = await svc.pause(id);
|
||||
const agent = await svcLifecycle.pauseAgent(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
@@ -5945,7 +5474,7 @@ export function agentRoutes(
|
||||
});
|
||||
return;
|
||||
}
|
||||
const agent = await svc.resume(id);
|
||||
const agent = await svcLifecycle.resumeAgent(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
@@ -6011,34 +5540,16 @@ export function agentRoutes(
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the linked hire approval (clears it from the inbox) and run the
|
||||
// shared approval side effects: agent activation, budget policy, and the
|
||||
// hire-approved notification. Fall back to direct activation if no open
|
||||
// approval record exists (e.g. agents created before approvals were tracked).
|
||||
const decidedByUserId = req.actor.userId ?? "board";
|
||||
const openApproval = await approvalsSvc.findOpenHireApprovalForAgent(existing.companyId, id);
|
||||
|
||||
let agent: Awaited<ReturnType<typeof svc.getById>> | null = null;
|
||||
if (openApproval) {
|
||||
await approvalsSvc.approve(openApproval.id, decidedByUserId);
|
||||
agent = await svc.getById(id);
|
||||
} else {
|
||||
const approval = await svc.activatePendingApproval(id);
|
||||
if (!approval) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
if (!approval.activated) {
|
||||
res.status(409).json({ error: "Only pending approval agents can be approved" });
|
||||
return;
|
||||
}
|
||||
agent = approval.agent;
|
||||
}
|
||||
|
||||
const decision = await approvalsSvc.approveHire(id, req.actor.userId ?? "board");
|
||||
const agent = decision?.agent;
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
if (!decision.applied) {
|
||||
res.status(409).json({ error: "Only pending approval agents can be approved" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
@@ -6047,7 +5558,7 @@ export function agentRoutes(
|
||||
action: "agent.approved",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
details: { source: "agent_detail", approvalId: openApproval?.id ?? null },
|
||||
details: { source: "agent_detail", approvalId: decision.approval?.id ?? null },
|
||||
});
|
||||
|
||||
res.json(redactAgentRowForResponse(agent));
|
||||
@@ -6061,23 +5572,9 @@ export function agentRoutes(
|
||||
return;
|
||||
}
|
||||
|
||||
// Terminating an agent that is still awaiting approval is the agent-detail
|
||||
// equivalent of rejecting the hire. When a linked hire approval is still
|
||||
// open, delegate to approvalsSvc.reject(), which both resolves the approval
|
||||
// (clearing the inbox "Approve/Reject" card) and terminates the agent.
|
||||
// Mirror the approve path's branch-or-fallback so we never terminate twice:
|
||||
// reject() already calls agentsSvc.terminate() internally.
|
||||
let agent: Awaited<ReturnType<typeof svc.terminate>> = null;
|
||||
if (existing.status === "pending_approval") {
|
||||
const openApproval = await approvalsSvc.findOpenHireApprovalForAgent(existing.companyId, id);
|
||||
if (openApproval) {
|
||||
await approvalsSvc.reject(openApproval.id, req.actor.userId ?? "board");
|
||||
agent = await svc.getById(id);
|
||||
}
|
||||
}
|
||||
if (!agent) {
|
||||
agent = await svc.terminate(id);
|
||||
}
|
||||
const agent = existing.status === "pending_approval"
|
||||
? (await approvalsSvc.rejectHire(id, req.actor.userId ?? "board"))?.agent
|
||||
: await svcLifecycle.terminateAgent(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { validateAiApiKey } from "../services/ai-api-key-test.js";
|
||||
export { validateAiApiKey } from "../services/ai-api-key-test.js";
|
||||
import { supportsLocalAiLogin } from "../services/local-ai-login-policy.js";
|
||||
import { localAiLoginService } from "../services/local-ai-login.js";
|
||||
import { z } from "zod";
|
||||
@@ -133,41 +135,6 @@ export async function canInstallSharedAiConnectionForNewAgent(
|
||||
connection.creator === userId || await accessService(db).hasPermission(companyId, "user", userId, "tools:manage_connections");
|
||||
}
|
||||
|
||||
/** Fixed provider endpoints; credentials are never sent to a caller-supplied URL or through a redirect. */
|
||||
export async function validateAiApiKey(
|
||||
provider: AiProvider,
|
||||
key: string,
|
||||
request: typeof fetch = fetch,
|
||||
) {
|
||||
const endpoints = {
|
||||
anthropic: "https://api.anthropic.com/v1/models?limit=1",
|
||||
openai: "https://api.openai.com/v1/models",
|
||||
openrouter: "https://openrouter.ai/api/v1/key",
|
||||
xai: "https://api.x.ai/v1/models",
|
||||
google: "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1",
|
||||
};
|
||||
let response: Response;
|
||||
try {
|
||||
response = await request(endpoints[provider], {
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(15000),
|
||||
headers:
|
||||
provider === "google" ? { "x-goog-api-key": key } : provider === "anthropic"
|
||||
? { "x-api-key": key, "anthropic-version": "2023-06-01" }
|
||||
: { Authorization: `Bearer ${key}` },
|
||||
});
|
||||
} catch {
|
||||
throw unprocessable("Could not verify the account. Try again.", { code: "ai_connection_verification_failed" });
|
||||
}
|
||||
await response.body?.cancel();
|
||||
if (!response.ok)
|
||||
throw unprocessable(
|
||||
response.status === 401 || response.status === 403
|
||||
? "The provider rejected this API key."
|
||||
: "The provider could not verify this account. Try again.",
|
||||
{ code: response.status === 401 || response.status === 403 ? "ai_connection_api_key_rejected" : "ai_connection_verification_failed" },
|
||||
);
|
||||
}
|
||||
|
||||
export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLocalAiLogin>[0] = {}) {
|
||||
function assertLocalLoginAvailable() {
|
||||
|
||||
@@ -1,52 +1 @@
|
||||
import { unprocessable } from "../errors.js";
|
||||
|
||||
export async function assertEnvironmentSelectionForCompany(
|
||||
environmentsSvc: {
|
||||
getById(environmentId: string): Promise<{
|
||||
id: string;
|
||||
driver: string;
|
||||
status?: string | null;
|
||||
config: Record<string, unknown> | null;
|
||||
} | null>;
|
||||
},
|
||||
_companyId: string,
|
||||
environmentId: string | null | undefined,
|
||||
options?: {
|
||||
allowedDrivers?: string[];
|
||||
allowedSandboxProviders?: string[];
|
||||
},
|
||||
) {
|
||||
if (environmentId === undefined || environmentId === null) return;
|
||||
const environment = await environmentsSvc.getById(environmentId);
|
||||
if (!environment) {
|
||||
throw unprocessable("Environment not found.");
|
||||
}
|
||||
if (environment.status === "archived") {
|
||||
throw unprocessable("Environment is archived.");
|
||||
}
|
||||
if (options?.allowedDrivers && !options.allowedDrivers.includes(environment.driver)) {
|
||||
throw unprocessable(
|
||||
`Environment driver "${environment.driver}" is not allowed here. Allowed drivers: ${options.allowedDrivers.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (environment.driver === "sandbox") {
|
||||
const config = environment.config && typeof environment.config === "object"
|
||||
? environment.config as Record<string, unknown>
|
||||
: {};
|
||||
const provider = typeof config.provider === "string" ? config.provider : "";
|
||||
if (provider === "fake") {
|
||||
throw unprocessable(
|
||||
`Environment sandbox provider "${provider}" is not allowed here. The built-in fake provider is probe-only and cannot execute runs.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
options?.allowedSandboxProviders
|
||||
&& options.allowedSandboxProviders.length > 0
|
||||
&& !options.allowedSandboxProviders.includes(provider)
|
||||
) {
|
||||
throw unprocessable(
|
||||
`Environment sandbox provider "${provider || "unknown"}" is not allowed here. Allowed providers: ${options.allowedSandboxProviders.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
export { assertEnvironmentSelectionForCompany } from "../services/environment-selection.js";
|
||||
@@ -3578,7 +3578,7 @@ registry.registerPath({
|
||||
summary: "Update an agent",
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: jsonBody(updateAgentSchema.omit({ permissions: true })),
|
||||
body: jsonBody(updateAgentSchema.omit({ permissions: true, spentMonthlyCents: true })),
|
||||
},
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
@@ -3850,6 +3850,15 @@ registry.registerPath({
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/agents/{id}/lifecycle/retry",
|
||||
tags: ["agents"],
|
||||
summary: "Retry an incomplete agent lifecycle step",
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/agents/{id}/pause",
|
||||
|
||||
Reference in new issue
Block a user