forked from farhoodlabs/paperclip
Merge upstream/master into dev (76 commits)
Resolved 5 conflicts: - .github/workflows/docker.yml, release.yml: kept fork stubs (CI handled by build-prod/build-dev) - server/src/routes/secrets.ts: kept fork's /usages route alongside upstream's /usage, /access-events - server/src/services/secrets.ts: kept fork's usages() function and in-use deletion guard, layered before upstream's soft-delete + provider cleanup in remove() - ui/src/api/secrets.ts: kept fork's usages() method alongside upstream's vault methods Typechecks pass on @paperclipai/shared, @paperclipai/server, @paperclipai/ui.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { normalizeIssueIdentifier } from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { activityService, normalizeActivityLimit } from "../services/activity.js";
|
||||
import { assertAuthenticated, assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
@@ -24,8 +25,9 @@ export function activityRoutes(db: Db) {
|
||||
const issueSvc = issueService(db);
|
||||
|
||||
async function resolveIssueByRef(rawId: string) {
|
||||
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
||||
return issueSvc.getByIdentifier(rawId);
|
||||
const identifier = normalizeIssueIdentifier(rawId);
|
||||
if (identifier) {
|
||||
return issueSvc.getByIdentifier(identifier);
|
||||
}
|
||||
return issueSvc.getById(rawId);
|
||||
}
|
||||
|
||||
+331
-73
@@ -13,6 +13,7 @@ import {
|
||||
createAgentSchema,
|
||||
deriveAgentUrlKey,
|
||||
isUuidLike,
|
||||
normalizeIssueIdentifier,
|
||||
resetAgentSessionSchema,
|
||||
testAdapterEnvironmentSchema,
|
||||
type AgentSkillSnapshot,
|
||||
@@ -55,8 +56,12 @@ import {
|
||||
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 type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils";
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import {
|
||||
detectAdapterModel,
|
||||
@@ -84,7 +89,8 @@ import {
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
||||
import { requireOpenCodeModelId } from "@paperclipai/adapter-opencode-local/server";
|
||||
import {
|
||||
loadDefaultAgentInstructionsBundle,
|
||||
resolveDefaultAgentInstructionsBundleRole,
|
||||
@@ -158,6 +164,9 @@ export function agentRoutes(
|
||||
const approvalsSvc = approvalService(db);
|
||||
const budgets = budgetService(db);
|
||||
const environmentsSvc = environmentService(db);
|
||||
const environmentRuntime = environmentRuntimeService(db, {
|
||||
pluginWorkerManager: options.pluginWorkerManager,
|
||||
});
|
||||
const heartbeat = heartbeatService(db, {
|
||||
pluginWorkerManager: options.pluginWorkerManager,
|
||||
});
|
||||
@@ -189,9 +198,13 @@ export function agentRoutes(
|
||||
* - 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 → currently fall back to local probing
|
||||
* with a warning check, since lifting a temporary sandbox lease for an
|
||||
* ad-hoc test invocation is out of scope for this iteration.
|
||||
* - 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;
|
||||
@@ -201,9 +214,17 @@ export function agentRoutes(
|
||||
executionTarget: AdapterExecutionTarget | null;
|
||||
environmentName: string | null;
|
||||
fallbackChecks: AdapterEnvironmentCheck[];
|
||||
release: (status?: "released" | "failed") => Promise<void>;
|
||||
}> {
|
||||
const noopRelease = async () => {};
|
||||
|
||||
if (!input.environmentId) {
|
||||
return { executionTarget: null, environmentName: null, fallbackChecks: [] };
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: null,
|
||||
fallbackChecks: [],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
const environment = await environmentsSvc.getById(input.environmentId);
|
||||
@@ -215,14 +236,20 @@ export function agentRoutes(
|
||||
{
|
||||
code: "environment_not_found",
|
||||
level: "warn",
|
||||
message: "Selected environment was not found. Falling back to a local probe.",
|
||||
message: "Selected environment was not found. The test did not run.",
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
if (environment.driver === "local") {
|
||||
return { executionTarget: null, environmentName: environment.name, fallbackChecks: [] };
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
if (environment.driver === "ssh") {
|
||||
@@ -239,7 +266,12 @@ export function agentRoutes(
|
||||
leaseMetadata: null,
|
||||
});
|
||||
if (target) {
|
||||
return { executionTarget: target, environmentName: environment.name, fallbackChecks: [] };
|
||||
return {
|
||||
executionTarget: target,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
return {
|
||||
executionTarget: null,
|
||||
@@ -249,9 +281,10 @@ export function agentRoutes(
|
||||
code: "environment_target_unavailable",
|
||||
level: "warn",
|
||||
message:
|
||||
`Could not resolve an execution target for environment "${environment.name}". Falling back to a local probe.`,
|
||||
`Could not resolve an execution target for environment "${environment.name}". The test did not run.`,
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
@@ -262,27 +295,163 @@ export function agentRoutes(
|
||||
code: "environment_target_failed",
|
||||
level: "warn",
|
||||
message:
|
||||
`Could not connect to environment "${environment.name}" to run the test. Falling back to a local probe.`,
|
||||
`Could not connect to environment "${environment.name}" to run the test.`,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// sandbox / plugin / other drivers: not yet supported for ad-hoc adapter tests.
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_driver_not_supported_for_test",
|
||||
level: "warn",
|
||||
message:
|
||||
`Adapter testing inside ${environment.driver} environments is not yet supported. Falling back to a local probe; results may not reflect runs in "${environment.name}".`,
|
||||
hint: "Run a real heartbeat in the environment to verify end-to-end behavior.",
|
||||
// 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.
|
||||
let leaseRecord: Awaited<ReturnType<typeof environmentRuntime.acquireRunLease>>;
|
||||
try {
|
||||
leaseRecord = await environmentRuntime.acquireRunLease({
|
||||
companyId: input.companyId,
|
||||
environment,
|
||||
issueId: null,
|
||||
heartbeatRunId: null,
|
||||
persistedExecutionWorkspace: null,
|
||||
});
|
||||
} 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,
|
||||
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,
|
||||
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: environment.id,
|
||||
driver: environment.driver,
|
||||
config: environment.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 a sandbox 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: [],
|
||||
release: releaseLease,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -767,7 +936,6 @@ export function agentRoutes(
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
await assertAdapterConfigConstraints(
|
||||
input.companyId,
|
||||
input.adapterType,
|
||||
input.constraintAdapterConfig
|
||||
? { ...input.constraintAdapterConfig, ...normalizedAdapterConfig }
|
||||
@@ -864,7 +1032,10 @@ export function agentRoutes(
|
||||
next.model = DEFAULT_GEMINI_LOCAL_MODEL;
|
||||
return ensureGatewayDeviceKey(adapterType, next);
|
||||
}
|
||||
// OpenCode requires explicit model selection — no default
|
||||
if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) {
|
||||
next.model = DEFAULT_OPENCODE_LOCAL_MODEL;
|
||||
return ensureGatewayDeviceKey(adapterType, next);
|
||||
}
|
||||
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
|
||||
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
}
|
||||
@@ -872,20 +1043,12 @@ export function agentRoutes(
|
||||
}
|
||||
|
||||
async function assertAdapterConfigConstraints(
|
||||
companyId: string,
|
||||
adapterType: string | null | undefined,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
) {
|
||||
if (adapterType !== "opencode_local") return;
|
||||
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig);
|
||||
const runtimeEnv = asRecord(runtimeConfig.env) ?? {};
|
||||
try {
|
||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model: runtimeConfig.model,
|
||||
command: runtimeConfig.command,
|
||||
cwd: runtimeConfig.cwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
requireOpenCodeModelId(adapterConfig.model);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`);
|
||||
@@ -1194,6 +1357,17 @@ export function agentRoutes(
|
||||
const refresh = typeof req.query.refresh === "string"
|
||||
? ["1", "true", "yes"].includes(req.query.refresh.toLowerCase())
|
||||
: false;
|
||||
const environmentId = asNonEmptyString(req.query.environmentId);
|
||||
const environment = environmentId ? await environmentsSvc.getById(environmentId) : null;
|
||||
if (environmentId && (!environment || environment.companyId !== companyId)) {
|
||||
res.status(404).json({ error: "Environment not found" });
|
||||
return;
|
||||
}
|
||||
if (type === "opencode_local" && environment && environment.driver !== "local") {
|
||||
const adapter = requireServerAdapter(type);
|
||||
res.json(adapter.models ?? []);
|
||||
return;
|
||||
}
|
||||
const models = refresh
|
||||
? await refreshAdapterModels(type)
|
||||
: await listAdapterModels(type);
|
||||
@@ -1243,33 +1417,51 @@ export function agentRoutes(
|
||||
normalizedAdapterConfig,
|
||||
);
|
||||
|
||||
const { executionTarget, environmentName, fallbackChecks } =
|
||||
const { executionTarget, environmentName, fallbackChecks, release } =
|
||||
await resolveAdapterTestExecutionContext({
|
||||
companyId,
|
||||
adapterType: type,
|
||||
environmentId: requestedEnvironmentId,
|
||||
});
|
||||
|
||||
const result = await adapter.testEnvironment({
|
||||
companyId,
|
||||
adapterType: type,
|
||||
config: runtimeAdapterConfig,
|
||||
executionTarget,
|
||||
environmentName,
|
||||
});
|
||||
let releaseStatus: "released" | "failed" = "released";
|
||||
try {
|
||||
// If the caller explicitly selected an environment, never fall back to
|
||||
// probing the host when we couldn't resolve that environment's
|
||||
// execution target. Surface the diagnostic checks instead.
|
||||
if (requestedEnvironmentId && !executionTarget && fallbackChecks.length > 0) {
|
||||
const status: AdapterEnvironmentTestResult["status"] = fallbackChecks.some((c) => c.level === "error")
|
||||
? "fail"
|
||||
: fallbackChecks.some((c) => c.level === "warn")
|
||||
? "warn"
|
||||
: "pass";
|
||||
if (status === "fail") releaseStatus = "failed";
|
||||
const synthesized: AdapterEnvironmentTestResult = {
|
||||
adapterType: type,
|
||||
status,
|
||||
checks: fallbackChecks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
res.json(synthesized);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fallbackChecks.length > 0) {
|
||||
const checks = [...fallbackChecks, ...result.checks];
|
||||
const status: typeof result.status = checks.some((c) => c.level === "error")
|
||||
? "fail"
|
||||
: checks.some((c) => c.level === "warn")
|
||||
? "warn"
|
||||
: result.status;
|
||||
res.json({ ...result, checks, status });
|
||||
return;
|
||||
const result = await adapter.testEnvironment({
|
||||
companyId,
|
||||
adapterType: type,
|
||||
config: runtimeAdapterConfig,
|
||||
executionTarget,
|
||||
environmentName,
|
||||
});
|
||||
|
||||
if (result.status === "fail") releaseStatus = "failed";
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
releaseStatus = "failed";
|
||||
throw err;
|
||||
} finally {
|
||||
await release(releaseStatus);
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1997,6 +2189,14 @@ export function agentRoutes(
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent, instructionsBundle);
|
||||
const agentEnv = asRecord(agent.adapterConfig)?.env;
|
||||
if (agentEnv) {
|
||||
await secretsSvc.syncEnvBindingsForTarget?.(
|
||||
companyId,
|
||||
{ targetType: "agent", targetId: agent.id },
|
||||
agentEnv,
|
||||
);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -2473,6 +2673,14 @@ export function agentRoutes(
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
if (touchesAdapterConfiguration) {
|
||||
const agentEnv = asRecord(agent.adapterConfig)?.env;
|
||||
await secretsSvc.syncEnvBindingsForTarget?.(
|
||||
agent.companyId,
|
||||
{ targetType: "agent", targetId: agent.id },
|
||||
agentEnv,
|
||||
);
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
@@ -2691,7 +2899,25 @@ export function agentRoutes(
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => {
|
||||
// Shared handler body for the wakeup-style endpoints. The two routes differ
|
||||
// only in:
|
||||
// - `source` — the modern /wakeup endpoint reads it from the request body
|
||||
// (timer|assignment|on_demand|automation) while the legacy
|
||||
// /heartbeat/invoke endpoint hardcodes "on_demand", since it has only
|
||||
// ever produced on-demand invocations.
|
||||
// - skipped-response shape — the modern endpoint surfaces the rich
|
||||
// SkippedWakeupResponse; the legacy endpoint stays on the simpler
|
||||
// { status: "skipped" } shape for backward compat.
|
||||
type HeartbeatSource = "timer" | "assignment" | "on_demand" | "automation";
|
||||
type WakeupRouteOpts = {
|
||||
source: HeartbeatSource | undefined;
|
||||
skippedResponse: (agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>) => unknown | Promise<unknown>;
|
||||
};
|
||||
const handleWakeupRoute = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
opts: WakeupRouteOpts,
|
||||
): Promise<void> => {
|
||||
const id = req.params.id as string;
|
||||
const agent = await svc.getById(id);
|
||||
if (!agent) {
|
||||
@@ -2710,7 +2936,7 @@ export function agentRoutes(
|
||||
}
|
||||
|
||||
const run = await heartbeat.wakeup(id, {
|
||||
source: req.body.source,
|
||||
source: opts.source,
|
||||
triggerDetail: req.body.triggerDetail ?? "manual",
|
||||
reason: req.body.reason ?? null,
|
||||
payload: req.body.payload ?? null,
|
||||
@@ -2725,7 +2951,7 @@ export function agentRoutes(
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
res.status(202).json(await buildSkippedWakeupResponse(agent, req.body.payload ?? null));
|
||||
res.status(202).json(await opts.skippedResponse(agent));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2743,9 +2969,23 @@ export function agentRoutes(
|
||||
});
|
||||
|
||||
res.status(202).json(run);
|
||||
};
|
||||
|
||||
router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => {
|
||||
await handleWakeupRoute(req, res, {
|
||||
source: req.body.source,
|
||||
skippedResponse: (agent) => buildSkippedWakeupResponse(agent, req.body.payload ?? null),
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/agents/:id/heartbeat/invoke", async (req, res) => {
|
||||
// Legacy endpoint. Hardcodes `source: "on_demand"` (the prior behavior
|
||||
// before the wakeup/invoke convergence). Reads scope fields directly off
|
||||
// the body without `validate(wakeAgentSchema)` because callers — including
|
||||
// the e2e suite — post an empty body, and the schema rejects undefined
|
||||
// / missing bodies. Only forwards fields the caller actually supplied so
|
||||
// an empty body produces the original fixed-arg `heartbeat.invoke()`
|
||||
// shape exactly.
|
||||
const id = req.params.id as string;
|
||||
const agent = await svc.getById(id);
|
||||
if (!agent) {
|
||||
@@ -2763,19 +3003,37 @@ export function agentRoutes(
|
||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||
}
|
||||
|
||||
const run = await heartbeat.invoke(
|
||||
id,
|
||||
"on_demand",
|
||||
{
|
||||
triggeredBy: req.actor.type,
|
||||
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
|
||||
},
|
||||
"manual",
|
||||
{
|
||||
actorType: req.actor.type === "agent" ? "agent" : "user",
|
||||
actorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
|
||||
},
|
||||
);
|
||||
const body = (req.body ?? {}) as Partial<{
|
||||
reason: unknown;
|
||||
payload: unknown;
|
||||
idempotencyKey: unknown;
|
||||
forceFreshSession: unknown;
|
||||
triggerDetail: unknown;
|
||||
}>;
|
||||
const contextSnapshot: Record<string, unknown> = {
|
||||
triggeredBy: req.actor.type,
|
||||
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
|
||||
};
|
||||
if (body.forceFreshSession === true) {
|
||||
contextSnapshot.forceFreshSession = true;
|
||||
}
|
||||
const wakeOpts: Parameters<typeof heartbeat.wakeup>[1] = {
|
||||
source: "on_demand",
|
||||
triggerDetail: typeof body.triggerDetail === "string" ? body.triggerDetail as "manual" | "system" | "ping" | "callback" : "manual",
|
||||
requestedByActorType: req.actor.type === "agent" ? "agent" : "user",
|
||||
requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
|
||||
contextSnapshot,
|
||||
};
|
||||
if (typeof body.reason === "string" && body.reason.length > 0) {
|
||||
wakeOpts.reason = body.reason;
|
||||
}
|
||||
if (body.payload && typeof body.payload === "object" && !Array.isArray(body.payload)) {
|
||||
wakeOpts.payload = body.payload as Record<string, unknown>;
|
||||
}
|
||||
if (typeof body.idempotencyKey === "string" && body.idempotencyKey.length > 0) {
|
||||
wakeOpts.idempotencyKey = body.idempotencyKey;
|
||||
}
|
||||
const run = await heartbeat.wakeup(id, wakeOpts);
|
||||
|
||||
if (!run) {
|
||||
res.status(202).json({ status: "skipped" });
|
||||
@@ -3082,8 +3340,8 @@ export function agentRoutes(
|
||||
router.get("/issues/:issueId/live-runs", async (req, res) => {
|
||||
const rawId = req.params.issueId as string;
|
||||
const issueSvc = issueService(db);
|
||||
const isIdentifier = /^[A-Z]+-\d+$/i.test(rawId);
|
||||
const issue = isIdentifier ? await issueSvc.getByIdentifier(rawId) : await issueSvc.getById(rawId);
|
||||
const identifier = normalizeIssueIdentifier(rawId);
|
||||
const issue = identifier ? await issueSvc.getByIdentifier(identifier) : await issueSvc.getById(rawId);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
@@ -3136,8 +3394,8 @@ export function agentRoutes(
|
||||
router.get("/issues/:issueId/active-run", async (req, res) => {
|
||||
const rawId = req.params.issueId as string;
|
||||
const issueSvc = issueService(db);
|
||||
const isIdentifier = /^[A-Z]+-\d+$/i.test(rawId);
|
||||
const issue = isIdentifier ? await issueSvc.getByIdentifier(rawId) : await issueSvc.getById(rawId);
|
||||
const identifier = normalizeIssueIdentifier(rawId);
|
||||
const issue = identifier ? await issueSvc.getByIdentifier(identifier) : await issueSvc.getById(rawId);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
createCostEventSchema,
|
||||
createFinanceEventSchema,
|
||||
normalizeIssueIdentifier,
|
||||
resolveBudgetIncidentSchema,
|
||||
updateBudgetSchema,
|
||||
upsertBudgetPolicySchema,
|
||||
@@ -62,8 +63,9 @@ export function costRoutes(
|
||||
const issues = issueService(db);
|
||||
|
||||
async function resolveIssueByRef(rawId: string) {
|
||||
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
||||
return issues.getByIdentifier(rawId);
|
||||
const identifier = normalizeIssueIdentifier(rawId);
|
||||
if (identifier) {
|
||||
return issues.getByIdentifier(identifier);
|
||||
}
|
||||
return issues.getById(rawId);
|
||||
}
|
||||
@@ -143,7 +145,8 @@ export function costRoutes(
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const summary = await costs.issueTreeSummary(issue.companyId, issue.id);
|
||||
const excludeRoot = req.query.excludeRoot === "true" || req.query.excludeRoot === "1";
|
||||
const summary = await costs.issueTreeSummary(issue.companyId, issue.id, { excludeRoot });
|
||||
res.json(summary);
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
projectService,
|
||||
} from "../services/index.js";
|
||||
import {
|
||||
collectEnvironmentSecretRefs,
|
||||
normalizeEnvironmentConfigForPersistence,
|
||||
normalizeEnvironmentConfigForProbe,
|
||||
parseEnvironmentDriverConfig,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
import { probeEnvironment } from "../services/environment-probe.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { listReadyPluginEnvironmentDrivers } from "../services/plugin-environment-driver.js";
|
||||
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
import { environmentService } from "../services/environments.js";
|
||||
@@ -202,6 +204,7 @@ export function environmentRoutes(
|
||||
companyId,
|
||||
environmentName: req.body.name,
|
||||
driver: req.body.driver,
|
||||
secretProvider: getConfiguredSecretProvider(),
|
||||
config: req.body.config,
|
||||
actor: {
|
||||
agentId: actor.agentId,
|
||||
@@ -211,6 +214,11 @@ export function environmentRoutes(
|
||||
}),
|
||||
};
|
||||
const environment = await svc.create(companyId, input);
|
||||
await secrets.syncSecretRefsForTarget(
|
||||
companyId,
|
||||
{ targetType: "environment", targetId: environment.id },
|
||||
await collectEnvironmentSecretRefs({ db, environment }),
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
@@ -305,6 +313,7 @@ export function environmentRoutes(
|
||||
companyId: existing.companyId,
|
||||
environmentName: nextName,
|
||||
driver: nextDriver,
|
||||
secretProvider: getConfiguredSecretProvider(),
|
||||
config: configSource,
|
||||
actor: {
|
||||
agentId: actor.agentId,
|
||||
@@ -320,6 +329,13 @@ export function environmentRoutes(
|
||||
res.status(404).json({ error: "Environment not found" });
|
||||
return;
|
||||
}
|
||||
if (patch.config !== undefined || patch.driver !== undefined) {
|
||||
await secrets.syncSecretRefsForTarget(
|
||||
environment.companyId,
|
||||
{ targetType: "environment", targetId: environment.id },
|
||||
await collectEnvironmentSecretRefs({ db, environment }),
|
||||
);
|
||||
}
|
||||
await logActivity(db, {
|
||||
companyId: environment.companyId,
|
||||
actorType: actor.actorType,
|
||||
|
||||
+722
-13
File diff suppressed because it is too large
Load Diff
@@ -66,6 +66,17 @@ import {
|
||||
getActorInfo,
|
||||
} from "./authz.js";
|
||||
import { validateInstanceConfig } from "../services/plugin-config-validator.js";
|
||||
import {
|
||||
findLocalFolderDeclaration,
|
||||
getStoredLocalFolders,
|
||||
inspectPluginLocalFolder,
|
||||
requireLocalFolderDeclaration,
|
||||
setStoredLocalFolder,
|
||||
} from "../services/plugin-local-folders.js";
|
||||
import {
|
||||
extractSecretRefPathsFromConfig,
|
||||
PLUGIN_SECRET_REFS_DISABLED_MESSAGE,
|
||||
} from "../services/plugin-secrets-handler.js";
|
||||
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
||||
|
||||
/** UI slot declaration extracted from plugin manifest */
|
||||
@@ -1934,6 +1945,12 @@ export function pluginRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
const secretRefsByPath = extractSecretRefPathsFromConfig(body.configJson, schema);
|
||||
if (secretRefsByPath.size > 0) {
|
||||
res.status(422).json({ error: PLUGIN_SECRET_REFS_DISABLED_MESSAGE });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await registry.upsertConfig(plugin.id, {
|
||||
configJson: body.configJson,
|
||||
});
|
||||
@@ -2379,6 +2396,152 @@ export function pluginRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Company-scoped trusted local folders
|
||||
// ===========================================================================
|
||||
|
||||
router.get("/plugins/:pluginId/companies/:companyId/local-folders", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
const { pluginId, companyId } = req.params;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
if (!plugin) {
|
||||
res.status(404).json({ error: "Plugin not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await registry.getCompanySettings(plugin.id, companyId);
|
||||
const storedFolders = getStoredLocalFolders(settings?.settingsJson);
|
||||
const declarations = plugin.manifestJson.localFolders ?? [];
|
||||
const folderKeys = declarations.map((declaration) => declaration.folderKey);
|
||||
|
||||
const statuses = await Promise.all(folderKeys.map((folderKey) =>
|
||||
inspectPluginLocalFolder({
|
||||
folderKey,
|
||||
declaration: findLocalFolderDeclaration(declarations, folderKey),
|
||||
storedConfig: storedFolders[folderKey] ?? null,
|
||||
})));
|
||||
|
||||
res.json({
|
||||
pluginId: plugin.id,
|
||||
companyId,
|
||||
declarations,
|
||||
folders: statuses,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/status", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
const { pluginId, companyId, folderKey } = req.params;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
if (!plugin) {
|
||||
res.status(404).json({ error: "Plugin not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await registry.getCompanySettings(plugin.id, companyId);
|
||||
const storedFolders = getStoredLocalFolders(settings?.settingsJson);
|
||||
const declarations = plugin.manifestJson.localFolders ?? [];
|
||||
const declaration = requireLocalFolderDeclaration(declarations, folderKey);
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey,
|
||||
declaration,
|
||||
storedConfig: storedFolders[folderKey] ?? null,
|
||||
});
|
||||
res.json(status);
|
||||
});
|
||||
|
||||
router.post("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/validate", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
const { pluginId, companyId, folderKey } = req.params;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
if (!plugin) {
|
||||
res.status(404).json({ error: "Plugin not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body as {
|
||||
path?: unknown;
|
||||
access?: "read" | "readWrite";
|
||||
requiredDirectories?: string[];
|
||||
requiredFiles?: string[];
|
||||
} | undefined;
|
||||
if (typeof body?.path !== "string" || body.path.trim().length === 0) {
|
||||
res.status(400).json({ error: '"path" is required and must be a non-empty string' });
|
||||
return;
|
||||
}
|
||||
|
||||
const declaration = requireLocalFolderDeclaration(plugin.manifestJson.localFolders ?? [], folderKey);
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey,
|
||||
declaration,
|
||||
overrideConfig: {
|
||||
path: body.path,
|
||||
},
|
||||
});
|
||||
res.json(status);
|
||||
});
|
||||
|
||||
router.put("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
const { pluginId, companyId, folderKey } = req.params;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
if (!plugin) {
|
||||
res.status(404).json({ error: "Plugin not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body as {
|
||||
path?: unknown;
|
||||
access?: "read" | "readWrite";
|
||||
requiredDirectories?: string[];
|
||||
requiredFiles?: string[];
|
||||
} | undefined;
|
||||
if (typeof body?.path !== "string" || body.path.trim().length === 0) {
|
||||
res.status(400).json({ error: '"path" is required and must be a non-empty string' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await registry.getCompanySettings(plugin.id, companyId);
|
||||
const declaration = requireLocalFolderDeclaration(plugin.manifestJson.localFolders ?? [], folderKey);
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey,
|
||||
declaration,
|
||||
storedConfig: getStoredLocalFolders(existing?.settingsJson)[folderKey] ?? null,
|
||||
overrideConfig: {
|
||||
path: body.path,
|
||||
},
|
||||
});
|
||||
|
||||
const nextSettings = setStoredLocalFolder(existing?.settingsJson, folderKey, {
|
||||
path: body.path,
|
||||
access: status.access,
|
||||
requiredDirectories: status.requiredDirectories,
|
||||
requiredFiles: status.requiredFiles,
|
||||
});
|
||||
await registry.upsertCompanySettings(plugin.id, companyId, {
|
||||
enabled: existing?.enabled ?? true,
|
||||
settingsJson: nextSettings,
|
||||
lastError: status.healthy ? null : status.problems.map((item: { message: string }) => item.message).join("; "),
|
||||
});
|
||||
await logPluginMutationActivity(req, "plugin.local_folder.configured", plugin.id, {
|
||||
pluginId: plugin.id,
|
||||
pluginKey: plugin.pluginKey,
|
||||
companyId,
|
||||
folderKey,
|
||||
healthy: status.healthy,
|
||||
});
|
||||
|
||||
res.json(status);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Plugin health dashboard — aggregated diagnostics for the settings page
|
||||
// ===========================================================================
|
||||
|
||||
@@ -142,6 +142,13 @@ export function projectRoutes(db: Db) {
|
||||
);
|
||||
}
|
||||
const project = await svc.create(companyId, projectData);
|
||||
if (project.env) {
|
||||
await secretsSvc.syncEnvBindingsForTarget?.(
|
||||
companyId,
|
||||
{ targetType: "project", targetId: project.id },
|
||||
project.env,
|
||||
);
|
||||
}
|
||||
let createdWorkspaceId: string | null = null;
|
||||
if (workspace) {
|
||||
const createdWorkspace = await svc.createWorkspace(project.id, workspace);
|
||||
@@ -207,6 +214,13 @@ export function projectRoutes(db: Db) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
if (body.env !== undefined) {
|
||||
await secretsSvc.syncEnvBindingsForTarget?.(
|
||||
project.companyId,
|
||||
{ targetType: "project", targetId: project.id },
|
||||
project.env,
|
||||
);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
|
||||
@@ -57,6 +57,34 @@ export function routineRoutes(
|
||||
return routine;
|
||||
}
|
||||
|
||||
async function logRoutineRevisionCreated(req: Request, input: {
|
||||
companyId: string;
|
||||
routineId: string;
|
||||
revisionId: string | null;
|
||||
revisionNumber: number;
|
||||
changeSummary?: string | null;
|
||||
triggerCount?: number | null;
|
||||
}) {
|
||||
if (!input.revisionId) return;
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: input.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "routine.revision_created",
|
||||
entityType: "routine",
|
||||
entityId: input.routineId,
|
||||
details: {
|
||||
revisionId: input.revisionId,
|
||||
revisionNumber: input.revisionNumber,
|
||||
changeSummary: input.changeSummary ?? null,
|
||||
triggerCount: input.triggerCount ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
router.get("/companies/:companyId/routines", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
@@ -72,6 +100,7 @@ export function routineRoutes(
|
||||
const created = await svc.create(companyId, req.body, {
|
||||
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||
runId: req.actor.runId ?? null,
|
||||
});
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -89,6 +118,14 @@ export function routineRoutes(
|
||||
if (telemetryClient) {
|
||||
trackRoutineCreated(telemetryClient);
|
||||
}
|
||||
await logRoutineRevisionCreated(req, {
|
||||
companyId,
|
||||
routineId: created.id,
|
||||
revisionId: created.latestRevisionId,
|
||||
revisionNumber: created.latestRevisionNumber,
|
||||
changeSummary: "Created routine",
|
||||
triggerCount: 0,
|
||||
});
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
@@ -102,6 +139,16 @@ export function routineRoutes(
|
||||
res.json(detail);
|
||||
});
|
||||
|
||||
router.get("/routines/:id/revisions", async (req, res) => {
|
||||
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
|
||||
if (!routine) {
|
||||
res.status(404).json({ error: "Routine not found" });
|
||||
return;
|
||||
}
|
||||
const revisions = await svc.listRevisions(routine.id);
|
||||
res.json(revisions);
|
||||
});
|
||||
|
||||
router.patch("/routines/:id", validate(updateRoutineSchema), async (req, res) => {
|
||||
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
|
||||
if (!routine) {
|
||||
@@ -131,6 +178,7 @@ export function routineRoutes(
|
||||
const updated = await svc.update(routine.id, req.body, {
|
||||
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||
runId: req.actor.runId ?? null,
|
||||
});
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -144,9 +192,52 @@ export function routineRoutes(
|
||||
entityId: routine.id,
|
||||
details: { title: updated?.title ?? routine.title },
|
||||
});
|
||||
if (updated && updated.latestRevisionId !== routine.latestRevisionId) {
|
||||
await logRoutineRevisionCreated(req, {
|
||||
companyId: routine.companyId,
|
||||
routineId: routine.id,
|
||||
revisionId: updated.latestRevisionId,
|
||||
revisionNumber: updated.latestRevisionNumber,
|
||||
changeSummary: "Updated routine",
|
||||
triggerCount: null,
|
||||
});
|
||||
}
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.post("/routines/:id/revisions/:revisionId/restore", async (req, res) => {
|
||||
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
|
||||
if (!routine) {
|
||||
res.status(404).json({ error: "Routine not found" });
|
||||
return;
|
||||
}
|
||||
await assertBoardCanAssignTasks(req, routine.companyId);
|
||||
const result = await svc.restoreRevision(routine.id, req.params.revisionId as string, {
|
||||
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||
runId: req.actor.runId ?? null,
|
||||
});
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: routine.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "routine.revision_restored",
|
||||
entityType: "routine",
|
||||
entityId: routine.id,
|
||||
details: {
|
||||
revisionId: result.revision.id,
|
||||
revisionNumber: result.revision.revisionNumber,
|
||||
restoredFromRevisionId: result.restoredFromRevisionId,
|
||||
restoredFromRevisionNumber: result.restoredFromRevisionNumber,
|
||||
triggerCount: result.revision.snapshot.triggers.length,
|
||||
},
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/routines/:id/runs", async (req, res) => {
|
||||
const routine = await svc.get(req.params.id as string);
|
||||
if (!routine) {
|
||||
@@ -169,6 +260,7 @@ export function routineRoutes(
|
||||
const created = await svc.createTrigger(routine.id, req.body, {
|
||||
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||
runId: req.actor.runId ?? null,
|
||||
});
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -182,6 +274,14 @@ export function routineRoutes(
|
||||
entityId: created.trigger.id,
|
||||
details: { routineId: routine.id, kind: created.trigger.kind },
|
||||
});
|
||||
await logRoutineRevisionCreated(req, {
|
||||
companyId: routine.companyId,
|
||||
routineId: routine.id,
|
||||
revisionId: created.revision.id,
|
||||
revisionNumber: created.revision.revisionNumber,
|
||||
changeSummary: created.revision.changeSummary,
|
||||
triggerCount: created.revision.snapshot.triggers.length,
|
||||
});
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
@@ -200,6 +300,7 @@ export function routineRoutes(
|
||||
const updated = await svc.updateTrigger(trigger.id, req.body, {
|
||||
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||
runId: req.actor.runId ?? null,
|
||||
});
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -211,9 +312,19 @@ export function routineRoutes(
|
||||
action: "routine.trigger_updated",
|
||||
entityType: "routine_trigger",
|
||||
entityId: trigger.id,
|
||||
details: { routineId: routine.id, kind: updated?.kind ?? trigger.kind },
|
||||
details: { routineId: routine.id, kind: updated?.trigger.kind ?? trigger.kind },
|
||||
});
|
||||
res.json(updated);
|
||||
if (updated) {
|
||||
await logRoutineRevisionCreated(req, {
|
||||
companyId: routine.companyId,
|
||||
routineId: routine.id,
|
||||
revisionId: updated.revision.id,
|
||||
revisionNumber: updated.revision.revisionNumber,
|
||||
changeSummary: updated.revision.changeSummary,
|
||||
triggerCount: updated.revision.snapshot.triggers.length,
|
||||
});
|
||||
}
|
||||
res.json(updated?.trigger ?? null);
|
||||
});
|
||||
|
||||
router.delete("/routine-triggers/:id", async (req, res) => {
|
||||
@@ -227,7 +338,11 @@ export function routineRoutes(
|
||||
res.status(404).json({ error: "Routine not found" });
|
||||
return;
|
||||
}
|
||||
await svc.deleteTrigger(trigger.id);
|
||||
const deleted = await svc.deleteTrigger(trigger.id, {
|
||||
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||
runId: req.actor.runId ?? null,
|
||||
});
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: routine.companyId,
|
||||
@@ -240,6 +355,16 @@ export function routineRoutes(
|
||||
entityId: trigger.id,
|
||||
details: { routineId: routine.id, kind: trigger.kind },
|
||||
});
|
||||
if (deleted.revision) {
|
||||
await logRoutineRevisionCreated(req, {
|
||||
companyId: routine.companyId,
|
||||
routineId: routine.id,
|
||||
revisionId: deleted.revision.id,
|
||||
revisionNumber: deleted.revision.revisionNumber,
|
||||
changeSummary: deleted.revision.changeSummary,
|
||||
triggerCount: deleted.revision.snapshot.triggers.length,
|
||||
});
|
||||
}
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
@@ -260,6 +385,7 @@ export function routineRoutes(
|
||||
const rotated = await svc.rotateTriggerSecret(trigger.id, {
|
||||
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||
runId: req.actor.runId ?? null,
|
||||
});
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -273,6 +399,14 @@ export function routineRoutes(
|
||||
entityId: trigger.id,
|
||||
details: { routineId: routine.id },
|
||||
});
|
||||
await logRoutineRevisionCreated(req, {
|
||||
companyId: routine.companyId,
|
||||
routineId: routine.id,
|
||||
revisionId: rotated.revision.id,
|
||||
revisionNumber: rotated.revision.revisionNumber,
|
||||
changeSummary: rotated.revision.changeSummary,
|
||||
triggerCount: rotated.revision.snapshot.triggers.length,
|
||||
});
|
||||
res.json(rotated);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
SECRET_PROVIDERS,
|
||||
type SecretProvider,
|
||||
createSecretProviderConfigSchema,
|
||||
createSecretSchema,
|
||||
remoteSecretImportPreviewSchema,
|
||||
remoteSecretImportSchema,
|
||||
rotateSecretSchema,
|
||||
updateSecretProviderConfigSchema,
|
||||
updateSecretSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
import { logActivity, secretService } from "../services/index.js";
|
||||
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
|
||||
|
||||
export function secretRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = secretService(db);
|
||||
const configuredDefaultProvider = process.env.PAPERCLIP_SECRETS_PROVIDER;
|
||||
const defaultProvider = (
|
||||
configuredDefaultProvider && SECRET_PROVIDERS.includes(configuredDefaultProvider as SecretProvider)
|
||||
? configuredDefaultProvider
|
||||
: "local_encrypted"
|
||||
) as SecretProvider;
|
||||
const defaultProvider = getConfiguredSecretProvider();
|
||||
|
||||
router.get("/companies/:companyId/secret-providers", (req, res) => {
|
||||
assertBoard(req);
|
||||
@@ -28,6 +26,205 @@ export function secretRoutes(db: Db) {
|
||||
res.json(svc.listProviders());
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/secret-providers/health", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const checks = await svc.checkProviders();
|
||||
res.json({ providers: checks });
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/secret-provider-configs", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
res.json(await svc.listProviderConfigs(companyId));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/secret-provider-configs", validate(createSecretProviderConfigSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const created = await svc.createProviderConfig(
|
||||
companyId,
|
||||
{
|
||||
provider: req.body.provider,
|
||||
displayName: req.body.displayName,
|
||||
status: req.body.status,
|
||||
isDefault: req.body.isDefault,
|
||||
config: req.body.config,
|
||||
},
|
||||
{ userId: req.actor.userId ?? "board", agentId: null },
|
||||
);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret_provider_config.created",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: created.id,
|
||||
details: {
|
||||
provider: created.provider,
|
||||
displayName: created.displayName,
|
||||
status: created.status,
|
||||
isDefault: created.isDefault,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
router.get("/secret-provider-configs/:id", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const existing = await svc.getProviderConfigById(req.params.id as string);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
res.json(existing);
|
||||
});
|
||||
|
||||
router.patch("/secret-provider-configs/:id", validate(updateSecretProviderConfigSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getProviderConfigById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
|
||||
const updated = await svc.updateProviderConfig(id, {
|
||||
displayName: req.body.displayName,
|
||||
status: req.body.status,
|
||||
isDefault: req.body.isDefault,
|
||||
config: req.body.config,
|
||||
});
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: updated.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret_provider_config.updated",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: updated.id,
|
||||
details: {
|
||||
provider: updated.provider,
|
||||
displayName: updated.displayName,
|
||||
status: updated.status,
|
||||
isDefault: updated.isDefault,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.delete("/secret-provider-configs/:id", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getProviderConfigById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
|
||||
const disabled = await svc.disableProviderConfig(id);
|
||||
if (!disabled) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: disabled.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret_provider_config.disabled",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: disabled.id,
|
||||
details: {
|
||||
provider: disabled.provider,
|
||||
displayName: disabled.displayName,
|
||||
status: disabled.status,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(disabled);
|
||||
});
|
||||
|
||||
router.post("/secret-provider-configs/:id/default", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getProviderConfigById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
|
||||
const updated = await svc.setDefaultProviderConfig(id);
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: updated.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret_provider_config.default_set",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: updated.id,
|
||||
details: {
|
||||
provider: updated.provider,
|
||||
displayName: updated.displayName,
|
||||
isDefault: updated.isDefault,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.post("/secret-provider-configs/:id/health", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getProviderConfigById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
|
||||
const health = await svc.checkProviderConfigHealth(id);
|
||||
if (!health) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: existing.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret_provider_config.health_checked",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: existing.id,
|
||||
details: {
|
||||
provider: existing.provider,
|
||||
status: health.status,
|
||||
code: health.details.code,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(health);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/secrets", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
@@ -45,10 +242,15 @@ export function secretRoutes(db: Db) {
|
||||
companyId,
|
||||
{
|
||||
name: req.body.name,
|
||||
key: req.body.key,
|
||||
provider: req.body.provider ?? defaultProvider,
|
||||
providerConfigId: req.body.providerConfigId,
|
||||
managedMode: req.body.managedMode,
|
||||
value: req.body.value,
|
||||
description: req.body.description,
|
||||
externalRef: req.body.externalRef,
|
||||
providerVersionRef: req.body.providerVersionRef,
|
||||
providerMetadata: req.body.providerMetadata,
|
||||
},
|
||||
{ userId: req.actor.userId ?? "board", agentId: null },
|
||||
);
|
||||
@@ -66,6 +268,77 @@ export function secretRoutes(db: Db) {
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/secrets/remote-import/preview",
|
||||
validate(remoteSecretImportPreviewSchema),
|
||||
async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const preview = await svc.previewRemoteImport(companyId, {
|
||||
providerConfigId: req.body.providerConfigId,
|
||||
query: req.body.query,
|
||||
nextToken: req.body.nextToken,
|
||||
pageSize: req.body.pageSize,
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret.remote_import.previewed",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: preview.providerConfigId,
|
||||
details: {
|
||||
provider: preview.provider,
|
||||
candidateCount: preview.candidates.length,
|
||||
readyCount: preview.candidates.filter((candidate) => candidate.status === "ready").length,
|
||||
duplicateCount: preview.candidates.filter((candidate) => candidate.status === "duplicate").length,
|
||||
conflictCount: preview.candidates.filter((candidate) => candidate.status === "conflict").length,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(preview);
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/secrets/remote-import",
|
||||
validate(remoteSecretImportSchema),
|
||||
async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const result = await svc.importRemoteSecrets(
|
||||
companyId,
|
||||
{
|
||||
providerConfigId: req.body.providerConfigId,
|
||||
secrets: req.body.secrets,
|
||||
},
|
||||
{ userId: req.actor.userId ?? "board", agentId: null },
|
||||
);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret.remote_import.completed",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: result.providerConfigId,
|
||||
details: {
|
||||
provider: result.provider,
|
||||
importedCount: result.importedCount,
|
||||
skippedCount: result.skippedCount,
|
||||
errorCount: result.errorCount,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
router.post("/secrets/:id/rotate", validate(rotateSecretSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
@@ -75,12 +348,18 @@ export function secretRoutes(db: Db) {
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
if (existing.status === "deleted") {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const rotated = await svc.rotate(
|
||||
id,
|
||||
{
|
||||
value: req.body.value,
|
||||
externalRef: req.body.externalRef,
|
||||
providerVersionRef: req.body.providerVersionRef,
|
||||
providerConfigId: req.body.providerConfigId,
|
||||
},
|
||||
{ userId: req.actor.userId ?? "board", agentId: null },
|
||||
);
|
||||
@@ -107,11 +386,19 @@ export function secretRoutes(db: Db) {
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
if (existing.status === "deleted") {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await svc.update(id, {
|
||||
name: req.body.name,
|
||||
key: req.body.key,
|
||||
status: req.body.status,
|
||||
providerConfigId: req.body.providerConfigId,
|
||||
description: req.body.description,
|
||||
externalRef: req.body.externalRef,
|
||||
providerMetadata: req.body.providerMetadata,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
@@ -145,6 +432,32 @@ export function secretRoutes(db: Db) {
|
||||
res.json({ agents: used.agents, skills: used.skills });
|
||||
});
|
||||
|
||||
router.get("/secrets/:id/usage", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
const bindings = await svc.listBindingReferences(existing.companyId, existing.id);
|
||||
res.json({ secretId: existing.id, bindings });
|
||||
});
|
||||
|
||||
router.get("/secrets/:id/access-events", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
const events = await svc.listAccessEvents(existing.companyId, existing.id);
|
||||
res.json(events);
|
||||
});
|
||||
|
||||
router.delete("/secrets/:id", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
|
||||
Reference in New Issue
Block a user