diff --git a/README.md b/README.md index 638318a..e7d9cdb 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,25 @@ for (const agent of agents) { **Returns** `Array<{ id, name, identifier, isDefault, createdAt }>` +#### `onecli.listAgentsWithGrants(options?)` + +`listAgents` plus a per-agent summary of what's granted (which apps and +secrets, not the full tool lists) in one round-trip. + +```typescript +const agents = await onecli.listAgentsWithGrants(); + +for (const agent of agents) { + console.log(agent.identifier, agent.grantsSummary.total); + for (const entry of agent.grantsSummary.entries) { + // entry.kind: "app" (a connection) | "secret" | "llm" + console.log(entry.kind === "app" ? entry.provider : entry.name); + } +} +``` + +**Returns** `AgentWithGrantsSummary[]` + #### `onecli.getEffectiveCredentials(agentId, options?)` Which credentials the agent can actually use, and what each one can do under the @@ -288,6 +307,95 @@ Idempotent even at the agent cap: if the project is at its plan's agent limit bu --- +### Agent grants + +Which credentials an agent may use. An agent starts with **no grants** — the +gateway injects nothing for it until a connection or secret is attached. Grant +writes take effect immediately; there is no draft/publish step. + +Grants are the writable **intent**. The read-only reflections +(`getEffectiveCredentials`, `getEffectiveAppPermissions`, +`getConnectionAgentAccess`) show the **effect** — what requests actually get +through once organization policy is applied on top. A tool a grant allows can +still be blocked (or forced to approval) by an org rule, so the reflections are +the view to trust when debugging a blocked request. + +#### `onecli.getAgentGrants(agentId, options?)` + +Everything granted to one agent. + +```typescript +const grants = await onecli.getAgentGrants("agent-id"); + +for (const c of grants.connections) { + // c.access: "full" (every tool) or "custom" (the allow/ask lists below) + console.log(c.provider, c.label, c.access, c.allow, c.ask); +} +for (const s of grants.secrets) { + console.log(s.name, s.type, s.scope); +} +``` + +**Returns** `AgentGrants` — `{ agentId, mode, connections, secrets }`. + +#### `onecli.setConnectionGrant(agentId, connectionId, input, options?)` + +Attach an app connection to an agent, or change what the agent may do with it. + +```typescript +// Full access — every tool the app supports +await onecli.setConnectionGrant("agent-id", "connection-id", { + access: "full", +}); + +// Custom — name the tools. `allow` runs freely; `ask` pauses for human approval. +await onecli.setConnectionGrant("agent-id", "connection-id", { + access: "custom", + allow: ["search_messages", "get_message"], + ask: ["send_email"], +}); +``` + +Two validation laws on custom grants (both a 422): the two lists together must +name at least one tool — to take everything away, detach instead — and a tool +can't be in both lists. Tool ids come from `listAppPermissionDefinitions()`. +The `ask` list requires a plan with manual approvals (403 otherwise). + +**Returns** the agent's updated `AgentGrants`. + +#### `onecli.removeConnectionGrant(agentId, connectionId, options?)` + +Detach a connection from an agent. The gateway stops serving it to that agent +immediately. + +**Returns** nothing (the server responds `204`). + +#### `onecli.attachSecret(agentId, secretId, options?)` + +Attach a secret (an API key or LLM key) to an agent. Secrets are +all-or-nothing — there are no per-tool lists. + +**Returns** the agent's updated `AgentGrants`. + +#### `onecli.detachSecret(agentId, secretId, options?)` + +Detach a secret from an agent. **Returns** nothing (`204`). + +#### `onecli.getConnectionGrants(connectionId, options?)` + +The reverse view: which agents hold a grant for one connection. + +```typescript +const { agents } = await onecli.getConnectionGrants("connection-id"); +for (const a of agents) { + console.log(a.agentId, a.access, a.allow, a.ask); +} +``` + +**Returns** `ConnectionGrants` — `{ connectionId, agents }`. + +--- + ### Project provisioning > **Cloud-only feature.** Calling `provisionProject()` against an OSS instance throws `OneCLIError`. @@ -419,6 +527,62 @@ try { --- +### Gateway errors & multiple accounts + +Agent traffic doesn't go through this SDK — it rides the gateway proxy +transparently. When the gateway blocks or can't route a proxied request, the +response body is a typed JSON error (distinct from the management API's +`{ error: { message, type } }` envelope). The SDK ships those body types plus a +narrowing helper, so agent-side code can react without guessing: + +```typescript +import { + parseGatewayError, + CONNECTION_ID_HEADER, +} from "@onecli-sh/sdk"; + +const send = async (headers: Record = {}) => + fetch("https://gmail.googleapis.com/gmail/v1/users/me/messages", { headers }); + +let res = await send(); +if (!res.ok) { + const err = parseGatewayError(await res.json().catch(() => null)); + + if ( + err?.error === "multiple_connections" || + err?.error === "multiple_providers" + ) { + // Two accounts could serve this request (e.g. two Gmail connections). + // Retry the identical request naming one of them: + const choice = err.connections[0]; + res = await send({ [CONNECTION_ID_HEADER]: choice.id }); + } else if (err) { + // access_restricted, blocked_by_policy, credential_not_found, ... — + // every arm carries a remediation URL or the blocking rule's name. + console.error(err.error, err.message); + } +} +``` + +All of these responses carry `x-should-retry: false`: retrying unchanged will +not succeed — the fix is the named remediation (add the header, grant the +connection to the agent, attach the credential). Successfully forwarded +responses advertise the available accounts in the `x-onecli-connections` +response header (a JSON array of `GatewayConnectionChoice`), so an agent can +learn the ids before ever hitting a 409. + +| Body (`error`) | Status | Meaning | +| -------------- | ------ | ------- | +| `multiple_connections` | 409 | Several accounts of the same app match — retry with `x-onecli-connection-id` | +| `multiple_providers` | 409 | Accounts of different apps match — same retry protocol | +| `connection_not_found` | 404 | The `x-onecli-connection-id` you sent names no available connection — re-pick | +| `access_restricted` | upstream 401/403 | A credential exists, but this agent has no grant for it (`manage_url` opens the fix) | +| `blocked_by_policy` | 403 | A policy rule blocked the request (`rule_name` says which) | +| `blocked_by_default_policy` | 403 | Nothing allowed the request (deny-by-default) | +| `credential_not_found` | upstream 401/403 | No credential exists for this host at all (`secret_url` is a create link) | + +--- + ### `onecli.org` — organization-level resources Connections and rules shared by **every project** in the organization. Requests carry no `X-Project-Id`; authenticate with an organization API key (`oc_org_...`), and note every org operation requires the admin or owner role. Requires OneCLI Cloud or a self-hosted Enterprise instance (a 404 from servers without the org surface is mapped to a descriptive `OneCLIError`). `getAuthorizeUrl` is for server-side runtimes only (browser fetch hides redirect headers). @@ -472,13 +636,16 @@ const handle = onecli.org.configureManualApproval( ``` Policy rules carry structured `targets` (app / connection / secret / -network) and `identities` (org rules take `agentGroup`/`user`/`group`). -Rule responses use `PolicyRuleTarget` (arrays always present, unset scalars -`null`); inputs use `PolicyRuleTargetInput` (omit unused fields — the API -rejects `null`s on write). `updatePolicyRule` requires at least one field -(an empty input is rejected with 422). Legacy rule listings still mix -custom rows (with `hostPattern`/etc.) and read-only app-permission rows -identified by `metadata.provider` + `metadata.toolId`. +network) and `identities` (org rules take `user`/`group`; `agent` +identities exist at project scope only). Rule responses use +`PolicyRuleTarget` (arrays always present, unset scalars `null`); inputs +use `PolicyRuleTargetInput` (omit unused fields — the API rejects `null`s +on write). `updatePolicyRule` requires at least one field (an empty input +is rejected with 422). + +Org rules are the organization-wide **ceiling**: they cap what any project +grant can allow. Per-agent access within a project is managed with +[agent grants](#agent-grants), not rules. | Method | Endpoint | Returns | |--------|----------|---------| @@ -503,40 +670,104 @@ identified by `metadata.provider` + `metadata.toolId`. ### Types -All types are exported for use in your own code: +Every request/response shape is exported. The full surface, by area: ```typescript +// Client + container configuration import type { OneCLIOptions, RequestOptions, ContainerConfig, + CredentialStub, GetContainerConfigOptions, ApplyContainerConfigOptions, +} from "@onecli-sh/sdk"; + +// Agents + grants +import type { + Agent, CreateAgentInput, CreateAgentResponse, EnsureAgentResponse, + AgentGrants, + AgentGrantConnection, + AgentGrantSecret, + ConnectionGrantInput, + ConnectionGrants, + AgentGrantsSummary, + GrantsSummaryEntry, + AgentWithGrantsSummary, +} from "@onecli-sh/sdk"; + +// Read-only policy reflections (effective access) +import type { + EffectiveCredentials, + EffectiveCredential, + CredentialAccessStatus, + CredentialProvenance, + ConnectionAgentAccess, + ConnectionAgent, + AgentAccessStatus, + AgentCredentialStatus, + AppPermissionDefinition, + EffectiveAppPermissions, + EffectiveToolGroup, + EffectiveTool, + EffectiveToolVerdict, + EffectiveProvenance, +} from "@onecli-sh/sdk"; + +// Gateway proxied-error protocol (see "Gateway errors & multiple accounts") +import { + parseGatewayError, + CONNECTION_ID_HEADER, + CONNECTIONS_HEADER, +} from "@onecli-sh/sdk"; +import type { + GatewayError, + GatewayConnectionChoice, + MultipleConnectionsError, + MultipleProvidersError, + ConnectionNotFoundError, + AccessRestrictedError, + BlockedByPolicyError, + BlockedByDefaultPolicyError, + CredentialNotFoundError, +} from "@onecli-sh/sdk"; + +// Manual approval +import type { ApprovalRequest, + ApprovalSummary, + ApprovalDetail, ManualApprovalCallback, ManualApprovalHandle, OrgApprovalRequest, OrgManualApprovalCallback, OrgManualApprovalOptions, +} from "@onecli-sh/sdk"; + +// Project provisioning +import type { ProvisionProjectInput, ProvisionProjectResponse, +} from "@onecli-sh/sdk"; + +// Organization surface (connections + org policy rules) +import type { ConnectOrgAppInput, GetOrgAuthorizeUrlOptions, OrgConnection, - OrgRule, - OrgRuleCondition, - CreateOrgRuleInput, - UpdateOrgRuleInput, OrgPolicyRule, + OrgRuleCondition, + OrgRuleMethod, + OrgRuleRateLimitWindow, PolicyRuleAction, PolicyRuleStatus, - PolicyRuleTarget, - PolicyRuleTargetInput, PolicyRuleIdentity, OrgPolicyRuleIdentityInput, + PolicyRuleTarget, + PolicyRuleTargetInput, PolicyRuleConditionsInput, CreateOrgPolicyRuleInput, UpdateOrgPolicyRuleInput, diff --git a/package.json b/package.json index 68d00c8..f1c09b0 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "build": "tsup", "dev": "tsup --watch", "test": "vitest run", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit -p tsconfig.test.json", "prepublishOnly": "pnpm run build" }, "keywords": [ diff --git a/src/agents/index.ts b/src/agents/index.ts index 20b13d8..ae91e42 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -5,8 +5,12 @@ import { } from "../errors.js"; import type { Agent, + AgentGrants, + AgentWithGrantsSummary, AppPermissionDefinition, ConnectionAgentAccess, + ConnectionGrantInput, + ConnectionGrants, EffectiveAppPermissions, CreateAgentInput, CreateAgentResponse, @@ -15,6 +19,22 @@ import type { } from "./types.js"; import type { RequestOptions } from "../request-options.js"; +/** Extract the server error-envelope message from a response body, if any. */ +const parseErrorEnvelope = (body: string): string | null => { + try { + const parsed = JSON.parse(body) as { + error?: { message?: string } | string; + }; + if (typeof parsed.error === "string") return parsed.error; + if (parsed.error && typeof parsed.error.message === "string") { + return parsed.error.message; + } + return null; + } catch { + return null; + } +}; + export class AgentsClient { private baseUrl: string; private apiKey: string; @@ -86,24 +106,57 @@ export class AgentsClient { /** * List all agents in the project. */ - listAgents = async (options?: RequestOptions): Promise => { - const url = `${this.baseUrl}/v1/agents`; + listAgents = async (options?: RequestOptions): Promise => + this.request("GET", "/v1/agents", undefined, options); + + /** + * List all agents with each one's attach summary — its granted connections, + * secrets, and LLM keys (`GET /v1/agents?include=grants-summary`). + */ + listAgentsWithGrants = async ( + options?: RequestOptions, + ): Promise => + this.request( + "GET", + "/v1/agents?include=grants-summary", + undefined, + options, + ); + + /** + * Shared request path. Non-2xx responses surface the server's error-envelope + * message when one exists (`{ error: { message } }` or `{ error: "..." }`) — + * a 410's pointer at the replacement endpoint, a 422's validation law — + * instead of a bare status line. 204 responses resolve to undefined. + */ + private request = async ( + method: "GET" | "PUT" | "DELETE", + path: string, + body?: unknown, + options?: RequestOptions, + ): Promise => { + const url = `${this.baseUrl}${path}`; try { const res = await fetch(url, { - method: "GET", + method, headers: this.buildHeaders(options), + ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(this.timeout), }); if (!res.ok) { + const detail = parseErrorEnvelope(await res.text().catch(() => "")); throw new OneCLIRequestError( - `OneCLI returned ${res.status} ${res.statusText}`, + detail ?? `OneCLI returned ${res.status} ${res.statusText}`, { url, statusCode: res.status }, ); } - return (await res.json()) as Agent[]; + if (res.status === 204) { + return undefined as T; + } + return (await res.json()) as T; } catch (error) { if ( error instanceof OneCLIError || @@ -122,34 +175,103 @@ export class AgentsClient { private getJson = async ( path: string, options?: RequestOptions, - ): Promise => { - const url = `${this.baseUrl}${path}`; + ): Promise => this.request("GET", path, undefined, options); - try { - const res = await fetch(url, { - method: "GET", - headers: this.buildHeaders(options), - signal: AbortSignal.timeout(this.timeout), - }); + /** + * The agent's grants: its attached app connections (with per-tool access) + * and secrets. Grants are attach INTENT — `getEffectiveCredentials` is the + * effective view with organization guardrails applied. + */ + getAgentGrants = async ( + agentId: string, + options?: RequestOptions, + ): Promise => + this.request( + "GET", + `/v1/agents/${encodeURIComponent(agentId)}/grants`, + undefined, + options, + ); - if (!res.ok) { - throw new OneCLIRequestError( - `OneCLI returned ${res.status} ${res.statusText}`, - { url, statusCode: res.status }, - ); - } + /** + * Attach an app connection to the agent, or replace its per-tool access. + * Returns the agent's fresh grant set. Idempotent — an identical desired + * state writes nothing and still returns 200. + */ + setConnectionGrant = async ( + agentId: string, + connectionId: string, + input: ConnectionGrantInput, + options?: RequestOptions, + ): Promise => + this.request( + "PUT", + `/v1/agents/${encodeURIComponent(agentId)}/grants/connections/${encodeURIComponent(connectionId)}`, + input, + options, + ); - return (await res.json()) as T; - } catch (error) { - if ( - error instanceof OneCLIError || - error instanceof OneCLIRequestError - ) { - throw error; - } - throw toOneCLIError(error); - } - }; + /** + * Detach an app connection from the agent (204; resolves to void). Also the + * only way to express "no tools at all" — the server rejects an all-blocked + * custom grant with 422. + */ + removeConnectionGrant = async ( + agentId: string, + connectionId: string, + options?: RequestOptions, + ): Promise => + this.request( + "DELETE", + `/v1/agents/${encodeURIComponent(agentId)}/grants/connections/${encodeURIComponent(connectionId)}`, + undefined, + options, + ); + + /** + * Attach a secret or LLM key to the agent. Secrets have no per-tool axis — + * the PUT takes no body. Returns the agent's fresh grant set. + */ + attachSecret = async ( + agentId: string, + secretId: string, + options?: RequestOptions, + ): Promise => + this.request( + "PUT", + `/v1/agents/${encodeURIComponent(agentId)}/grants/secrets/${encodeURIComponent(secretId)}`, + undefined, + options, + ); + + /** Detach a secret from the agent (204; resolves to void). */ + detachSecret = async ( + agentId: string, + secretId: string, + options?: RequestOptions, + ): Promise => + this.request( + "DELETE", + `/v1/agents/${encodeURIComponent(agentId)}/grants/secrets/${encodeURIComponent(secretId)}`, + undefined, + options, + ); + + /** + * Which agents a connection is granted to (attach intent, read-only). + * The connection-side writes are the same server operations as + * `setConnectionGrant`/`removeConnectionGrant` — use those. + */ + getConnectionGrants = async ( + connectionId: string, + options?: RequestOptions, + ): Promise => + this.request( + "GET", + `/v1/connections/${encodeURIComponent(connectionId)}/grants`, + undefined, + options, + ); /** * Which credentials this agent can actually use, and what each one can do diff --git a/src/agents/types.ts b/src/agents/types.ts index 07c39e2..5f0b8fa 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -5,7 +5,11 @@ export interface CreateAgentInput { /** Unique identifier: 1-50 chars, lowercase letters, numbers, and hyphens, starting with a letter or number. */ identifier: string; - /** Identifier of the parent agent. Child inherits parent's secretMode and credential assignments. */ + /** + * Identifier of the parent agent. Accepted for compatibility; inheritance + * was removed — every new agent starts with no credential grants regardless + * of parent. Attach credentials with the grants methods. + */ parentIdentifier?: string; } @@ -135,3 +139,87 @@ export type { EffectiveToolGroup, EffectiveToolVerdict, } from "../org/types.js"; + +// ── Grants (the attach model) ──────────────────────────────────────────────── +// A grant attaches one credential (an app connection, secret, or LLM key) to +// one agent, and is the only project-scope policy writer — the server compiles +// grants into policy rules. Grants are attach INTENT; the effective view (org +// guardrails applied) is the read-only reflections (`getEffectiveCredentials`, +// `getConnectionAgentAccess`, `getEffectiveAppPermissions`). + +/** + * The desired connection grant. `full` attaches every catalog tool (tools + * added to the catalog later are included automatically). `custom` sets a + * per-tool tri-state: `allow` always runs, `ask` needs manual approval, and + * everything else is blocked. + * + * Server-enforced laws (422): `allow` and `ask` must not share a tool, and a + * custom grant needs at least one tool across the two lists — an all-blocked + * grant is a detach (`removeConnectionGrant`). A non-empty `ask` requires the + * approvals feature (403 otherwise). + */ +export type ConnectionGrantInput = + | { access: "full" } + | { access: "custom"; allow: string[]; ask: string[] }; + +export interface AgentGrantConnection { + connectionId: string; + provider: string; + label: string | null; + scope: "project" | "organization"; + access: "full" | "custom"; + allow: string[]; + ask: string[]; +} + +export interface AgentGrantSecret { + secretId: string; + name: string; + /** `generic` is a plain secret; anything else is an LLM key. */ + type: string; + scope: "project" | "organization"; +} + +/** An agent's full grant set — returned by reads and by every grant write. */ +export interface AgentGrants { + agentId: string; + /** + * `grants` on current servers. The `all` arm is wire compatibility with + * older self-hosted releases and disappears in a future release — never + * branch behavior on it. + */ + mode: "all" | "grants"; + connections: AgentGrantConnection[]; + secrets: AgentGrantSecret[]; +} + +/** Which agents a connection is granted to (the reverse view). */ +export interface ConnectionGrants { + connectionId: string; + agents: { + agentId: string; + access: "full" | "custom"; + allow: string[]; + ask: string[]; + }[]; +} + +export type GrantsSummaryEntry = + | { + kind: "app"; + provider: string; + connectionId: string; + label: string | null; + } + | { kind: "secret" | "llm"; id: string; name: string }; + +/** The attach-list summary (`listAgentsWithGrants()`). */ +export interface AgentGrantsSummary { + mode: "all" | "grants"; + entries: GrantsSummaryEntry[]; + total: number; +} + +export interface AgentWithGrantsSummary extends Agent { + grantsSummary: AgentGrantsSummary; +} diff --git a/src/client.ts b/src/client.ts index 3de70c8..6704ce5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -12,8 +12,12 @@ import type { } from "./container/types.js"; import type { Agent, + AgentGrants, + AgentWithGrantsSummary, AppPermissionDefinition, ConnectionAgentAccess, + ConnectionGrantInput, + ConnectionGrants, CreateAgentInput, EffectiveAppPermissions, CreateAgentResponse, @@ -114,6 +118,92 @@ export class OneCLI { return this.agentsClient.listAgents(options); }; + /** + * List all agents with each one's attach summary — its granted connections, + * secrets, and LLM keys. + */ + listAgentsWithGrants = ( + options?: RequestOptions, + ): Promise => { + return this.agentsClient.listAgentsWithGrants(options); + }; + + /** + * The agent's grants: its attached app connections (with per-tool access) + * and secrets. Grants are attach INTENT — `getEffectiveCredentials` is the + * effective view with organization guardrails applied. + */ + getAgentGrants = ( + agentId: string, + options?: RequestOptions, + ): Promise => { + return this.agentsClient.getAgentGrants(agentId, options); + }; + + /** + * Attach an app connection to the agent, or replace its per-tool access. + * Returns the agent's fresh grant set. + */ + setConnectionGrant = ( + agentId: string, + connectionId: string, + input: ConnectionGrantInput, + options?: RequestOptions, + ): Promise => { + return this.agentsClient.setConnectionGrant( + agentId, + connectionId, + input, + options, + ); + }; + + /** + * Detach an app connection from the agent (204; resolves to void). + */ + removeConnectionGrant = ( + agentId: string, + connectionId: string, + options?: RequestOptions, + ): Promise => { + return this.agentsClient.removeConnectionGrant( + agentId, + connectionId, + options, + ); + }; + + /** + * Attach a secret or LLM key to the agent (no request body — secrets have + * no per-tool axis). Returns the agent's fresh grant set. + */ + attachSecret = ( + agentId: string, + secretId: string, + options?: RequestOptions, + ): Promise => { + return this.agentsClient.attachSecret(agentId, secretId, options); + }; + + /** Detach a secret from the agent (204; resolves to void). */ + detachSecret = ( + agentId: string, + secretId: string, + options?: RequestOptions, + ): Promise => { + return this.agentsClient.detachSecret(agentId, secretId, options); + }; + + /** + * Which agents a connection is granted to (attach intent, read-only). + */ + getConnectionGrants = ( + connectionId: string, + options?: RequestOptions, + ): Promise => { + return this.agentsClient.getConnectionGrants(connectionId, options); + }; + /** * Create a new agent. */ diff --git a/src/gateway/types.ts b/src/gateway/types.ts new file mode 100644 index 0000000..050ad0d --- /dev/null +++ b/src/gateway/types.ts @@ -0,0 +1,145 @@ +// Typed bodies for the errors the OneCLI gateway returns on PROXIED agent +// traffic (not the management REST API — those errors ride the +// `{ error: { message, type } }` envelope instead). +// +// Agent requests reach providers through the gateway proxy transparently, so +// these bodies arrive on ordinary fetch/HTTP responses inside agent code. All +// of them carry `x-should-retry: false`: an unchanged retry will not succeed — +// the fix is the named remediation (add the connection header, attach the +// credential, open the URL). + +/** One connectable account, as listed in disambiguation responses and the + * `x-onecli-connections` response header. */ +export interface GatewayConnectionChoice { + id: string; + label: string | null; + provider: string; + display_name: string | null; +} + +/** Request header naming which account a proxied request should use. */ +export const CONNECTION_ID_HEADER = "x-onecli-connection-id"; + +/** Response header advertising the available accounts (a JSON array of + * {@link GatewayConnectionChoice}) on successfully forwarded responses. */ +export const CONNECTIONS_HEADER = "x-onecli-connections"; + +/** + * 409 — two or more accounts of the SAME app could serve the request. Retry + * the identical request with {@link CONNECTION_ID_HEADER} set to one of the + * listed `connections[].id`. + */ +export interface MultipleConnectionsError { + error: "multiple_connections"; + message: string; + connections: GatewayConnectionChoice[]; + header: string; + example: string; +} + +/** 409 — accounts of DIFFERENT apps both match the request. Same retry + * protocol as {@link MultipleConnectionsError}. */ +export interface MultipleProvidersError { + error: "multiple_providers"; + message: string; + connections: GatewayConnectionChoice[]; + header: string; + example: string; +} + +/** 404 — the id sent in {@link CONNECTION_ID_HEADER} names no available + * connection (stale or removed). Re-pick from `connections`. */ +export interface ConnectionNotFoundError { + error: "connection_not_found"; + message: string; + connections: GatewayConnectionChoice[]; + header: string; +} + +/** + * 401/403 (the upstream status is preserved) — a credential for this host + * exists in the project, but the agent has no grant for it. `manage_url` + * opens the account's Agent access dialog. + */ +export interface AccessRestrictedError { + error: "access_restricted"; + message: string; + provider: string; + manage_url: string; +} + +/** 403 — a policy rule blocked the request. */ +export interface BlockedByPolicyError { + error: "blocked_by_policy"; + message: string; + rule_name: string; + method: string; + path: string; + dashboard_url: string; +} + +/** 403 — nothing allowed the request under a deny-by-default posture. */ +export interface BlockedByDefaultPolicyError { + error: "blocked_by_default_policy"; + message: string; + method: string; + host: string; + path: string; + dashboard_url: string; +} + +/** 401/403 (upstream status preserved) — no credential exists for the host at + * all. `secret_url` is a pre-built create link. */ +export interface CredentialNotFoundError { + error: "credential_not_found"; + message: string; + hostname: string; + path: string; + secret_url: string; +} + +export type GatewayError = + | MultipleConnectionsError + | MultipleProvidersError + | ConnectionNotFoundError + | AccessRestrictedError + | BlockedByPolicyError + | BlockedByDefaultPolicyError + | CredentialNotFoundError; + +const GATEWAY_ERROR_CODES = new Set([ + "multiple_connections", + "multiple_providers", + "connection_not_found", + "access_restricted", + "blocked_by_policy", + "blocked_by_default_policy", + "credential_not_found", +]); + +/** + * Narrow an already-parsed response body to a typed gateway error, or `null` + * when the body is not one (an ordinary provider error, a management-API + * envelope, a non-object). + * + * ```ts + * const res = await fetch("https://gmail.googleapis.com/gmail/v1/users/me"); + * if (!res.ok) { + * const err = parseGatewayError(await res.json().catch(() => null)); + * if (err?.error === "multiple_connections") { + * // retry with { [CONNECTION_ID_HEADER]: err.connections[0].id } + * } + * } + * ``` + */ +export const parseGatewayError = (body: unknown): GatewayError | null => { + if (typeof body !== "object" || body === null) return null; + const candidate = body as { error?: unknown }; + if ( + typeof candidate.error !== "string" || + !GATEWAY_ERROR_CODES.has(candidate.error) + ) { + return null; + } + return body as GatewayError; +}; diff --git a/src/index.ts b/src/index.ts index 4a7da16..3c9f025 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,7 +31,31 @@ export type { CredentialProvenance, EffectiveCredential, EffectiveCredentials, + AgentGrants, + AgentGrantConnection, + AgentGrantSecret, + AgentGrantsSummary, + AgentWithGrantsSummary, + ConnectionGrantInput, + ConnectionGrants, + GrantsSummaryEntry, } from "./agents/types.js"; +export { + CONNECTION_ID_HEADER, + CONNECTIONS_HEADER, + parseGatewayError, +} from "./gateway/types.js"; +export type { + AccessRestrictedError, + BlockedByDefaultPolicyError, + BlockedByPolicyError, + ConnectionNotFoundError, + CredentialNotFoundError, + GatewayConnectionChoice, + GatewayError, + MultipleConnectionsError, + MultipleProvidersError, +} from "./gateway/types.js"; export type { ApprovalRequest, ApprovalSummary, @@ -51,6 +75,9 @@ export type { GetOrgAuthorizeUrlOptions, OrgConnection, OrgPolicyRule, + OrgRuleCondition, + OrgRuleMethod, + OrgRuleRateLimitWindow, EffectiveAppPermissions, EffectiveTool, EffectiveToolGroup, diff --git a/src/org/types.ts b/src/org/types.ts index 14563e5..b118dd9 100644 --- a/src/org/types.ts +++ b/src/org/types.ts @@ -30,12 +30,6 @@ export interface OrgConnection { connectedAt: string; } -export type OrgRuleAction = - | "block" - | "rate_limit" - | "manual_approval" - | "allow"; - export type OrgRuleMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; export type OrgRuleRateLimitWindow = "minute" | "hour" | "day"; @@ -49,62 +43,6 @@ export interface OrgRuleCondition { key?: string; } -/** - * An organization-scoped policy rule, applied to every agent in the org. - * - * The endpoint fields (`hostPattern`/`pathPattern`/`method`) are present on - * custom (user-authored) rules only. App-permission rules — rows whose - * `metadata.source` is `"app_permission"` — omit them; those rules are - * identified by `metadata.provider` + `metadata.toolId`. On cloud - * deployments app permissions are managed as policy-engine rules (see - * `createPolicyRule`); the legacy permissions endpoint is retired there. - */ -export interface OrgRule { - id: string; - name: string; - hostPattern?: string; - pathPattern?: string | null; - method?: OrgRuleMethod | null; - action: OrgRuleAction; - enabled: boolean; - rateLimit: number | null; - rateLimitWindow: OrgRuleRateLimitWindow | null; - scope?: string; - conditions?: OrgRuleCondition[]; - metadata?: unknown; - createdAt: string; -} - -export interface CreateOrgRuleInput { - name: string; - hostPattern: string; - action: OrgRuleAction; - enabled: boolean; - pathPattern?: string; - method?: OrgRuleMethod; - /** Required when `action` is "rate_limit". */ - rateLimit?: number; - /** Required when `action` is "rate_limit". */ - rateLimitWindow?: OrgRuleRateLimitWindow; - conditions?: OrgRuleCondition[]; -} - -/** - * Partial update for an organization rule. Nullable fields accept an explicit - * `null` to clear the stored value (omitting a field leaves it unchanged). - */ -export interface UpdateOrgRuleInput { - name?: string; - hostPattern?: string; - action?: OrgRuleAction; - enabled?: boolean; - pathPattern?: string | null; - method?: OrgRuleMethod | null; - rateLimit?: number | null; - rateLimitWindow?: OrgRuleRateLimitWindow | null; - conditions?: OrgRuleCondition[] | null; -} - /** A policy-engine rule action. */ export type PolicyRuleAction = "allow" | "block"; @@ -113,11 +51,11 @@ export type PolicyRuleStatus = "draft" | "published"; /** * A principal a policy rule applies to, as the API returns it. Org rules - * carry `agentGroup`, `user`, or `group` identities (`agent` exists at - * project scope only); an empty identity list means "everyone". + * carry `user` or `group` identities (`agent` exists at project scope + * only); an empty identity list means "everyone". */ export interface PolicyRuleIdentity { - type: "agent" | "agentGroup" | "user" | "group"; + type: "agent" | "user" | "group"; id: string; } diff --git a/test/agents/client.test.ts b/test/agents/client.test.ts index 8883f43..2bc2fca 100644 --- a/test/agents/client.test.ts +++ b/test/agents/client.test.ts @@ -34,6 +34,7 @@ describe("AgentsClient", () => { "http://localhost:3000///", "oc_test", 5000, + null, ); client.createAgent({ name: "Test", identifier: "test" }); @@ -54,6 +55,7 @@ describe("AgentsClient", () => { "http://localhost:3000", "oc_mykey", 5000, + null, ); await client.createAgent({ name: "My Agent", identifier: "my-agent" }); @@ -75,7 +77,7 @@ describe("AgentsClient", () => { new Response(JSON.stringify(MOCK_AGENT), { status: 201 }), ); - const client = new AgentsClient("http://localhost:3000", "", 5000); + const client = new AgentsClient("http://localhost:3000", "", 5000, null); await client.createAgent({ name: "Test", identifier: "test" }); expect(fetchSpy).toHaveBeenCalledWith( @@ -95,6 +97,7 @@ describe("AgentsClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const agent = await client.createAgent({ name: "My Agent", @@ -116,6 +119,7 @@ describe("AgentsClient", () => { "http://localhost:3000", "oc_bad", 5000, + null, ); await expect( @@ -142,6 +146,7 @@ describe("AgentsClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const err = await client @@ -160,6 +165,7 @@ describe("AgentsClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); await expect( @@ -179,6 +185,7 @@ describe("AgentsClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const err = await client @@ -199,6 +206,7 @@ describe("AgentsClient", () => { "http://localhost:3000", "oc_mykey", 5000, + null, ); await client.listAgents(); @@ -223,6 +231,7 @@ describe("AgentsClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const agents = await client.listAgents(); @@ -237,7 +246,7 @@ describe("AgentsClient", () => { }), ); - const client = new AgentsClient("http://localhost:3000", "oc_bad", 5000); + const client = new AgentsClient("http://localhost:3000", "oc_bad", 5000, null); const err = await client.listAgents().catch((e: unknown) => e); expect(err).toBeInstanceOf(OneCLIRequestError); @@ -253,6 +262,7 @@ describe("AgentsClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); await expect(client.listAgents()).rejects.toThrow(OneCLIError); @@ -269,6 +279,7 @@ describe("AgentsClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const result = await client.ensureAgent({ name: "My Agent", @@ -294,6 +305,7 @@ describe("AgentsClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const result = await client.ensureAgent({ name: "My Agent", @@ -326,6 +338,7 @@ describe("AgentsClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const result = await client.ensureAgent({ name: "My Agent", @@ -361,6 +374,7 @@ describe("AgentsClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const err = await client @@ -387,6 +401,7 @@ describe("AgentsClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const err = await client @@ -409,6 +424,7 @@ describe("AgentsClient", () => { "http://localhost:3000", "oc_bad", 5000, + null, ); await expect( @@ -425,6 +441,7 @@ describe("AgentsClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); await expect( diff --git a/test/agents/grants.test.ts b/test/agents/grants.test.ts new file mode 100644 index 0000000..445850a --- /dev/null +++ b/test/agents/grants.test.ts @@ -0,0 +1,204 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { AgentsClient } from "../../src/agents/index.js"; +import { OneCLIRequestError } from "../../src/errors.js"; +import type { AgentGrants } from "../../src/agents/types.js"; + +// The grants surface replaces the retired equipment writes, so the properties +// that matter are the exact PATH (the old twins are 410 now) and the wire BODY +// (the server requires both arrays on a custom grant). + +const GRANTS: AgentGrants = { + agentId: "a1", + mode: "grants", + connections: [ + { + connectionId: "c1", + provider: "gmail", + label: "Work", + scope: "project", + access: "custom", + allow: ["search_messages"], + ask: ["send_email"], + }, + ], + secrets: [ + { secretId: "s1", name: "STRIPE_KEY", type: "generic", scope: "project" }, + ], +}; + +let spy: ReturnType; + +// A Response body can only be read once, so mint a fresh one per call — +// mockResolvedValue would hand the same exhausted object to a second call. +const arm = (body: unknown, status = 200) => { + spy = vi + .spyOn(globalThis, "fetch") + .mockImplementation( + async () => + new Response(status === 204 ? null : JSON.stringify(body), { status }), + ); +}; + +const client = () => + new AgentsClient("http://localhost:3000", "oc_test", 5000, "proj_1"); + +afterEach(() => { + spy?.mockRestore(); +}); + +describe("grants", () => { + it("getAgentGrants hits the canonical path and decodes both arms", async () => { + arm(GRANTS); + const result = await client().getAgentGrants("a1"); + expect(result.connections[0]!.access).toBe("custom"); + expect(result.secrets[0]!.name).toBe("STRIPE_KEY"); + expect(spy).toHaveBeenCalledWith( + "http://localhost:3000/v1/agents/a1/grants", + expect.objectContaining({ method: "GET" }), + ); + }); + + it("setConnectionGrant PUTs the full body verbatim", async () => { + arm(GRANTS); + await client().setConnectionGrant("a1", "c1", { access: "full" }); + expect(spy).toHaveBeenCalledWith( + "http://localhost:3000/v1/agents/a1/grants/connections/c1", + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ access: "full" }), + }), + ); + }); + + it("setConnectionGrant PUTs a custom body with both arrays", async () => { + arm(GRANTS); + await client().setConnectionGrant("a1", "c1", { + access: "custom", + allow: ["get_message"], + ask: [], + }); + expect(spy).toHaveBeenCalledWith( + "http://localhost:3000/v1/agents/a1/grants/connections/c1", + expect.objectContaining({ + body: JSON.stringify({ access: "custom", allow: ["get_message"], ask: [] }), + }), + ); + }); + + it("removeConnectionGrant handles the bare 204", async () => { + arm(null, 204); + await expect( + client().removeConnectionGrant("a1", "c1"), + ).resolves.toBeUndefined(); + expect(spy).toHaveBeenCalledWith( + "http://localhost:3000/v1/agents/a1/grants/connections/c1", + expect.objectContaining({ method: "DELETE" }), + ); + }); + + it("attachSecret PUTs with no body (assign-only)", async () => { + arm(GRANTS); + await client().attachSecret("a1", "s1"); + const init = spy.mock.calls[0]![1] as RequestInit; + expect(init.method).toBe("PUT"); + expect("body" in init).toBe(false); + expect(spy).toHaveBeenCalledWith( + "http://localhost:3000/v1/agents/a1/grants/secrets/s1", + expect.anything(), + ); + }); + + it("detachSecret handles the bare 204", async () => { + arm(null, 204); + await expect(client().detachSecret("a1", "s1")).resolves.toBeUndefined(); + }); + + it("getConnectionGrants hits the connection orientation", async () => { + arm({ connectionId: "c1", agents: [] }); + const result = await client().getConnectionGrants("c1"); + expect(result.connectionId).toBe("c1"); + expect(spy).toHaveBeenCalledWith( + "http://localhost:3000/v1/connections/c1/grants", + expect.objectContaining({ method: "GET" }), + ); + }); + + it("listAgentsWithGrants sends the include param", async () => { + arm([ + { + id: "a1", + name: "Cody", + identifier: "cody", + isDefault: true, + createdAt: "2026-07-01T00:00:00.000Z", + grantsSummary: { + mode: "grants", + entries: [ + { kind: "app", provider: "gmail", connectionId: "c1", label: "Work" }, + { kind: "llm", id: "s2", name: "ANTHROPIC_KEY" }, + ], + total: 2, + }, + }, + ]); + const result = await client().listAgentsWithGrants(); + expect(result[0]!.grantsSummary.total).toBe(2); + expect(result[0]!.grantsSummary.entries[0]!.kind).toBe("app"); + expect(spy).toHaveBeenCalledWith( + "http://localhost:3000/v1/agents?include=grants-summary", + expect.objectContaining({ method: "GET" }), + ); + }); + + it("path segments are encoded (no traversal into sibling routes)", async () => { + arm(GRANTS); + await client().getAgentGrants("a/../b"); + expect(spy).toHaveBeenCalledWith( + "http://localhost:3000/v1/agents/a%2F..%2Fb/grants", + expect.anything(), + ); + spy.mockRestore(); + arm(GRANTS); + await client().setConnectionGrant("a1", "c/../x", { access: "full" }); + expect(spy).toHaveBeenCalledWith( + "http://localhost:3000/v1/agents/a1/grants/connections/c%2F..%2Fx", + expect.anything(), + ); + }); + + it("surfaces the server's error-envelope message on a 422", async () => { + const msg = "A tool can't be both always-allowed and require approval."; + arm({ error: { message: msg, type: "validation_error" } }, 422); + const err = await client() + .setConnectionGrant("a1", "c1", { + access: "custom", + allow: ["send_email"], + ask: ["send_email"], + }) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(OneCLIRequestError); + expect((err as OneCLIRequestError).statusCode).toBe(422); + expect((err as Error).message).toContain(msg); + }); + + it("surfaces the 410 pointer message from a retired endpoint", async () => { + const msg = + "PATCH /v1/agents/:agentId/secret-mode was removed: agents are always selective now."; + arm({ error: { message: msg, type: "invalid_request_error" } }, 410); + const err = await client() + .getAgentGrants("a1") + .catch((e: unknown) => e); + expect((err as OneCLIRequestError).statusCode).toBe(410); + expect((err as Error).message).toContain("agents are always selective"); + }); + + it("sends X-Project-Id on every grants call", async () => { + arm(GRANTS); + await client().getAgentGrants("a1"); + const init = spy.mock.calls[0]![1] as RequestInit; + expect((init.headers as Record)["X-Project-Id"]).toBe( + "proj_1", + ); + }); +}); diff --git a/test/client.test.ts b/test/client.test.ts index 6292990..3bc6151 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -193,4 +193,58 @@ describe("OneCLI", () => { fetchSpy.mockRestore(); }); }); + + describe("grants facade", () => { + // The README documents these on OneCLI itself — a method that exists only + // on AgentsClient is invisible to users of `new OneCLI()`. + it("exposes and delegates every grants method", async () => { + const grants = { + agentId: "a1", + mode: "grants", + connections: [], + secrets: [], + }; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation( + async () => new Response(JSON.stringify(grants), { status: 200 }), + ); + + const oc = new OneCLI({ apiKey: "oc_test", url: "http://localhost:3000" }); + await oc.getAgentGrants("a1"); + await oc.setConnectionGrant("a1", "c1", { access: "full" }); + await oc.attachSecret("a1", "s1"); + await oc.getConnectionGrants("c1"); + await oc.listAgentsWithGrants().catch(() => undefined); // array-shaped; delegation is what's pinned + + const urls = fetchSpy.mock.calls.map((c) => String(c[0])); + expect(urls).toContain("http://localhost:3000/v1/agents/a1/grants"); + expect(urls).toContain( + "http://localhost:3000/v1/agents/a1/grants/connections/c1", + ); + expect(urls).toContain( + "http://localhost:3000/v1/agents/a1/grants/secrets/s1", + ); + expect(urls).toContain("http://localhost:3000/v1/connections/c1/grants"); + expect(urls).toContain( + "http://localhost:3000/v1/agents?include=grants-summary", + ); + fetchSpy.mockRestore(); + }); + + it("exposes and delegates both detach methods (204)", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async () => new Response(null, { status: 204 })); + + const oc = new OneCLI({ apiKey: "oc_test", url: "http://localhost:3000" }); + await expect(oc.removeConnectionGrant("a1", "c1")).resolves.toBeUndefined(); + await expect(oc.detachSecret("a1", "s1")).resolves.toBeUndefined(); + expect(fetchSpy.mock.calls.map((c) => (c[1] as RequestInit).method)).toEqual([ + "DELETE", + "DELETE", + ]); + fetchSpy.mockRestore(); + }); + }); }); diff --git a/test/container/client.test.ts b/test/container/client.test.ts index b65f8c0..6cc93a7 100644 --- a/test/container/client.test.ts +++ b/test/container/client.test.ts @@ -43,6 +43,7 @@ describe("ContainerClient", () => { "http://localhost:3000///", "oc_test", 5000, + null, ); client.getContainerConfig(); @@ -63,6 +64,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_mykey", 5000, + null, ); await client.getContainerConfig(); @@ -83,6 +85,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "", 5000, + null, ); await client.getContainerConfig(); @@ -103,6 +106,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const config = await client.getContainerConfig(); @@ -121,6 +125,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_bad", 5000, + null, ); await expect(client.getContainerConfig()).rejects.toThrow( @@ -144,6 +149,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); await expect(client.getContainerConfig()).rejects.toThrow( @@ -163,6 +169,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const err = await client @@ -181,6 +188,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); await expect(client.getContainerConfig()).rejects.toThrow(OneCLIError); @@ -196,6 +204,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const err = await client @@ -217,6 +226,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const args: string[] = []; await client.applyContainerConfig(args, { @@ -248,6 +258,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const args: string[] = []; await client.applyContainerConfig(args, { @@ -271,6 +282,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const args: string[] = []; await client.applyContainerConfig(args, { addHostMapping: false }); @@ -294,6 +306,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const args: string[] = []; await client.applyContainerConfig(args, { @@ -314,6 +327,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const result = await client.applyContainerConfig([], { combineCaBundle: false, @@ -332,6 +346,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const args: string[] = []; const result = await client.applyContainerConfig(args); @@ -352,6 +367,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_invalid", 5000, + null, ); await expect(client.applyContainerConfig([])).rejects.toThrow( @@ -376,6 +392,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const args: string[] = []; @@ -397,6 +414,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const result = await client.applyContainerConfig([]); @@ -415,6 +433,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const args = ["run", "-i", "--rm"]; const argsBefore = [...args]; @@ -434,6 +453,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const args = ["run", "-i", "--rm", "--name", "my-agent"]; const originalLength = args.length; @@ -463,6 +483,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const args: string[] = []; await client.applyContainerConfig(args, { @@ -486,6 +507,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const args: string[] = []; await client.applyContainerConfig(args, { @@ -513,6 +535,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const args: string[] = []; await client.applyContainerConfig(args, { combineCaBundle: false }); @@ -541,6 +564,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const args: string[] = []; await client.applyContainerConfig(args, { combineCaBundle: false }); @@ -567,6 +591,7 @@ describe("ContainerClient", () => { "http://localhost:3000", "oc_test", 5000, + null, ); const args: string[] = []; await client.applyContainerConfig(args, { diff --git a/test/gateway/types.test.ts b/test/gateway/types.test.ts new file mode 100644 index 0000000..63afc19 --- /dev/null +++ b/test/gateway/types.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; + +import { + CONNECTION_ID_HEADER, + CONNECTIONS_HEADER, + parseGatewayError, +} from "../../src/gateway/types.js"; + +// parseGatewayError narrows a parsed proxied-response body. The negative arms +// matter as much as the positive ones: a management-API envelope and an +// ordinary provider error must NOT be mistaken for a gateway error. + +const CHOICE = { + id: "conn_1", + label: "Work", + provider: "gmail", + display_name: "Gmail", +}; + +describe("parseGatewayError", () => { + it("narrows multiple_connections and keeps the retry fields", () => { + const body = { + error: "multiple_connections", + message: "Multiple Google Mail accounts are connected.", + connections: [CHOICE], + header: "x-onecli-connection-id", + example: "x-onecli-connection-id: conn_1", + }; + const parsed = parseGatewayError(body); + expect(parsed?.error).toBe("multiple_connections"); + if (parsed?.error === "multiple_connections") { + expect(parsed.connections[0]?.id).toBe("conn_1"); + expect(parsed.header).toBe(CONNECTION_ID_HEADER); + } + }); + + it("narrows multiple_providers the same way", () => { + const parsed = parseGatewayError({ + error: "multiple_providers", + message: "Accounts from more than one app match this request.", + connections: [CHOICE], + header: "x-onecli-connection-id", + example: "x-onecli-connection-id: conn_1", + }); + expect(parsed?.error).toBe("multiple_providers"); + }); + + it("narrows connection_not_found (the stale-id case, no example field)", () => { + const parsed = parseGatewayError({ + error: "connection_not_found", + message: "No available connection matches that id.", + connections: [CHOICE], + header: "x-onecli-connection-id", + }); + expect(parsed?.error).toBe("connection_not_found"); + }); + + it("narrows access_restricted with its manage_url", () => { + const parsed = parseGatewayError({ + error: "access_restricted", + message: "This agent has no grant for the Gmail connection.", + provider: "gmail", + manage_url: "https://app.onecli.sh/connections/apps/gmail", + }); + if (parsed?.error === "access_restricted") { + expect(parsed.manage_url).toContain("/connections/apps/gmail"); + } else { + expect.unreachable("expected access_restricted"); + } + }); + + it("narrows both policy blocks (rule_name only on the named one)", () => { + const named = parseGatewayError({ + error: "blocked_by_policy", + message: "Blocked by rule.", + rule_name: "No deletes", + method: "DELETE", + path: "/gmail/v1/users/me/messages/abc", + dashboard_url: "https://app.onecli.sh/agents", + }); + expect(named?.error).toBe("blocked_by_policy"); + + const unnamed = parseGatewayError({ + error: "blocked_by_default_policy", + message: "Nothing allows this request.", + method: "POST", + host: "api.stripe.com", + path: "/v1/charges", + dashboard_url: "https://app.onecli.sh/agents", + }); + expect(unnamed?.error).toBe("blocked_by_default_policy"); + }); + + it("narrows credential_not_found", () => { + const parsed = parseGatewayError({ + error: "credential_not_found", + message: "No credential exists for this host.", + hostname: "api.stripe.com", + path: "/v1/charges", + secret_url: "https://app.onecli.sh/secrets/new?hostname=api.stripe.com", + }); + expect(parsed?.error).toBe("credential_not_found"); + }); + + it("rejects the management-API error envelope", () => { + expect( + parseGatewayError({ + error: { message: "Agent not found", type: "invalid_request_error" }, + }), + ).toBeNull(); + }); + + it("rejects ordinary provider errors and non-objects", () => { + expect(parseGatewayError({ error: "invalid_grant" })).toBeNull(); + expect(parseGatewayError({ message: "rate limited" })).toBeNull(); + expect(parseGatewayError("upstream text body")).toBeNull(); + expect(parseGatewayError(null)).toBeNull(); + expect(parseGatewayError(undefined)).toBeNull(); + expect(parseGatewayError(42)).toBeNull(); + }); + + it("exports the two protocol header names", () => { + expect(CONNECTION_ID_HEADER).toBe("x-onecli-connection-id"); + expect(CONNECTIONS_HEADER).toBe("x-onecli-connections"); + }); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..9e55fb7 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + // Typecheck config covering the tests too — `tsconfig.json` stays src-only + // because tsup's dts build derives its output layout from its rootDir. + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": ["src", "test"], + "exclude": ["node_modules", "lib"] +}