UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

289 lines • 16.5 kB
"use strict"; /** * 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. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.googleIdentity = exports.CLOUD_PLATFORM_SCOPE = void 0; const lazyRequire_js_1 = require("../../lib/lazyRequire.js"); const kinds_js_1 = require("../../identity/kinds.js"); const ADAPTER = 'googleIdentity'; /** The scope every Google Cloud data-plane API accepts. */ exports.CLOUD_PLATFORM_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'; /** * 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' }, * }); */ function googleIdentity(options = {}) { const defaultScopes = options.scopes ?? [exports.CLOUD_PLATFORM_SCOPE]; const allowed = options.services === undefined ? undefined : new Set(options.services); const cache = {}; const clientFor = (scopes) => { // One client for the life of the provider. Scopes are fixed at // construction by the library, so a request that asks for DIFFERENT scopes // gets its own client rather than a token minted for the wrong ones — the // cache is keyed on the default set and skipped otherwise. if (sameScopes(scopes, defaultScopes)) { return (cache.client ??= buildClient(options, scopes)); } return buildClient(options, scopes); }; return { id: options.id ?? 'google-identity', async getCredential(req) { if (req.mode === 'user') { throw new Error(`${ADAPTER}: a \`mode: 'user'\` request arrived for '${req.service}', and this ` + `provider cannot serve one.\n` + ` It vends the DEPLOYMENT's Google credential (Application Default Credentials, ` + `workload identity, or an impersonated service account). It has no per-user token ` + `vault — Google's own (the Agent Identity auth manager) has no Node surface to ` + `build on.\n` + ` Returning a machine token here would succeed and be wrong: the call would run ` + `with the AGENT's access rather than the person's, and nothing downstream could ` + `tell.\n` + ` Fix: declare \`mode: 'machine'\` if the deployment's own identity is really ` + `what you want, or vend the user's token from a provider that holds one.`); } if (req.userToken !== undefined) { // A user's signed token handed to a provider that cannot exchange it. // Named rather than ignored: silently dropping somebody's proof and // vending machine access is the same downgrade in a quieter costume. throw new Error(`${ADAPTER}: a \`userToken\` arrived for '${req.service}', but this provider has ` + `nothing to exchange it against — it vends the deployment's own Google credential ` + `and cannot act on behalf of a person.\n` + ` Ignoring it would hand back agent-scoped access while holding the user's proof. ` + `Drop the token, or use a provider that can exchange one.`); } if (allowed !== undefined && !allowed.has(req.service)) { throw new Error(`${ADAPTER}: this provider is configured for [${[...allowed].join(', ')}] and was ` + `asked for '${req.service}'.\n` + ` It vends GOOGLE access tokens; handing one to a tool that wanted a different ` + `service's credential would fail downstream as a puzzling 401 instead of here as ` + `a wiring error.\n` + ` Fix: add '${req.service}' to 'services', or attach a provider that serves it.`); } const scopes = req.scopes !== undefined && req.scopes.length > 0 ? req.scopes : defaultScopes; let client; try { client = await clientFor(scopes); } catch (err) { // "No credential in this environment" is the most common real failure // and deserves the fix rather than a library's stack trace — but ONLY // when that is really what happened. A refusal this adapter authored // (a missing peer dependency, an impersonation with no target) is // already the right diagnosis, and rewriting it as "no credential" // would send the reader to `gcloud auth` for a problem `npm install` // fixes. Wrong diagnoses are their own kind of silently-wrong. if (isOwnRefusal(err)) throw err; throw noCredentials(err); } let answer; try { answer = await client.getAccessToken(); } catch (err) { throw sdkFailure('getAccessToken', err); } const token = typeof answer === 'string' ? answer : answer?.token; if (typeof token !== 'string' || token === '') { throw new Error(`${ADAPTER}: the auth client returned no access token for '${req.service}'.\n` + ` No value is quoted here on purpose — every field of a token response is a ` + `secret. A credential that resolved but vends nothing usually means the ` + `environment has a config file with no usable key, or an impersonation the source ` + `identity is not permitted to perform.`); } // The library records expiry in unix MILLISECONDS; the port reports unix // SECONDS. Reported when known and omitted when not — an invented expiry // is worse than none, because a caller would cache against it. const expiryMs = client.credentials?.expiry_date; const expiresAt = typeof expiryMs === 'number' && Number.isFinite(expiryMs) && expiryMs > 0 ? Math.floor(expiryMs / 1000) : undefined; return { status: 'issued', credential: (0, kinds_js_1.bearer)(token), ...(expiresAt !== undefined && { expiresAt }), }; }, }; } exports.googleIdentity = googleIdentity; // ─── Internals ─────────────────────────────────────────────────────── function sameScopes(a, b) { return a.length === b.length && a.every((scope, index) => scope === b[index]); } /** Build the auth client: ADC, then impersonation on top when configured. */ async function buildClient(options, scopes) { if (options._client) return options._client; const mod = loadAuthSdk(options._sdk); if (typeof mod.GoogleAuth !== 'function') { throw new Error(`${ADAPTER}: \`google-auth-library\` is installed but exports no \`GoogleAuth\`. ` + `This adapter is built against the 11.x package — update it, or pass \`_client\`.`); } const source = await new mod.GoogleAuth({ scopes: [...scopes] }).getClient(); const impersonate = options.impersonate; if (impersonate === undefined) return source; if (typeof mod.Impersonated !== 'function') { throw new Error(`${ADAPTER}: 'impersonate' is configured but \`google-auth-library\` exports no ` + `\`Impersonated\`. Update the package, or drop 'impersonate' to vend with the ` + `environment's own identity.`); } if (typeof impersonate.targetPrincipal !== 'string' || impersonate.targetPrincipal === '') { throw new TypeError(`${ADAPTER}: 'impersonate.targetPrincipal' is required — the service account to act as, ` + `e.g. 'agent-runner@my-project.iam.gserviceaccount.com'.`); } return new mod.Impersonated({ sourceClient: source, targetPrincipal: impersonate.targetPrincipal, targetScopes: [...scopes], ...(impersonate.delegates !== undefined && { delegates: [...impersonate.delegates] }), ...(impersonate.lifetimeSeconds !== undefined && { lifetime: impersonate.lifetimeSeconds }), }); } function loadAuthSdk(injected) { if (injected) return injected; try { return (0, lazyRequire_js_1.lazyRequire)('google-auth-library'); } catch { throw new Error(`${ADAPTER} requires the \`google-auth-library\` peer dependency.\n` + ` Install: npm install google-auth-library\n` + ` It is optional and loaded only when this provider first vends, so nothing else in ` + `this library pays for it.`); } } /** * "There is no usable credential here" — the most common real failure, given * the fix instead of the library's own text. */ function noCredentials(err) { const name = errorName(err); const failure = new Error(`${ADAPTER}: could not resolve a Google credential in this environment — ${name}.\n` + ` The underlying message is withheld: auth libraries echo file paths and request ` + `detail into failure text, and this message reaches the model as a tool result.\n` + ` On Cloud Run / GKE / GCE the runtime service account is used automatically. ` + `Elsewhere, run \`gcloud auth application-default login\`, or point ` + `GOOGLE_APPLICATION_CREDENTIALS at a service-account key or a workload-identity ` + `config file.`); failure.name = 'GoogleCredentialsUnavailableError'; return failure; } /** Re-raise without the library's text. See the module header for why. */ function sdkFailure(operation, err) { const failure = new Error(`${ADAPTER}: ${operation} failed — ${errorName(err)}.\n` + ` The underlying message is withheld: this call handles an access token, and auth ` + `libraries echo request detail into failure text. Check Cloud Logging for the full error.`); failure.name = 'GoogleCredentialError'; return failure; } /** * Did THIS adapter write this error? * * Every refusal authored here opens with the adapter's own name — both as the * marker and because it is what makes the message readable ("googleIdentity: * …", "googleIdentity requires …"). A library's own failure never does, so the * prefix is a reliable discriminator without an error subclass per refusal. */ function isOwnRefusal(err) { return err instanceof Error && err.message.startsWith(ADAPTER); } function errorName(err) { const name = err?.name; return typeof name === 'string' && name.length > 0 ? name : 'an unnamed failure'; } //# sourceMappingURL=google.js.map