agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
194 lines • 10.2 kB
TypeScript
/**
* googleIdentity — the {@link CredentialProvider} port over Google's own
* credential machinery (peer-dep `google-auth-library`).
*
* import { googleIdentity } from 'agentfootprint/security';
* const credentials = googleIdentity();
*
* ── What it is, and what it deliberately is not ─────────────────────────────
* This is the **narrow** adapter: it vends *Google* access tokens for *Google*
* APIs, from whatever credential the environment already has — Application
* Default Credentials on Cloud Run or GKE, a workload-identity federation
* config, a service account, optionally impersonating another service account.
* That is one job and it is done completely.
*
* It is **not** a token vault and does not pretend to be one. The other
* column's identity adapter can vend a *GitHub* token for a *user* because
* that service runs a vault with per-user OAuth grants behind it. Google's
* equivalent — the Agent Identity auth manager — is Preview with no Node
* surface, so `mode: 'user'` here is **refused by name** rather than quietly
* served with a machine token. A machine token returned where a user token was
* asked for is the exact silent downgrade the port exists to prevent: the call
* succeeds, the data comes back, and it was the agent's access rather than the
* person's. See {@link googleIdentity} for the refusal's wording.
*
* ── The one-hour fact, and where it bites ───────────────────────────────────
* A Google OAuth access token lives about an hour. That is fine wherever the
* credential is fetched per use — which is how `ctx.credential` works, so the
* ordinary path is unaffected. It bites in exactly one place, and it is worth
* naming because it looks like it should work:
*
* > **The OpenAI-compatible endpoint trap.** Google publishes an
* > OpenAI-compatible Gemini endpoint, and `openai({ baseURL, apiKey })` does
* > reach it. If you fill that `apiKey` with a token from here, it works for
* > an hour and then every call fails with a 401 — because `apiKey` is a
* > STRING captured when the provider is constructed, and a long-lived agent
* > process outlives it. There is no refresh home in the OpenAI provider's
* > options for a credential that expires. Use the native `gemini()` provider,
* > which reads ADC through the SDK and refreshes underneath you.
*
* `expiresAt` is reported on every issued credential so a caller that caches
* one can tell. This adapter never caches: {@link CachedGoogleClient} keeps
* the *client*, and the client's own refresh logic keeps the token fresh.
*
* ── Secrets ─────────────────────────────────────────────────────────────────
* The `sdkFailure` law, same as every other credential-touching adapter here:
* the library's own message never comes through, because auth libraries echo
* request detail into failure text and a message thrown from a
* `CredentialProvider` reaches the LLM as a tool result AND rides
* `agentfootprint.credential.failed` to every sink. What comes through is the
* operation that failed and the error's NAME. The original is not attached as
* `cause` — a cause travels into every serializer that walks own properties,
* which would undo all of it in one `JSON.stringify`.
*
* Pattern: Adapter (GoF) + lazy peer-dep load — `google-auth-library` is
* required the first time `getCredential` runs, or never if you inject one.
*/
import type { CredentialProvider } from '../../identity/types.js';
/** The scope every Google Cloud data-plane API accepts. */
export declare const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
/**
* The slice of an auth client this adapter calls — a `GoogleAuth`, an
* `OAuth2Client` and an `Impersonated` all satisfy it, which is why nothing
* here switches on which one it has.
*/
export interface GoogleAuthClientLike {
/** The current access token, refreshed if the library thinks it is stale. */
getAccessToken(): Promise<{
token?: string | null;
} | string | null | undefined>;
/** Where the library records the expiry it knows about, in unix ms. */
readonly credentials?: {
readonly expiry_date?: number | null;
};
}
/** The slice of a `GoogleAuth` this adapter calls. */
export interface GoogleAuthLike {
getClient(): Promise<GoogleAuthClientLike>;
}
/** The slice of `google-auth-library` this adapter loads. */
export interface GoogleAuthSdkModule {
readonly GoogleAuth?: new (options: {
scopes?: readonly string[];
}) => GoogleAuthLike;
readonly Impersonated?: new (options: {
sourceClient: GoogleAuthClientLike;
targetPrincipal: string;
targetScopes: string[];
delegates?: string[];
lifetime?: number;
}) => GoogleAuthClientLike;
}
/** Impersonate another service account for the tokens this provider vends. */
export interface GoogleImpersonation {
/**
* The service account to act as, e.g.
* `'agent-runner@my-project.iam.gserviceaccount.com'`. The credential the
* environment already has must hold `roles/iam.serviceAccountTokenCreator`
* on it; without that the exchange fails with a 403 whose text this adapter
* withholds, so the permission is worth checking before you wonder why.
*/
readonly targetPrincipal: string;
/** A delegation chain, when one account cannot impersonate the target directly. */
readonly delegates?: readonly string[];
/**
* How long the impersonated token lives, in seconds. The service's own
* ceiling is 3600 (one hour) unless the org policy raises it, and asking for
* more than it allows is refused by Google rather than clamped.
*/
readonly lifetimeSeconds?: number;
}
/** Options for {@link googleIdentity}. */
export interface GoogleIdentityOptions {
/**
* The OAuth scopes to request. Default `[cloud-platform]`, which is what
* every Google Cloud data-plane API accepts.
*
* A request's own `scopes` win when it names any — a tool that knows it only
* needs read access should say so, and this is where that is honoured.
*/
readonly scopes?: readonly string[];
/**
* Act as another service account. Off by default; see
* {@link GoogleImpersonation}.
*/
readonly impersonate?: GoogleImpersonation;
/**
* Which downstream services this provider will answer for.
*
* Unset — the default — it answers for ANY `service`, because the token it
* vends is a Google credential and the caller knows better than this adapter
* which Google API they are about to call.
*
* Set it and a request for a service outside the list is refused BY NAME
* rather than served. That is the useful setting in a deployment where tools
* declare `needs: [{ credential: 'github' }]` alongside Google ones: without
* it, this provider would happily hand a Google access token to the tool
* that wanted a GitHub one, and the failure would surface as a puzzling 401
* from GitHub rather than as a wiring error here.
*/
readonly services?: readonly string[];
/** Stable provider id (default `'google-identity'`). */
readonly id?: string;
/**
* @internal Test seam — a pre-built auth client. Bypasses the SDK entirely,
* so the suite runs with no package and no credential.
*/
readonly _client?: GoogleAuthClientLike;
/** @internal Test seam — the SDK module, to exercise the real construction. */
readonly _sdk?: GoogleAuthSdkModule;
}
/**
* Vend Google access tokens from whatever credential this environment has.
*
* **Status: field-validated for machine identity; refresh bounded.** An
* independent trial ran this adapter on live Google Cloud (2026-08-14):
* `googleIdentity({ services: ['aiplatform'] })` vended a real bearer
* credential from Application Default Credentials, and that credential
* authorized a Vertex AI request with HTTP 200. The same run proved the
* `bearer` kind, an expiry about 3,599 seconds out, a second vend without
* reconstruction, `mode: 'user'` failing CLOSED rather than quietly handing
* back machine access, a disallowed service failing closed, and
* `JSON.stringify(credential)` yielding `{"kind":"bearer"}` — no token, no
* Authorization header.
*
* **What is NOT proven:** an expiry-triggered refresh. The trial called the
* provider twice minutes apart and checked a future expiry; it did not wait an
* hour. Google's own auth client is what refreshes (this adapter caches the
* CLIENT, never a token — see {@link CachedGoogleClient}), and that refresh was
* field-proved on a different door (`gemini({ googleAuthOptions })`). A soak or
* a controlled expiring-credential double is still owed before anyone claims
* long-duration production proof for THIS provider.
*
* @throws when `mode: 'user'` is requested — Google's per-user token vault has
* no Node surface, and a machine token returned in its place would be a
* silent downgrade.
* @throws when `services` is configured and the request names another one.
*
* @example A tool that calls a Google API with the deployment's own identity
* const agent = Agent.create({ provider, credentials: googleIdentity() })
* .tool(defineTool({
* name: 'read_sheet',
* needs: [{ credential: 'sheets', scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'] }],
* execute: async (args, ctx) =>
* fetch(url, { headers: ctx.credential!.toHeaders() }).then((r) => r.text()),
* }))
* .build();
*
* @example Acting as a dedicated service account
* googleIdentity({
* impersonate: { targetPrincipal: 'agent-runner@my-project.iam.gserviceaccount.com' },
* });
*/
export declare function googleIdentity(options?: GoogleIdentityOptions): CredentialProvider;
//# sourceMappingURL=google.d.ts.map