Implement user-secret runtime responsibility gates

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-01 17:09:39 +00:00
1 parent 4e8cbf13e3
commit 2f8bab01bc
9 files changed
+519 -15

No files matched your search

@@ -283,6 +283,72 @@ describe("resolveExecutionRunAdapterConfig", () => {
});
});
it("blocks required missing user secrets before runtime env resolution", async () => {
const resolveAdapterConfigForRuntime = vi.fn();
const resolveEnvBindings = vi.fn();
const collectMissingRuntimeBindings = vi.fn(async (_companyId, _env, context) =>
context.consumerType === "agent"
? [
{
consumerType: "agent",
consumerId: "agent-1",
configPath: "env.GITHUB_TOKEN",
envKey: "GITHUB_TOKEN",
bindingType: "user_secret_ref",
secretId: null,
secretName: null,
userSecretDefinitionId: "definition-1",
userSecretDefinitionKey: "github_token",
userSecretDefinitionName: "GitHub token",
responsibleUserId: context.responsibleUserId,
errorCode: "user_secret_missing",
},
]
: [],
);
await expect(resolveExecutionRunAdapterConfig({
companyId: "company-1",
agentId: "agent-1",
issueId: "issue-1",
heartbeatRunId: "run-1",
responsibleUserId: "user-1",
executionRunConfig: {
env: {
GITHUB_TOKEN: { type: "user_secret_ref", key: "github_token", required: true },
},
},
projectEnv: null,
secretsSvc: {
resolveAdapterConfigForRuntime,
resolveEnvBindings,
collectMissingRuntimeBindings,
} as any,
})).rejects.toMatchObject({
code: "configuration_incomplete",
resultJson: {
configurationIncomplete: {
reason: "secret_binding_missing",
companyId: "company-1",
agentId: "agent-1",
issueId: "issue-1",
missingBindings: [
expect.objectContaining({
bindingType: "user_secret_ref",
userSecretDefinitionKey: "github_token",
responsibleUserId: "user-1",
}),
],
},
},
});
expect(collectMissingRuntimeBindings.mock.calls[0]?.[2]).toMatchObject({
responsibleUserId: "user-1",
});
expect(resolveAdapterConfigForRuntime).not.toHaveBeenCalled();
expect(resolveEnvBindings).not.toHaveBeenCalled();
});
it("rejects inline sensitive env values for low-trust runs", async () => {
await expect(resolveExecutionRunAdapterConfig({
companyId: "company-1",
@@ -2151,6 +2151,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(goals);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(environments);
await db.delete(instanceSettings);
@@ -2237,6 +2238,57 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
});
});
it("inherits responsible user for agent-created child issues", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
const responsibleUserId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const parent = await svc.create(companyId, {
title: "Parent issue",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
createdByUserId: responsibleUserId,
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "assignment",
status: "running",
responsibleUserId,
contextSnapshot: { issueId: parent.id },
});
const child = await svc.create(companyId, {
parentId: parent.id,
title: "Agent-created child",
createdByAgentId: agentId,
actorRunId: runId,
});
expect(parent.responsibleUserId).toBe(responsibleUserId);
expect(child.responsibleUserId).toBe(responsibleUserId);
});
it("does not stamp the assignee default environment onto new issues", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
@@ -712,6 +712,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
id: issues.id,
assigneeAgentId: issues.assigneeAgentId,
createdByUserId: issues.createdByUserId,
responsibleUserId: issues.responsibleUserId,
})
.from(issues)
.where(eq(issues.id, run.linkedIssueId!));
@@ -719,6 +720,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
id: run.linkedIssueId,
assigneeAgentId: agentId,
createdByUserId: userId,
responsibleUserId: userId,
});
const inboxIssues = await issueSvc.list(companyId, {
@@ -729,6 +731,45 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(inboxIssues.map((issue) => issue.id)).toContain(run.linkedIssueId);
});
it("uses the routine revision responsible-user snapshot for automatic runs", async () => {
const { companyId, agentId, projectId, svc } = await seedFixture();
const responsibleUserId = randomUUID();
const driftUserId = randomUUID();
const routine = await svc.create(
companyId,
{
projectId,
goalId: null,
parentIssueId: null,
title: "snapshotted owner routine",
description: null,
assigneeAgentId: agentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
},
{ userId: responsibleUserId },
);
await db
.update(routines)
.set({ responsibleUserId: driftUserId, updatedAt: new Date() })
.where(eq(routines.id, routine.id));
const run = await svc.runRoutine(routine.id, { source: "schedule" });
expect(run.status).toBe("issue_created");
expect(run.responsibleUserId).toBe(responsibleUserId);
const [createdIssue] = await db
.select({
responsibleUserId: issues.responsibleUserId,
})
.from(issues)
.where(eq(issues.id, run.linkedIssueId!));
expect(createdIssue?.responsibleUserId).toBe(responsibleUserId);
});
it("waits for the assignee wakeup to be queued before returning the routine run", async () => {
let wakeupResolved = false;
const { routine, svc } = await seedFixture({
@@ -420,6 +420,52 @@ describeEmbeddedPostgres("secretService", () => {
responsibleUserId: "user-2",
}),
).rejects.toThrow(/not configured/i);
await expect(
svc.collectMissingRuntimeBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
responsibleUserId: "user-2",
}),
).resolves.toEqual([
expect.objectContaining({
bindingType: "user_secret_ref",
configPath: "env.GITHUB_TOKEN",
envKey: "GITHUB_TOKEN",
userSecretDefinitionId: definition.id,
userSecretDefinitionKey: "github_token",
responsibleUserId: "user-2",
errorCode: "user_secret_missing",
}),
]);
const optionalEnv = {
OPTIONAL_GITHUB_TOKEN: {
type: "user_secret_ref" as const,
key: "github_token",
version: "latest" as const,
required: false,
},
};
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-optional" }, optionalEnv);
await expect(
svc.collectMissingRuntimeBindings(companyId, optionalEnv, {
consumerType: "agent",
consumerId: "agent-optional",
responsibleUserId: "user-2",
}),
).resolves.toEqual([]);
await expect(
svc.resolveEnvBindings(companyId, optionalEnv, {
consumerType: "agent",
consumerId: "agent-optional",
actorType: "agent",
actorId: "agent-optional",
responsibleUserId: "user-2",
}),
).resolves.toMatchObject({
env: {},
manifest: [],
});
const resolved = await svc.resolveEnvBindings(companyId, env, {
consumerType: "agent",
+3
View File
@@ -5229,6 +5229,7 @@ export function issueRoutes(
...(sourceTrust ? { sourceTrust } : {}),
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
actorRunId: actor.runId,
watchdogActorRunId: actor.runId,
});
await issueReferencesSvc.syncIssue(issue.id);
@@ -5397,6 +5398,7 @@ export function issueRoutes(
...(sourceTrust ? { sourceTrust } : {}),
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
actorRunId: actor.runId,
actorAgentId: actor.agentId,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
watchdogActorRunId: actor.runId,
@@ -5562,6 +5564,7 @@ export function issueRoutes(
...(sourceTrust ? { sourceTrust } : {}),
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
actorRunId: actor.runId,
actorAgentId: actor.agentId,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
});
+112 -11
View File
@@ -431,9 +431,19 @@ type RuntimeConfigSecretResolver = Pick<
>;
function formatMissingBindingForOperator(missing: MissingRuntimeBinding): string {
if (missing.bindingType === "user_secret_ref") {
const definitionLabel =
missing.userSecretDefinitionName
? `"${missing.userSecretDefinitionName}"`
: missing.userSecretDefinitionKey
? `"${missing.userSecretDefinitionKey}"`
: "declared user secret";
const ownerLabel = missing.responsibleUserId ? ` for responsible user ${missing.responsibleUserId}` : "";
return `user secret ${definitionLabel}${ownerLabel} not available at ${missing.consumerType} ${missing.configPath}`;
}
const secretLabel = missing.secretName
? `"${missing.secretName}"`
: missing.secretId;
: missing.secretId ?? "unknown";
return `secret ${secretLabel} not bound at ${missing.consumerType} ${missing.configPath}`;
}
@@ -579,7 +589,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
...(await input.secretsSvc.collectMissingRuntimeBindings(
input.companyId,
environmentEnv,
{ consumerType: "environment", consumerId: input.environmentId },
{
consumerType: "environment",
consumerId: input.environmentId,
responsibleUserId: input.responsibleUserId ?? null,
},
)),
);
}
@@ -588,7 +602,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
...(await input.secretsSvc.collectMissingRuntimeBindings(
input.companyId,
parseObject(executionRunConfig.env),
{ consumerType: "agent", consumerId: input.agentId },
{
consumerType: "agent",
consumerId: input.agentId,
responsibleUserId: input.responsibleUserId ?? null,
},
)),
);
if (typeof input.secretsSvc.collectMissingAdapterConfigRuntimeBindings === "function") {
@@ -597,7 +615,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
input.companyId,
executionRunConfig,
input.adapterType ?? null,
{ consumerType: "agent", consumerId: input.agentId },
{
consumerType: "agent",
consumerId: input.agentId,
responsibleUserId: input.responsibleUserId ?? null,
},
)),
);
}
@@ -607,7 +629,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
...(await input.secretsSvc.collectMissingRuntimeBindings(
input.companyId,
projectEnv,
{ consumerType: "project", consumerId: input.projectId },
{
consumerType: "project",
consumerId: input.projectId,
responsibleUserId: input.responsibleUserId ?? null,
},
)),
);
}
@@ -616,7 +642,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
...(await input.secretsSvc.collectMissingRuntimeBindings(
input.companyId,
routineEnv,
{ consumerType: "routine", consumerId: input.routineId },
{
consumerType: "routine",
consumerId: input.routineId,
responsibleUserId: input.responsibleUserId ?? null,
},
)),
);
}
@@ -4498,6 +4528,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
executionPolicy: issues.executionPolicy,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
parentId: issues.parentId,
createdByUserId: issues.createdByUserId,
responsibleUserId: issues.responsibleUserId,
originKind: issues.originKind,
originId: issues.originId,
originRunId: issues.originRunId,
@@ -4513,13 +4546,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
issueContext: Awaited<ReturnType<typeof getIssueExecutionContext>> | null,
) {
if (!issueContext || issueContext.originKind !== "routine_execution" || !issueContext.originId) {
return { routineId: null, env: null };
return { routineId: null, env: null, responsibleUserId: null };
}
const routineRun = issueContext.originRunId
? await db
.select({
routineRevisionId: routineRuns.routineRevisionId,
responsibleUserId: routineRuns.responsibleUserId,
})
.from(routineRuns)
.where(
@@ -4536,6 +4570,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const revision = await db
.select({
snapshot: routineRevisions.snapshot,
responsibleUserId: routineRevisions.responsibleUserId,
})
.from(routineRevisions)
.where(
@@ -4548,16 +4583,50 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
.then((rows) => rows[0] ?? null);
const snapshot = revision?.snapshot as RoutineRevisionSnapshotV1 | undefined;
if (snapshot?.version === 1) {
return { routineId: issueContext.originId, env: snapshot.routine.env ?? null };
return {
routineId: issueContext.originId,
env: snapshot.routine.env ?? null,
responsibleUserId: revision?.responsibleUserId ?? snapshot.routine.responsibleUserId ?? null,
};
}
}
const routine = await db
.select({ env: routines.env })
.select({ env: routines.env, responsibleUserId: routines.responsibleUserId })
.from(routines)
.where(and(eq(routines.id, issueContext.originId), eq(routines.companyId, companyId)))
.then((rows) => rows[0] ?? null);
return { routineId: issueContext.originId, env: routine?.env ?? null };
return {
routineId: issueContext.originId,
env: routine?.env ?? null,
responsibleUserId: routineRun?.responsibleUserId ?? routine?.responsibleUserId ?? null,
};
}
async function resolveResponsibleUserIdForRun(input: {
run: typeof heartbeatRuns.$inferSelect;
contextSnapshot: Record<string, unknown>;
issueContext: Awaited<ReturnType<typeof getIssueExecutionContext>> | null;
routineEnvContext: Awaited<ReturnType<typeof getRoutineEnvForExecutionIssue>>;
}) {
const contextResponsibleUserId = readNonEmptyString(input.contextSnapshot.responsibleUserId);
if (contextResponsibleUserId) return contextResponsibleUserId;
if (input.run.responsibleUserId) return input.run.responsibleUserId;
if (input.routineEnvContext.responsibleUserId) return input.routineEnvContext.responsibleUserId;
if (input.issueContext?.responsibleUserId) return input.issueContext.responsibleUserId;
if (input.issueContext?.createdByUserId) return input.issueContext.createdByUserId;
if (input.issueContext?.parentId) {
const parent = await db
.select({
responsibleUserId: issues.responsibleUserId,
createdByUserId: issues.createdByUserId,
})
.from(issues)
.where(and(eq(issues.companyId, input.run.companyId), eq(issues.id, input.issueContext.parentId)))
.then((rows) => rows[0] ?? null);
return parent?.responsibleUserId ?? parent?.createdByUserId ?? null;
}
return null;
}
async function getRuntimeState(agentId: string) {
@@ -8301,10 +8370,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}
const claimedAt = new Date();
const responsibleUserId = await resolveResponsibleUserIdForRun({
run,
contextSnapshot: context,
issueContext: issueId ? await getIssueExecutionContext(run.companyId, issueId) : null,
routineEnvContext: { routineId: null, env: null, responsibleUserId: null },
});
const claimed = await db
.update(heartbeatRuns)
.set({
status: "running",
responsibleUserId,
startedAt: run.startedAt ?? claimedAt,
updatedAt: claimedAt,
})
@@ -9395,6 +9471,26 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
delete context.acceptedPlanWakeRouting;
}
const routineEnvContext = await getRoutineEnvForExecutionIssue(agent.companyId, issueContext);
const responsibleUserId = await resolveResponsibleUserIdForRun({
run,
contextSnapshot: context,
issueContext,
routineEnvContext,
});
if (responsibleUserId && run.responsibleUserId !== responsibleUserId) {
await db
.update(heartbeatRuns)
.set({ responsibleUserId, updatedAt: new Date() })
.where(eq(heartbeatRuns.id, run.id));
run = { ...run, responsibleUserId };
}
if (responsibleUserId && issueContext && !issueContext.responsibleUserId) {
await db
.update(issues)
.set({ responsibleUserId, updatedAt: new Date() })
.where(and(eq(issues.companyId, agent.companyId), eq(issues.id, issueContext.id), isNull(issues.responsibleUserId)));
issueContext = { ...issueContext, responsibleUserId };
}
const projectExecutionWorkspacePolicy = gateProjectExecutionWorkspacePolicy(
parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy),
isolatedWorkspacesEnabled,
@@ -9703,6 +9799,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
environmentEnv: selectedEnvironmentForConfig?.envVars ?? null,
projectId: projectContext?.id ?? null,
routineId: routineEnvContext.routineId,
responsibleUserId,
executionRunConfig,
projectEnv: projectContext?.env ?? null,
routineEnv: routineEnvContext.env,
@@ -12017,6 +12114,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
if (projectId && !readNonEmptyString(enrichedContextSnapshot.projectId)) {
enrichedContextSnapshot.projectId = projectId;
}
const queuedResponsibleUserId =
readNonEmptyString(enrichedContextSnapshot.responsibleUserId) ??
(opts.requestedByActorType === "user" ? opts.requestedByActorId ?? null : null);
const budgetBlock = await budgets.getInvocationBlock(agent.companyId, agentId, {
issueId,
@@ -12603,6 +12703,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
invocationSource: source,
triggerDetail,
status: "queued",
responsibleUserId: queuedResponsibleUserId,
wakeupRequestId: wakeupRequest.id,
contextSnapshot: enrichedContextSnapshot,
sessionIdBefore: sessionBefore,
@@ -12668,7 +12769,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
Boolean(sameScopeRunningRun) &&
!sameScopeQueuedRun &&
shouldQueueFollowupForRunningIssueWake({ contextSnapshot: enrichedContextSnapshot, wakeCommentId });
const rawCoalescedTarget =
sameScopeQueuedRun ??
sameScopeScheduledRetryRun ??
@@ -12777,6 +12877,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
invocationSource: source,
triggerDetail,
status: "queued",
responsibleUserId: queuedResponsibleUserId,
wakeupRequestId: wakeupRequest.id,
contextSnapshot: enrichedContextSnapshot,
sessionIdBefore: sessionBefore,
+61
View File
@@ -14,6 +14,7 @@ import {
documents,
goals,
heartbeatRuns,
routineRuns,
executionWorkspaces,
issueApprovals,
issueAttachments,
@@ -137,6 +138,55 @@ function readStringFromRecord(record: unknown, key: string) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
async function resolveResponsibleUserIdForIssueCreate(
reader: DbReader,
companyId: string,
input: {
explicitResponsibleUserId?: string | null;
createdByUserId?: string | null;
parentId?: string | null;
originKind?: string | null;
originRunId?: string | null;
actorRunId?: string | null;
},
) {
const explicitResponsibleUserId = readStringFromRecord(input, "explicitResponsibleUserId");
if (explicitResponsibleUserId) return explicitResponsibleUserId;
if (input.originKind === "routine_execution" && input.originRunId) {
const routineRun = await reader
.select({ responsibleUserId: routineRuns.responsibleUserId })
.from(routineRuns)
.where(and(eq(routineRuns.companyId, companyId), eq(routineRuns.id, input.originRunId)))
.then((rows) => rows[0] ?? null);
if (routineRun?.responsibleUserId) return routineRun.responsibleUserId;
}
if (input.actorRunId) {
const actorRun = await reader
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, input.actorRunId)))
.then((rows) => rows[0] ?? null);
if (actorRun?.responsibleUserId) return actorRun.responsibleUserId;
}
if (input.parentId) {
const parent = await reader
.select({
responsibleUserId: issues.responsibleUserId,
createdByUserId: issues.createdByUserId,
})
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.id, input.parentId)))
.then((rows) => rows[0] ?? null);
if (parent?.responsibleUserId) return parent.responsibleUserId;
if (parent?.createdByUserId) return parent.createdByUserId;
}
return input.createdByUserId ?? null;
}
function buildReusedExecutionWorkspaceConfigPatchFromIssueSettings(
settings: ReturnType<typeof parseIssueExecutionWorkspaceSettings>,
) {
@@ -366,6 +416,7 @@ type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
inheritExecutionWorkspaceFromIssueId?: string | null;
watchdog?: { agentId: string; instructions?: string | null } | null;
watchdogActorRunId?: string | null;
actorRunId?: string | null;
};
type IssueChildCreateInput = IssueCreateInput & {
acceptanceCriteria?: string[];
@@ -5014,6 +5065,7 @@ export function issueService(db: Db) {
inheritExecutionWorkspaceFromIssueId,
watchdog,
watchdogActorRunId,
actorRunId,
...issueData
} = data;
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
@@ -5159,9 +5211,18 @@ export function issueService(db: Db) {
const issueNumber = company.issueCounter;
const identifier = `${company.issuePrefix}-${issueNumber}`;
const responsibleUserId = await resolveResponsibleUserIdForIssueCreate(tx, companyId, {
explicitResponsibleUserId: issueData.responsibleUserId ?? null,
createdByUserId: issueData.createdByUserId ?? null,
parentId: issueData.parentId ?? null,
originKind: issueData.originKind ?? "manual",
originRunId: issueData.originRunId ?? null,
actorRunId: actorRunId ?? null,
});
const values = {
...issueData,
responsibleUserId,
requestDepth: clampIssueRequestDepth(issueData.requestDepth),
originKind: issueData.originKind ?? "manual",
goalId: resolveIssueGoalId({
+25
View File
@@ -820,6 +820,7 @@ export function routineService(
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
createdByRunId: actor.runId ?? null,
responsibleUserId: snapshot.routine.responsibleUserId ?? null,
createdAt: now,
})
.returning();
@@ -1137,6 +1138,7 @@ export function routineService(
completedAt: triggeredAt,
linkedIssueId: null,
routineRevisionId: input.routine.latestRevisionId,
responsibleUserId: input.routine.responsibleUserId ?? null,
})
.returning();
await updateRoutineTouchedState({
@@ -1486,6 +1488,26 @@ export function routineService(
const triggeredAt = new Date();
const manualRunnerUserId = input.source === "manual" ? input.actor?.userId ?? null : null;
const latestRevisionResponsibleUserId = input.routine.latestRevisionId
? await txDb
.select({
responsibleUserId: routineRevisions.responsibleUserId,
snapshot: routineRevisions.snapshot,
})
.from(routineRevisions)
.where(and(
eq(routineRevisions.companyId, input.routine.companyId),
eq(routineRevisions.routineId, input.routine.id),
eq(routineRevisions.id, input.routine.latestRevisionId),
))
.then((rows) => {
const row = rows[0] ?? null;
const snapshot = row?.snapshot as RoutineRevisionSnapshotV1 | undefined;
return row?.responsibleUserId ?? snapshot?.routine.responsibleUserId ?? null;
})
: null;
const responsibleUserId =
manualRunnerUserId ?? latestRevisionResponsibleUserId ?? input.routine.responsibleUserId ?? null;
const [createdRun] = await txDb
.insert(routineRuns)
.values({
@@ -1499,6 +1521,7 @@ export function routineService(
triggerPayload,
dispatchFingerprint,
routineRevisionId: input.routine.latestRevisionId,
responsibleUserId,
})
.returning();
@@ -1552,6 +1575,7 @@ export function routineService(
assigneeAgentId,
createdByAgentId: input.source === "manual" ? input.actor?.agentId ?? null : null,
createdByUserId: manualRunnerUserId,
responsibleUserId,
originKind: issueOriginKind,
originId: issueOriginId,
originRunId: createdRun.id,
@@ -1861,6 +1885,7 @@ export function routineService(
catchUpPolicy: input.catchUpPolicy,
variables,
env,
responsibleUserId: actor.userId ?? null,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
updatedByAgentId: actor.agentId ?? null,
+113 -4
View File
@@ -266,8 +266,14 @@ export type MissingRuntimeBinding = {
consumerId: string;
configPath: string;
envKey: string;
secretId: string;
bindingType?: "secret_ref" | "user_secret_ref";
secretId: string | null;
secretName: string | null;
userSecretDefinitionId?: string | null;
userSecretDefinitionKey?: string | null;
userSecretDefinitionName?: string | null;
responsibleUserId?: string | null;
errorCode?: SecretResolutionErrorCode;
};
type RuntimeSecretResolution = {
@@ -2251,6 +2257,7 @@ export function secretService(db: Db) {
throw unprocessable("User secret definition is not active");
}
if (!responsibleUserId?.trim()) {
if (input.allowMissingOverride || input.required === false) return null;
throw unprocessable("Responsible user is required for user secret resolution", {
code: "responsible_user_missing",
});
@@ -3441,7 +3448,16 @@ export function secretService(db: Db) {
if (binding.type !== "secret_ref") return [];
return [{ key, configPath: `env.${key}`, secretId: binding.secretId }];
});
if (secretRefs.length === 0) return [];
const userSecretRefs = Object.entries(record).flatMap(([key, rawBinding]) => {
if (!ENV_KEY_RE.test(key)) return [];
const parsed = envBindingSchema.safeParse(rawBinding);
if (!parsed.success) return [];
const binding = canonicalizeBinding(parsed.data as EnvBinding);
if (binding.type !== "user_secret_ref") return [];
if (!binding.required || binding.allowMissingOverride) return [];
return [{ key, configPath: `env.${key}`, binding }];
});
if (secretRefs.length === 0 && userSecretRefs.length === 0) return [];
const bindingChecks = await Promise.all(secretRefs.map(async (entry) => ({
entry,
@@ -3456,7 +3472,6 @@ export function secretService(db: Db) {
const missingEntries = bindingChecks
.filter((check) => !check.found)
.map((check) => check.entry);
if (missingEntries.length === 0) return [];
const secretRows = await Promise.all(
[...new Set(missingEntries.map((entry) => entry.secretId))].map(async (secretId) => [
@@ -3466,14 +3481,108 @@ export function secretService(db: Db) {
);
const secretsById = new Map(secretRows);
return missingEntries.map((entry) => ({
const missingSecretBindings: MissingRuntimeBinding[] = missingEntries.map((entry) => ({
consumerType: context.consumerType,
consumerId: context.consumerId,
configPath: entry.configPath,
envKey: entry.key,
bindingType: "secret_ref",
secretId: entry.secretId,
secretName: secretsById.get(entry.secretId)?.name ?? null,
}));
const missingUserSecretBindings: MissingRuntimeBinding[] = [];
for (const entry of userSecretRefs) {
let definition: typeof userSecretDefinitions.$inferSelect | null = null;
try {
definition = await resolveUserSecretDefinition(companyId, { definitionKey: entry.binding.key });
} catch {
missingUserSecretBindings.push({
consumerType: context.consumerType,
consumerId: context.consumerId,
configPath: entry.configPath,
envKey: entry.key,
bindingType: "user_secret_ref",
secretId: null,
secretName: null,
userSecretDefinitionKey: entry.binding.key,
responsibleUserId: context.responsibleUserId ?? null,
errorCode: "user_secret_definition_missing",
});
continue;
}
const declaration = await db
.select()
.from(userSecretDeclarations)
.where(and(
eq(userSecretDeclarations.companyId, companyId),
eq(userSecretDeclarations.userSecretDefinitionId, definition.id),
eq(userSecretDeclarations.targetType, context.consumerType),
eq(userSecretDeclarations.targetId, context.consumerId),
eq(userSecretDeclarations.configPath, entry.configPath),
))
.then((rows) => rows[0] ?? null);
if (!declaration) {
missingUserSecretBindings.push({
consumerType: context.consumerType,
consumerId: context.consumerId,
configPath: entry.configPath,
envKey: entry.key,
bindingType: "user_secret_ref",
secretId: null,
secretName: null,
userSecretDefinitionId: definition.id,
userSecretDefinitionKey: definition.key,
userSecretDefinitionName: definition.name,
responsibleUserId: context.responsibleUserId ?? null,
errorCode: "binding_missing",
});
continue;
}
if (!context.responsibleUserId?.trim()) {
missingUserSecretBindings.push({
consumerType: context.consumerType,
consumerId: context.consumerId,
configPath: entry.configPath,
envKey: entry.key,
bindingType: "user_secret_ref",
secretId: null,
secretName: null,
userSecretDefinitionId: definition.id,
userSecretDefinitionKey: definition.key,
userSecretDefinitionName: definition.name,
responsibleUserId: null,
errorCode: "responsible_user_missing",
});
continue;
}
const secret = await getUserSecretValue({
companyId,
ownerUserId: context.responsibleUserId,
definitionId: definition.id,
});
if (!secret || secret.status !== "active") {
missingUserSecretBindings.push({
consumerType: context.consumerType,
consumerId: context.consumerId,
configPath: entry.configPath,
envKey: entry.key,
bindingType: "user_secret_ref",
secretId: secret?.id ?? null,
secretName: null,
userSecretDefinitionId: definition.id,
userSecretDefinitionKey: definition.key,
userSecretDefinitionName: definition.name,
responsibleUserId: context.responsibleUserId,
errorCode: secret ? "secret_inactive" : "user_secret_missing",
});
}
}
return [...missingSecretBindings, ...missingUserSecretBindings];
},
collectMissingAdapterConfigRuntimeBindings: async (