@agent-native/dispatch 0.14.2 → 0.14.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/actions/approve-dispatch-change.d.ts +10 -10
- package/dist/actions/provider-api-register.js +64 -2
- package/dist/actions/provider-api-register.js.map +1 -1
- package/dist/actions/reject-dispatch-change.d.ts +10 -10
- package/dist/server/lib/dispatch-store.d.ts +24 -21
- package/dist/server/lib/dispatch-store.d.ts.map +1 -1
- package/dist/server/lib/dispatch-store.js +59 -23
- package/dist/server/lib/dispatch-store.js.map +1 -1
- package/dist/server/lib/vault-store.d.ts.map +1 -1
- package/dist/server/lib/vault-store.js +102 -37
- package/dist/server/lib/vault-store.js.map +1 -1
- package/package.json +2 -3
- package/src/actions/provider-api-register.spec.ts +193 -0
- package/src/actions/provider-api-register.ts +66 -1
- package/src/server/lib/approval-fencing.spec.ts +309 -0
- package/src/server/lib/dispatch-store.ts +92 -21
- package/src/server/lib/vault-store.ts +151 -45
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
|
|
3
|
-
import { and, desc, eq, isNull, or } from "@agent-native/core/db/schema";
|
|
3
|
+
import { and, desc, eq, isNull, or, sql } from "@agent-native/core/db/schema";
|
|
4
4
|
import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";
|
|
5
5
|
import {
|
|
6
6
|
deleteAppSecret,
|
|
@@ -567,8 +567,59 @@ export async function createGrant(
|
|
|
567
567
|
if (!secret) throw new Error("Secret not found");
|
|
568
568
|
|
|
569
569
|
const timestamp = now();
|
|
570
|
-
const grantId = id();
|
|
571
570
|
const actor = ctx.ownerEmail;
|
|
571
|
+
const [existing] = await db
|
|
572
|
+
.select()
|
|
573
|
+
.from(schema.vaultGrants)
|
|
574
|
+
.where(
|
|
575
|
+
and(
|
|
576
|
+
eq(schema.vaultGrants.secretId, secretId),
|
|
577
|
+
eq(schema.vaultGrants.appId, appId),
|
|
578
|
+
ctxScope(schema.vaultGrants, ctx),
|
|
579
|
+
),
|
|
580
|
+
)
|
|
581
|
+
.orderBy(desc(schema.vaultGrants.updatedAt))
|
|
582
|
+
.limit(1);
|
|
583
|
+
|
|
584
|
+
if (existing?.status === "active") {
|
|
585
|
+
return existing;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (existing) {
|
|
589
|
+
await db
|
|
590
|
+
.update(schema.vaultGrants)
|
|
591
|
+
.set({
|
|
592
|
+
grantedBy: actor,
|
|
593
|
+
status: "active",
|
|
594
|
+
syncedAt: null,
|
|
595
|
+
updatedAt: timestamp,
|
|
596
|
+
})
|
|
597
|
+
.where(
|
|
598
|
+
and(
|
|
599
|
+
eq(schema.vaultGrants.id, existing.id),
|
|
600
|
+
ctxScope(schema.vaultGrants, ctx),
|
|
601
|
+
),
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
await recordVaultAudit({
|
|
605
|
+
action: "grant.reinstated",
|
|
606
|
+
secretId,
|
|
607
|
+
appId,
|
|
608
|
+
summary: `Reinstated "${secret.name}" (${secret.credentialKey}) for ${appId}`,
|
|
609
|
+
metadata: { grantId: existing.id },
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
await recordAudit({
|
|
613
|
+
action: "vault.grant.reinstated",
|
|
614
|
+
targetType: "vault-grant",
|
|
615
|
+
targetId: existing.id,
|
|
616
|
+
summary: `Reinstated vault secret "${secret.name}" for ${appId}`,
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
return getGrant(existing.id, ctx);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const grantId = id();
|
|
572
623
|
|
|
573
624
|
await db.insert(schema.vaultGrants).values({
|
|
574
625
|
id: grantId,
|
|
@@ -956,65 +1007,115 @@ export async function approveRequest(
|
|
|
956
1007
|
const db = getDb();
|
|
957
1008
|
const request = await getRequest(requestId, ctx);
|
|
958
1009
|
if (!request) throw new Error("Request not found");
|
|
959
|
-
if (request.status !== "pending") {
|
|
960
|
-
throw new Error("Only pending requests can be approved");
|
|
961
|
-
}
|
|
962
1010
|
|
|
963
1011
|
const timestamp = now();
|
|
964
1012
|
const reviewer = ctx.ownerEmail;
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1013
|
+
const staleApplyingBefore = timestamp - 5 * 60 * 1000;
|
|
1014
|
+
|
|
1015
|
+
// Fence the transition on the current status — scoped to caller's tenant —
|
|
1016
|
+
// so a concurrent approve can't both win: only the caller that flips
|
|
1017
|
+
// pending -> applying proceeds to create the secret/grant. A crashed worker
|
|
1018
|
+
// leaves an applying row behind, so a later reviewer may reclaim a lease
|
|
1019
|
+
// that has been idle for five minutes. See
|
|
1020
|
+
// claimAgentTeamRun in packages/core/src/server/agent-teams-run-queue.ts
|
|
1021
|
+
// for the same pattern.
|
|
1022
|
+
const claimed = await db
|
|
968
1023
|
.update(schema.vaultRequests)
|
|
969
1024
|
.set({
|
|
970
|
-
status: "
|
|
971
|
-
reviewedBy: reviewer,
|
|
972
|
-
reviewedAt: timestamp,
|
|
1025
|
+
status: "applying",
|
|
973
1026
|
updatedAt: timestamp,
|
|
974
1027
|
})
|
|
975
1028
|
.where(
|
|
976
1029
|
and(
|
|
977
1030
|
eq(schema.vaultRequests.id, requestId),
|
|
978
1031
|
ctxScope(schema.vaultRequests, ctx),
|
|
1032
|
+
or(
|
|
1033
|
+
eq(schema.vaultRequests.status, "pending"),
|
|
1034
|
+
and(
|
|
1035
|
+
eq(schema.vaultRequests.status, "applying"),
|
|
1036
|
+
sql`${schema.vaultRequests.updatedAt} < ${staleApplyingBefore}`,
|
|
1037
|
+
),
|
|
1038
|
+
),
|
|
979
1039
|
),
|
|
980
|
-
)
|
|
1040
|
+
)
|
|
1041
|
+
.returning();
|
|
1042
|
+
|
|
1043
|
+
if (claimed.length === 0) {
|
|
1044
|
+
const current = await getRequest(requestId, ctx);
|
|
1045
|
+
if (current?.status === "applying") {
|
|
1046
|
+
throw new Error("Vault request is already being applied");
|
|
1047
|
+
}
|
|
1048
|
+
return current;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const claimedRequest = claimed[0];
|
|
981
1052
|
|
|
982
1053
|
// Secret + grant must land in the REQUEST's tenant, not the approver's
|
|
983
1054
|
// (the approver may be acting on behalf of another user in the same org).
|
|
984
|
-
const requestCtx = ctxForRow(
|
|
1055
|
+
const requestCtx = ctxForRow(claimedRequest);
|
|
985
1056
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1057
|
+
try {
|
|
1058
|
+
// Check if secret already exists in the request's tenant for this key.
|
|
1059
|
+
const existingSecrets = await db
|
|
1060
|
+
.select()
|
|
1061
|
+
.from(schema.vaultSecrets)
|
|
1062
|
+
.where(
|
|
1063
|
+
and(
|
|
1064
|
+
eq(schema.vaultSecrets.credentialKey, claimedRequest.credentialKey),
|
|
1065
|
+
ctxScope(schema.vaultSecrets, requestCtx),
|
|
1066
|
+
),
|
|
1067
|
+
);
|
|
1068
|
+
let secret = existingSecrets[0] ?? null;
|
|
1069
|
+
|
|
1070
|
+
if (!secret) {
|
|
1071
|
+
secret = await createSecret(
|
|
1072
|
+
{
|
|
1073
|
+
credentialKey: claimedRequest.credentialKey,
|
|
1074
|
+
value: secretValue,
|
|
1075
|
+
name: secretName || claimedRequest.credentialKey,
|
|
1076
|
+
},
|
|
1077
|
+
requestCtx,
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (secret) {
|
|
1082
|
+
// Create the grant in the request's tenant as well.
|
|
1083
|
+
await createGrant(secret.id, claimedRequest.appId, requestCtx);
|
|
1084
|
+
}
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
await db
|
|
1087
|
+
.update(schema.vaultRequests)
|
|
1088
|
+
.set({ status: "pending", updatedAt: now() })
|
|
1089
|
+
.where(
|
|
1090
|
+
and(
|
|
1091
|
+
eq(schema.vaultRequests.id, requestId),
|
|
1092
|
+
ctxScope(schema.vaultRequests, ctx),
|
|
1093
|
+
eq(schema.vaultRequests.status, "applying"),
|
|
1094
|
+
),
|
|
1095
|
+
);
|
|
1096
|
+
throw error;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
await db
|
|
1100
|
+
.update(schema.vaultRequests)
|
|
1101
|
+
.set({
|
|
1102
|
+
status: "approved",
|
|
1103
|
+
reviewedBy: reviewer,
|
|
1104
|
+
reviewedAt: timestamp,
|
|
1105
|
+
updatedAt: now(),
|
|
1106
|
+
})
|
|
990
1107
|
.where(
|
|
991
1108
|
and(
|
|
992
|
-
eq(schema.
|
|
993
|
-
ctxScope(schema.
|
|
1109
|
+
eq(schema.vaultRequests.id, requestId),
|
|
1110
|
+
ctxScope(schema.vaultRequests, ctx),
|
|
1111
|
+
eq(schema.vaultRequests.status, "applying"),
|
|
994
1112
|
),
|
|
995
1113
|
);
|
|
996
|
-
let secret = existingSecrets[0] ?? null;
|
|
997
|
-
|
|
998
|
-
if (!secret) {
|
|
999
|
-
secret = await createSecret(
|
|
1000
|
-
{
|
|
1001
|
-
credentialKey: request.credentialKey,
|
|
1002
|
-
value: secretValue,
|
|
1003
|
-
name: secretName || request.credentialKey,
|
|
1004
|
-
},
|
|
1005
|
-
requestCtx,
|
|
1006
|
-
);
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
if (secret) {
|
|
1010
|
-
// Create the grant in the request's tenant as well.
|
|
1011
|
-
await createGrant(secret.id, request.appId, requestCtx);
|
|
1012
|
-
}
|
|
1013
1114
|
|
|
1014
1115
|
await recordVaultAudit({
|
|
1015
1116
|
action: "request.approved",
|
|
1016
|
-
appId:
|
|
1017
|
-
summary: `Approved ${
|
|
1117
|
+
appId: claimedRequest.appId,
|
|
1118
|
+
summary: `Approved ${claimedRequest.credentialKey} for ${claimedRequest.appId} (requested by ${claimedRequest.requestedBy})`,
|
|
1018
1119
|
metadata: { requestId, reviewer },
|
|
1019
1120
|
});
|
|
1020
1121
|
|
|
@@ -1029,14 +1130,11 @@ export async function denyRequest(
|
|
|
1029
1130
|
const db = getDb();
|
|
1030
1131
|
const request = await getRequest(requestId, ctx);
|
|
1031
1132
|
if (!request) throw new Error("Request not found");
|
|
1032
|
-
if (request.status !== "pending") {
|
|
1033
|
-
throw new Error("Only pending requests can be denied");
|
|
1034
|
-
}
|
|
1035
1133
|
|
|
1036
1134
|
const timestamp = now();
|
|
1037
1135
|
const reviewer = ctx.ownerEmail;
|
|
1038
1136
|
|
|
1039
|
-
await db
|
|
1137
|
+
const claimed = await db
|
|
1040
1138
|
.update(schema.vaultRequests)
|
|
1041
1139
|
.set({
|
|
1042
1140
|
status: "denied",
|
|
@@ -1048,13 +1146,21 @@ export async function denyRequest(
|
|
|
1048
1146
|
and(
|
|
1049
1147
|
eq(schema.vaultRequests.id, requestId),
|
|
1050
1148
|
ctxScope(schema.vaultRequests, ctx),
|
|
1149
|
+
eq(schema.vaultRequests.status, "pending"),
|
|
1051
1150
|
),
|
|
1052
|
-
)
|
|
1151
|
+
)
|
|
1152
|
+
.returning();
|
|
1153
|
+
|
|
1154
|
+
if (claimed.length === 0) {
|
|
1155
|
+
return getRequest(requestId, ctx);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const claimedRequest = claimed[0];
|
|
1053
1159
|
|
|
1054
1160
|
await recordVaultAudit({
|
|
1055
1161
|
action: "request.denied",
|
|
1056
|
-
appId:
|
|
1057
|
-
summary: `Denied ${
|
|
1162
|
+
appId: claimedRequest.appId,
|
|
1163
|
+
summary: `Denied ${claimedRequest.credentialKey} for ${claimedRequest.appId} (requested by ${claimedRequest.requestedBy})`,
|
|
1058
1164
|
metadata: { requestId, reviewer, reason },
|
|
1059
1165
|
});
|
|
1060
1166
|
|