workbench Docs

Reference

Plugin context API

The ToolContext object every handler receives — userId, getToken, http, getConfig — what each does per auth type, and the host guards your plugin is responsible for.

Every tool handler is called as handler(ctx, args). ctx is a ToolContext, built fresh for the (userId, integration) pair before each execution.

ts
export interface ToolContext {
  userId: string;
  getToken(): Promise<string>;
  http(url: string, init?: RequestInit): Promise<Response>;
  // Per-connection config set at connect time (e.g. { instanceUrl } for a
  // self-hosted GitLab). Returns {} when none was stored.
  getConfig(): Record<string, unknown>;
}

That is the entire surface. There is no database handle, no filesystem, no logger, and no browser — the internal browser and jots plugins reach those from server source precisely so that plugins cannot.

MemberSync?Returns
userIdThe workbench user id as a string.
getToken()AsyncThe decrypted access token, refreshing it first if needed.
http(url, init?)AsyncA fetch Response, with the user's credential attached.
getConfig()SyncThe per-connection config object, or {}.

ctx.userId#

The id of the workbench user the tool is running for. Use it to key any per-user state your plugin keeps outside the workbench. Never use it as a credential — it is an identifier, not a secret.

ctx.getConfig()#

Synchronous, because the connection config is fetched and parsed while the context is being built. Returns the JSON object stored on the connection at connect time, or {} when nothing was stored or the stored JSON fails to parse (the parse failure is swallowed).

Two things populate it:

Auth typeContents
oauth2 with an instance block{ instanceUrl } — the origin the user chose.
apikeyEvery non-secret field from the manifest's fields[], keyed by field.key.

The GitLab plugin reads it on every call, defaulting to the cloud origin:

ts
function origin(ctx: any): string {
  const u = ctx.getConfig?.()?.instanceUrl;
  return typeof u === "string" && u ? u.replace(/\/+$/, "") : DEFAULT_ORIGIN;
}

New Relic reads region and accountId the same way — the secret field (apiKey) is not in getConfig(). The server stores it encrypted as the token and attached by ctx.http().

ctx.getToken()#

Returns the decrypted access token. The token is loaded lazily on first call and memoised for the life of the context, so calling it repeatedly in one handler is free.

  • Throws "Not connected" if no token is stored.
  • Refreshes when expiresAt - 30s <= now. The 30-second skew means a token about to expire mid-flight is refreshed rather than used.
  • Refresh is a form-encoded grant_type=refresh_token POST to the resolved token URL (the instance-swapped one, for self-hosted integrations). The rotated token is persisted, and the previous refresh_token is kept if the provider does not return a new one.
  • Refresh throws if there is no stored refresh_token, if the integration is not oauth2, or if no OAuth client is configured for it.

Most handlers never call getToken()ctx.http() does it for them. Call it directly only when you need the raw token in a payload, as github_get_clone_url does when minting an https://x-access-token:<token>@… URL.

ctx.http(url, init)#

A fetch wrapper that attaches the user's credential. Always use it instead of bare fetch — a direct fetch skips authentication, the cookie-domain allowlist, and the Atlassian cloud-id resolver.

Its behaviour branches on the integration's declared auth type.

Auth typeHost checkCredentialRedirects
apikeyOnly if allowedHosts is setheaders[headerName] = token, verbatim — no Bearer prefixfetch default
cookieMandatory, against targetDomain + cookieDomainsA single Cookie header, filtered by expiry and domainForced redirect: "manual"
oauth2 (and any other type)NoneAuthorization: Bearer <token>fetch default

apikey#

Loads the token, then — only if allowedHosts is non-empty — checks the URL host against it. Matching is case-insensitive, strips a leading dot, and accepts an exact host or any subdomain of a listed host. A violation throws API-key auth: URL host <host> not in declared allowedHosts.

The credential is then set as headers[headerName] = token with no transformation. If the API expects Authorization: Bearer <key>, bake the Bearer prefix into the value the user pastes, or pick a different header.

The host check here is mandatory and runs before anything is sent. The allowed set is targetDomain plus every entry of cookieDomains, dot-stripped and lowercased, matched as exact host or subdomain. A violation throws Cookie auth: URL host <host> not in declared cookieDomains.

Cookies are then filtered twice — dropping anything expired, then anything whose domain does not match the target host — joined into one Cookie header, and the request is issued with redirect: "manual" so a redirect can never carry the session cookie to another host. Your handler sees the 3xx itself.

oauth2#

Calls ctx.getToken() (refreshing if needed) and sets Authorization: Bearer <token>. If the URL matches https://api.atlassian.com/ex/(jira|confluence)/cloud-id/, the literal cloud-id segment is replaced with the real site id, resolved once from /oauth/token/accessible-resources and cached per (userId, product) in a process-global map for the lifetime of the process.

Security#

The oauth2 branch of `ctx.http()` applies no host guard at all

It sends Authorization: Bearer <user's token> to whatever URL you pass. If any part of that URL comes from tool arguments, an agent — or anyone who can influence the agent's input — can point it at a host they control and receive the user's access token. The same applies to apikey when allowedHosts is omitted. Guarding the host is the plugin's job.

Two shipped plugins accept a URL argument, and both guard it. Copy whichever pattern fits.

Allowlist the host, then drop the credential across redirects#

slack_download_file takes a Slack file URL. It validates the scheme and host before the credentialed request, then walks redirects by hand so the token never follows one off Slack:

ts
function isSlackHost(host: string): boolean {
  const h = host.toLowerCase().replace(/\.$/, "");
  return h === "slack.com" || h === "files.slack.com" || h.endsWith(".slack.com");
}
ts
if (target.protocol !== "https:" || !isSlackHost(target.hostname)) {
  return { ok: false, error: "invalid_host" };
}

let url = target.toString();
let onSlack = true;
for (let hop = 0; hop < 5; hop++) {
  const hopRes = onSlack
    ? await ctx.http(url, { redirect: "manual" })
    : await fetch(url, { redirect: "manual" });
  // …
  onSlack = isSlackHost(next.hostname);
}

The loop caps the chain at five requests, the first of which is the original fetch — so at most four redirects are followed.

Three things make this work: the host is checked before the first credentialed call, redirect: "manual" keeps the hop loop in the plugin's hands, and the moment the next hop leaves Slack the request switches to a plain fetch with no Authorization header. It also rejects non-https: redirect targets, and treats a text/html response as not_authed_or_not_found rather than handing back Slack's login page as file bytes.

Block private address ranges#

google_drive_upload_from_url fetches an arbitrary user-supplied URL. It cannot allowlist a host, so it blocks the SSRF-relevant ranges instead:

ts
const parsed = new URL(args.url);
if (parsed.protocol !== "https:") {
  throw new Error("Only https:// URLs are allowed");
}
const host = parsed.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
const privateRange =
  /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0$)/;
const privateIPv6 = /^(::1$|fc|fd|fe80)/i;
if (host === "localhost" || privateRange.test(host) || privateIPv6.test(host)) {
  throw new Error("Requests to private or internal addresses are not allowed");
}
Known limitation of the private-range check

This is a string test against the hostname, not against the address the name resolves to. A public DNS name that resolves to 127.0.0.1 or an RFC-1918 address passes it. Prefer a host allowlist whenever the set of legitimate destinations is known.

Practical rules#

  • If every destination is known in advance, hardcode the base URL in the handler and take only path components as arguments.
  • If a URL must be an argument, validate scheme and host before the first call that carries a credential.
  • For apikey plugins, set allowedHosts in the manifest. It is the only declarative guard available, and New Relic is the shipped precedent — allowedHosts: ["api.newrelic.com", "api.eu.newrelic.com"].
  • Never interpolate an argument into the host portion of a URL template.