Repository navigation
feat(agent-vault): add Agent Vault proxies, sessions, access bundles and delegations - #46
Conversation
A POST that creates something, such as a session, can't be retried after a network error once the request may have reached the server: the retry could create a second one. postOnce retries a network error only if the connection never opened (a failed DNS lookup, a refused connection, or a connect timeout), and still retries a rate-limited response. Other requests retry as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and delegations Adds sdk.agentVault() with proxies, sessions, accessBundles and delegations, at parity with the Python SDK as of Infisical/python-sdk-official#66, including sessions on behalf of users and proxy descriptions. Creates and proxy token rotations use postOnce. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nstance Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
💬 Discussion in Slack: #pr-review-node-sdk-v2-46-feat-agent-vault-add-agent-vault-proxies-sessions-acces Posted by Review Police — reviews, comments, new commits, and CI failures will stream into this channel. |
[Critical impact] The latest changes appear safe to merge; no new actionable issues were found.
|
…the request timeout With the SDK's 10-second request timeout, a stalled connection ends in an AbortError before undici's connect timeout fires, so UND_ERR_CONNECT_TIMEOUT rarely shows up and the stalled create isn't retried. An abort can't be retried safely, since it looks the same whether or not the request was sent, so the comments now say that instead of claiming connect timeouts are retried. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…up can't remove The live-check script now records each resource as soon as its request resolves, before checking the response, so a failed check no longer leaks the proxy, access bundle, delegation or session. Cleanup reports each removal it couldn't make, with the resource ID, and a failed request names its step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…with 422 Infisical's validation errors (422) list their issues in message as an array rather than a string, so the SDK threw a generic error without them. The error message is now the whole response body, so it says what the server refused, such as a session with more than 20 inline services. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Infisical renamed the bundle-hosts traffic policy to service-hosts and added inline services, which are services sent with a session instead of added to an access bundle (Infisical/infisical#9064). - AgentVaultTrafficPolicy.BundleHosts is now ServiceHosts. The SDK sends it as bundle-hosts, which every Infisical version accepts, and reports bundle-hosts from a version before the rename as ServiceHosts. - sessions.create takes services, and accessBundle is optional. Sessions return their inline services without secrets, and a session from a version without inline services gets an empty list. - Services in access bundles have allowedTools, and grantees have isOrgMembershipPending. - The live-check script covers service hosts and inline services. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…of its tools Infisical refuses a non-empty allowedTools on a service sent with a session, so the JSDoc of the services option now lists that limit with the others, and says to add the MCP server to an access bundle to limit its tools. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
||
| const BASE_PATH = "/api/v1/agent-vault"; | ||
|
|
||
| const segment = (id: string) => encodeURIComponent(id); |
There was a problem hiding this comment.
encodeURIComponent("..") stays .., so fetch resolves it and accessBundles.revoke("..", { userIds: [u] }) becomes DELETE /api/v1/agent-vault/members, which removes u from agent vault and every bundle, and the reply has the same { members, skipped } shape. matters for any bot or tool that passes user input as an id. can we have segment throw for ., .. and empty?
const segment = (id: string) => {
if (!id || id === "." || id === "..") throw new InfisicalSDKError(`Invalid ID "${id}"`);
return encodeURIComponent(id);
};There was a problem hiding this comment.
Good catch, fixed in af882be. segment now throws an InfisicalSDKError for an empty ID, . and .. before any request, as you suggested. I confirmed that .. resolved to DELETE /api/v1/agent-vault/members with the same body and reply shape, so a revoke would have removed the user from Agent Vault without any sign. An ID like %2e%2e stays one segment, since encodeURIComponent turns % into %25, and a test covers it.
The Python SDK had the same problem, and worse, since it didn't encode IDs at all, so a/../.. got there too. Infisical/python-sdk-official@1540d7d encodes every ID and refuses the same values.
| dynamicSecrets = () => this.dynamicSecretsClient; | ||
| auth = () => this.authClient; | ||
| kms = () => this.kmsClient; | ||
| agentVault = () => this.agentVaultClient; |
There was a problem hiding this comment.
the docs name InfisicalSDKError and InfisicalSDKRequestError, but neither is exported from the package root (the exports at the bottom of this file), so import { InfisicalSDKRequestError } from "@infisical/sdk" fails. can we export both and add a statusCode on the request error? then the delegations.get line in the docs can say statusCode 404 instead of matching [StatusCode=404] in the message.
There was a problem hiding this comment.
Done in af882be. Both classes are exported from the package root, and InfisicalSDKRequestError has statusCode. The message format is unchanged, so code that matches [StatusCode=404] keeps working. The docs now say that delegations.get throws one whose statusCode is 404 (Infisical/infisical@d70053dd8a).
| grant: (accessBundleId: string, data: ChangeAgentVaultAccessBundleGrantsRequest) => | ||
| this.apiClient.post<GrantAgentVaultAccessBundleResponse>( | ||
| `${BASE_PATH}/access-bundles/${segment(accessBundleId)}/members`, | ||
| data | ||
| ), | ||
|
|
||
| revoke: (accessBundleId: string, data: ChangeAgentVaultAccessBundleGrantsRequest) => | ||
| this.apiClient.delete<RevokeAgentVaultAccessBundleResponse>( | ||
| `${BASE_PATH}/access-bundles/${segment(accessBundleId)}/members`, | ||
| { data } | ||
| ) |
There was a problem hiding this comment.
grant and revoke go through the retrying post/delete, so if the first call lands and the response is lost, the retry returns { members: [], skipped: [u] } and the caller thinks u already had it. can these not retry once the request may have been sent, like the creates? revoke is a DELETE, so it'd need a no-retry delete next to postOnce.
There was a problem hiding this comment.
Agreed, fixed in af882be. grant uses postOnce, and revoke uses a new ApiClient.deleteOnce that retries in the same cases. Against a local server that drops the connection after reading the request, each now sends one request, where proxies.update sends five.
I left the other deletes retrying. After a lost response, their retry can fail with a 404, which is an error rather than a wrong answer, and addSubjects returns the whole delegation, so a retry reports the right state. The Python SDK got the same change in Infisical/python-sdk-official@1540d7d.
There was a problem hiding this comment.
Follow-up: on the Python SDK, Sheen pointed out that a 404 for a delete that worked is a wrong answer too, so I've changed my mind on the deletes. proxies.delete, accessBundles.delete, delegations.delete and delegations.removeSubjects now use deleteOnce as well, in d0754d0.
| @@ -26,12 +26,11 @@ export class InfisicalSDKRequestError extends Error { | |||
| export const newInfisicalError = (error: any) => { | |||
There was a problem hiding this comment.
at the end of this function (return new InfisicalSDKError(error?.message || "An error occurred")), network errors lose the original error, so a create that timed out and one that was refused only differ by message ("This operation was aborted" vs "fetch failed"). with postOnce the caller now has to decide whether to retry, so it'd help to keep cause and say it timed out:
return new InfisicalSDKError(isTimeout ? "Request timed out after 10s" : error?.message || "An error occurred", { cause: error });openai-node does the same (APIConnectionError with cause, and "Request timed out.").
There was a problem hiding this comment.
Done in af882be, with one change: the timeout isn't always 10 seconds, since the AWS auth client uses 5, so the message isn't hardcoded in newInfisicalError. Instead, the request timeout aborts with a TimeoutError that says how long it waited, such as "The request timed out after 10 seconds". Every error now keeps the one it came from in cause, so a caller can tell a timeout (cause.name === "TimeoutError") from a connection that was refused or reset (cause.cause.code). An error the SDK raises itself, such as for passing both an email and an ID, is no longer wrapped a second time.
| // connection, or undici's connect timeout. The connect timeout shows up only if it fires before the request | ||
| // timeout. With the SDK's 10-second request timeout, a stalled connection ends in an AbortError instead, which | ||
| // can't be retried safely, since an abort looks the same whether or not the request was sent. | ||
| const CONNECT_ERROR_CODES = new Set([ |
There was a problem hiding this comment.
EHOSTUNREACH and ENETUNREACH also fail before anything is sent, so postOnce could retry them too. they're in the aws sdk's network error list next to ENOTFOUND and EAI_AGAIN:
"EHOSTUNREACH", "ENETUNREACH",There was a problem hiding this comment.
Added both in af882be, and updated the comment above the list.
| description?: string; | ||
| }; | ||
|
|
||
| export type ListAgentVaultGranteesOptions = { |
There was a problem hiding this comment.
the backend takes actorType on both /members and /members/available, but listGrantees and listAvailableGrantees don't expose it, so getting just the groups means paging through everything and filtering locally. can we add actorType?: AgentVaultMemberType to the options, like listAvailableSubjects has?
There was a problem hiding this comment.
Added in af882be. Both methods take actorType with all three member types, and the docs list it. Checked against a local instance: each value returns only that kind of grantee, and the available list leaves out existing grantees. The Python SDK's list_grantees and list_available_grantees got actor_type too.
| export type AgentVaultGrantee = ( | ||
| | (AgentVaultUser & { | ||
| /** Whether the user still has an unaccepted invitation to the organization. */ | ||
| isOrgMembershipPending?: boolean; |
There was a problem hiding this comment.
isOrgMembershipPending is optional here, but the grantee schema on main always returns it (z.boolean()), and AgentVaultMember below already has it required. can we drop the ??
There was a problem hiding this comment.
Done in af882be. AgentVaultGrantee is only used by the grantee list, whose schema always returns it.
…filters
- An empty ID, "." or ".." is refused before any request. fetch resolves "."
and ".." like directories, so accessBundles.revoke("..") reached the route
that removes members from Agent Vault, which takes the same body.
- accessBundles.grant uses postOnce, and revoke a new ApiClient.deleteOnce, so
neither is retried once the request may have reached the server. Their
responses say which grantees changed, so after a lost response, a retry
would report each one as skipped.
- InfisicalSDKError and InfisicalSDKRequestError are exported from the package
root, and InfisicalSDKRequestError has statusCode.
- Every error keeps the one it came from in cause, and a request that times
out throws a TimeoutError that says how long it waited, instead of "This
operation was aborted". An error the SDK raises itself isn't wrapped again.
- postOnce also retries EHOSTUNREACH and ENETUNREACH, which are raised before
a connection opens.
- listGrantees and listAvailableGrantees take actorType.
- isOrgMembershipPending is required on grantees, as the API always returns it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r grantees by type From the review of the same methods in Infisical/node-sdk-v2#46: - Each ID goes into the path as one encoded segment, and an empty ID, ".", ".." or None raises a ValueError before any request. Before, an ID such as ".." or "a/../.." reached the route that removes members from Agent Vault, which takes the same body as access_bundles.revoke. - access_bundles.grant uses post_once, and revoke a new delete_once, so neither is retried once the request may have reached the server. Their responses say which grantees changed, so after a lost response, a retry would report each one as skipped. - list_grantees and list_available_grantees take actor_type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Matches the session API change in Infisical/infisical#9099 and the Python SDK's Infisical/python-sdk-official#67. sessions.create sends accessBundle, and sessions have accessBundle ({ id, name }, or null for a session with only inline services) instead of an accessBundles list. id is null once the bundle is deleted, and position is gone. sessions.revoke leaves out accessBundle and services, as the API does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The error constructors took ErrorOptions, which exists only in TypeScript's
ES2022 library, so a project that compiles the SDK's types against an older
library, with skipLibCheck off, failed with "Cannot find name
'ErrorOptions'". They take { cause?: unknown } instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| ); | ||
| } else if (error.message) { | ||
| return new InfisicalSDKError(error.message); | ||
| return new InfisicalSDKError(error.message, { cause: error }); |
There was a problem hiding this comment.
with cause on every error, an uncaught one now prints the whole response body. a 502 from nginx dumped a ~150KB html page into the terminal, where before it was one line. options:
- make
responseonFetchHttpErrornon-enumerable, soerr.cause.response.datastill works but doesn't print - leave it, since it only hits uncaught errors with non-json bodies
leaning the first, your call
There was a problem hiding this comment.
Agreed, went with the first option in 85a945f. FetchHttpError.response is no longer enumerable, so err.cause.response.data still returns the body, but printing or logging the error leaves it out. With a 153KB HTML 502 from a local server, an uncaught error now prints 896 characters, down from about 11,000 (Node cuts long strings at 10,000), and a logger that records every property stores 148 characters, down from the whole page. A test covers both: the body is out of util.inspect and still in cause.response.data.
Since errors keep the one they came from in cause, an HTTP error's cause holds the whole response, so an uncaught 502 printed about 10KB of an nginx HTML page, and a logger that records every property stored all of it. FetchHttpError.response is now not enumerable: err.cause.response.data still returns the body, and printing or logging the error leaves it out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…change Taking users out of a delegation or deleting it doesn't revoke the sessions created on their behalf: the proxy refuses them until a delegation covers the users again. The delete and removeSubjects JSDoc now says so, and that revoking the sessions ends them for good. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
proxies.delete, accessBundles.delete, delegations.delete and delegations.removeSubjects use deleteOnce, like grants and revokes. After a lost response, a retry got a 404 for a delete that worked, and removing the last user or group threw instead of returning null. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Context
This adds Agent Vault to the Node.js SDK, at parity with the Python SDK as of Infisical/python-sdk-official#66.
sdk.agentVault()has four groups of methods:proxies):list,create,update,delete,rotateTokenandrevoke.createandupdatetakedescription, andupdateremoves it when you pass an empty string. An emptyallowedHostsarray or string removes every allowed host onupdate, since the API rejects an empty host pattern, and is left out oncreate.sessions):list,get,create,updateandrevoke.createtakesonBehalfOfUserEmailoronBehalfOfUserIdto start a session on behalf of a user. Passing both throws anInfisicalSDKErrorbefore any request. An empty string is sent, not dropped, so the server rejects it instead of the SDK quietly creating the session for the machine identity.ttlis left out unless you pass it, so the server picks the default: 7d, or 1h for a session on behalf of a user.updatealways sendslabel, andnullremoves it.accessBundles):list,get,create,updateanddelete, pluslistGrantees,listAvailableGrantees,grantandrevokefor the API's/membersroutes.listGranteesandlistAvailableGranteestakeactorType, to list only users, machine identities or groups.listtakesonBehalfOfUserEmailoronBehalfOfUserId, andupdateremoves the description when you pass an empty string. The SDK doesn't manage services, so a service's nested settings, such as its credential, are typed loosely, as the API returns them.delegations):list,get,set,delete,addSubjects,removeSubjectsandlistAvailableSubjects.removeSubjectsreturnsnullonce it takes out the last user or group, since the API then removes the delegation.Service hosts and inline services (from Infisical/infisical#9064)
AgentVaultTrafficPolicyhasAnyHostandServiceHosts, the new name ofbundle-hosts. The SDK sendsServiceHostsasbundle-hosts, which every Infisical version accepts, and reportsbundle-hostsfrom a version before the rename asServiceHosts. There's noBundleHostsmember, since this SDK has never shipped Agent Vault.sessions.createtakesservices, the inline services to send with the session, andaccessBundleis now optional. Each service is typed: an HTTP API with a bearer, basic or passthrough credential, or an MCP server with a bearer credential. Sessions return their inline services without secrets, and a session from a version without inline services gets an empty list.sessions.revokeleaves them out, as the API does.allowedTools, and user grantees haveisOrgMembershipPending, both of which the API already returns.One access bundle per session (from Infisical/infisical#9099, as in Infisical/python-sdk-official#67):
sessions.createsendsaccessBundle, and sessions haveaccessBundleinstead of anaccessBundleslist:{ id, name }, ornullfor a session with only inline services.idis null once the bundle is deleted.sessions.revokeleaves it out, as the API does.Validation errors: a 422 lists its issues in
messageas an array, so for every method the SDK used to throw a generic error without them. The error message is now the whole response body, so it says what the server refused.Errors:
InfisicalSDKErrorandInfisicalSDKRequestErrorare exported from the package root, andInfisicalSDKRequestErrorhasstatusCode. Every error keeps the one it came from incause, a request that times out throws aTimeoutErrorthat says how long it waited, and an error the SDK raises itself isn't wrapped again. An HTTP error's response stays readable ascause.response, but it isn't enumerable, so printing or logging an error leaves out the body. These reach every method of the SDK, not only Agent Vault.IDs: an empty ID,
.or..is refused before any request. fetch resolves.and..like directories, soaccessBundles.revoke("..")reached the route that removes members from Agent Vault, which takes the same body.The enums
AgentVaultTrafficPolicy,AgentVaultSessionStatus,AgentVaultSessionScope,AgentVaultMemberTypeandAgentVaultDelegationScopeare exported from the package root. Like the Python SDK, each method takes an enum member or its string value.Retries for creates, grants, revokes and deletes: creating a proxy, a session or an access bundle, rotating a proxy token, granting or revoking an access bundle, deleting a proxy, an access bundle or a delegation, and removing a delegation's users and groups use a new
ApiClient.postOnceorApiClient.deleteOnce, the counterparts of the Python SDK'spost_onceanddelete_once. Grants and revokes say which grantees changed, so after a lost response, a retry would report each one as skipped, and a retried delete would get a 404 for a delete that worked. These methods retry a network error only if the connection never opened: a failed DNS lookup (ENOTFOUND,EAI_AGAIN), a refused connection (ECONNREFUSED), or an unreachable host or network (EHOSTUNREACH,ENETUNREACH). It doesn't retry a request timeout or a dropped connection, since the server may already have handled the request. That includes a connection that stalls before it opens: the SDK's 10-second request timeout fires before undici's connect timeout, and the timeout looks the same whether or not the request was sent.UND_ERR_CONNECT_TIMEOUTstays in the list for the rare case where it does show up. It still retries a 429, as every request does. Other requests retry as before.For the reviewer
sessions.revokereturns the session withoutaccessBundleandservices, and a listed delegation has nomissingAccessBundles.[StatusCode=...]message format stays.services, since zod strips unknown keys. If the request also names an access bundle, that version creates the session with only the bundle, and the SDK doesn't detect it.ServiceHostsworks on every version, since the SDK sends it asbundle-hosts. A version without improvement(agent-vault): sessions take and return one accessBundle infisical#9099 doesn't readaccessBundle, so merge this after #9099 is deployed to Cloud, as with feat(agent-vault)!: sessions carry one accessBundle python-sdk-official#67.fetchWithRetrycall inApiClient.requestand add tests at the end ofsink/fetch-base.test.ts. Whichever merges second keeps fix: enforce timeouts while reading response bodies #44's structure and passesretryOptionstofetchWithRetry.tsconfig.jsonsetsremoveComments: true, so the published.d.tshas none of the new doc comments. Turning it off would publish them for every module, so it's left for a separate change.Steps to verify the change
npm ci, thennpm testandnpm run build.main, and set the environment variables listed at the top ofsink/test-agent-vault.ts: a machine identity with the Admin role in Agent Vault, a machine identity that's a member of Agent Vault without a delegation, and a user who's a member of Agent Vault. Runnpx tsx sink/test-agent-vault.ts. Following the steps in feat(agent-vault): sessions on behalf of users, access bundles, delegations and proxy descriptions python-sdk-official#66, it creates an access bundle, grants it to the machine identity and the user, sets a delegation, and creates a session on behalf of the user as the machine identity. On the way it calls every method once, and it deletes what it creates.Testing done
main, and the other new tests port the Python SDK's tests. With each of 24 rules broken on purpose, such as a create that's retried, a dropped empty email, or a7ddefault forttl, only the tests written for that rule fail. For the review changes, each of 14 deliberate breaks fails a test.Content-Typeon a POST that takes none.postOnceretries match what Node 24'sfetchreports for a failed DNS lookup and a refused connection. A connection that drops after the request is sent reportsUND_ERR_SOCKET, which isn't retried. A create sent to an address that drops packets ends after one attempt, when the SDK's 10-second timeout fires.mainwith feat(agent-vault): inline services on sessions infisical#9140 (inline services) and improvement(agent-vault): sessions take and return one accessBundle infisical#9099 (one access bundle),sink/test-agent-vault.tspassed all 38 checks on the final code, including service hosts, inline services, andaccessBundleon sessions with and without one. 22 extra checks also passed, including two that failed before the validation-error fix, and a shape check of 22 responses matched the SDK's types. 16 more checks covered the review changes:statusCodeon a live 404, 403 and 422, refused IDs (the user stayed a grantee and in Agent Vault),actorTypeandisOrgMembershipPendingon live grantees, and, against local servers, one request each forgrantandrevokewhen the connection drops, whereproxies.updatesends five, and a timeout after 10 seconds with theTimeoutErrorincause. 3 more checkedaccessBundleagainst #9099: a session reads its bundle back,revokeleaves it out, and a session keeps the bundle's name, with a nullid, once the bundle is deleted.Type
Checklist
type(scope): short description(scope is optional, e.g.,fix: prevent crash on syncorfix(api): handle null response).🤖 Generated with Claude Code