Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/ai-byok-credential-cas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@tanstack/ai': minor
---

`CredentialStore` can now do compare-and-set writes. It has two new optional methods, `getVersioned` and `setIf`, with the same shape as the pair on `MetadataStore`. A store without them still works. `memoryCredentialStore` has both.

The new `readCredentialEntry` in `@tanstack/ai/byok/server` reads a credential like `readCredential`. It also tells you which scope had the credential (the user or the tenant) and gives its revision when the store has `getVersioned`.

`scrubSecrets` from `@tanstack/ai/byok` now also removes the JSON-escaped and URL-encoded forms of each secret. It skips a secret that is shorter than 6 characters, so a short value does not remove common words.
1 change: 1 addition & 0 deletions .changeset/ai-harness-transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ Serve a harness session to clients.
- `expose` on `defineHarness()` names the agents, settings, config keys, and commands that a client can use. Any other input of those kinds gets `rejected` with `not_exposed`.
- `createHarnessClient()` from `@tanstack/ai-harness/client` is a typed, browser-safe client. It sends inputs, reads the session, and streams events that resume from a cursor.
- `oauthConnector()` and the OAuth helpers from `@tanstack/ai-harness/oauth` (`loopbackLogin`, `deviceLogin`, `exchangeCode`, `refreshCredential`, and more) keep a credential per user. A tool calls `token({ run: ctx.context.run })` for the token of the user of its run. `token({ run, wait: true })` stops the turn with an `auth_required` interrupt until the user signs in. `deviceLogin`, `loopbackLogin`, `startLoopbackReceiver`, `exchangeCode`, and `refreshCredential(config, credential, { fetch, signal })` stop at once when their `signal` aborts, so a cancel or a session close stops a running `/connect`. `OAuthConfig.issuer` makes `loopbackLogin` refuse a code from another issuer (RFC 9207). The loopback deadline (`timeoutMs`) does not keep the process alive.
- `run.credentials.refresh(id, update, { signal })` refreshes a saved credential once for all callers on a host, keyed by tenant, user, and id. It saves with `setIf` when the store has `getVersioned` and `setIf`, and uses the other host's token on a conflict. Without that pair, it does a plain `set`. It saves to the scope where it found the credential, so a tenant token stays the tenant's. `update` gets a signal that only `host.close()` aborts. An abort of `signal` rejects only that call. `oauthConnector` refreshes this way, and `refreshCredential` keeps the `client` of the old credential.
2 changes: 1 addition & 1 deletion .changeset/ai-mcp-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Use MCP in a TanStack AI harness.
- `@tanstack/ai-mcp/harness` adds `createHarnessMcpServer`. It serves a harness as an MCP server, so Claude Code, Claude Desktop, Cursor, or another agent can use it. The tools are `chat`, `steer`, `cancel`, `approve`, `reject`, `resolve`, `answer`, and `status`, plus `agent_<name>` for each exposed agent and `command_<name>` for each exposed command. Every tool takes an optional `threadId`. With `approvals: 'ask'` (the default), the client asks the user about each approval when it supports elicitation. Otherwise the approvals come back in the result. `approvals: 'auto'` approves every tool call. `resolve` answers every kind of interrupt in one call.
- On a host with `access`, `createHarnessMcpServer` reads the user of each HTTP request with `identify`. The host checks each tool call of that user. A host without `access` needs `openAccess: true` (no user) or `openAccess: { principal }` (one local user), else the server throws at the start. Each tool call releases its session handle when it ends, so the session closes on idle. The `status` tool and the media reads read the thread with `host.observe`, so they set up no plugins.
- `@tanstack/ai-mcp/harness` also adds the `mcp()` harness plugin. It connects stdio and HTTP servers by name, gives the model their tools as `<server>_<tool>`, and adds a `/mcp` status command. A stdio server starts once per host, and every session of the host shares it. `oauth: true` signs in to a server with `/connect <name>`, for each user.
- `@tanstack/ai-mcp/connector` adds `mcpConnector`: one OAuth MCP server as a harness plugin, with `needsApproval`, `prefix`, `scopes`, and `clientName`. The sign-in passes the `iss` of the callback on (RFC 9207). A cancel or a session close stops a running sign-in at once: the loopback stops listening, and the OAuth requests abort.
- `@tanstack/ai-mcp/connector` adds `mcpConnector`: one OAuth MCP server as a harness plugin, with `needsApproval`, `prefix`, `scopes`, and `clientName`. The sign-in passes the `iss` of the callback on (RFC 9207). A cancel or a session close stops a running sign-in at once: the loopback stops listening, and the OAuth requests abort. When the MCP SDK refreshes a token, the connector saves it with `credentials.refresh` of the harness: to the scope of the sign-in, and with compare-and-set when the store has it. Each session still sends its own refresh request. When the server refuses a token, the connector deletes the saved sign-in only if it is the token that this session used.
- `mcp()` and `mcpConnector` answer the `elicitation/create` requests of MCP spec 2025 servers, and the input requests of spec 2026-07-28 servers, as session questions. One tool call can ask up to 5 times.

`@tanstack/ai-harness` is an optional peer dependency. Only the `./harness` and `./connector` entries need it.
10 changes: 10 additions & 0 deletions .changeset/harness-redaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tanstack/ai-harness': minor
---

Keep the secrets of a session out of what it keeps and sends.

- Each session replaces every secret that it gave out with `[redacted]`. These are the values of `run.keys` (also from env vars) and `run.credentials`, connector credentials and OAuth tokens, and secret answers. It scrubs log records, feed events, plugin state, notes, the model context, tool results, and durable step results. It also scrubs the error frames of the HTTP handler, the WebSocket, and the stdio worker. It finds the raw, JSON-escaped, and URL-encoded forms, and skips values shorter than 6 characters. A secret split across two streaming deltas stays in those two events, and a secret that is JSON-escaped two times is not found.
- `scrubSecrets` from `@tanstack/ai-harness` is the one from `@tanstack/ai/byok`.
- The log keeps the final result of each tool call: the result after every `onAfterToolCall` hook, scrubbed. A replacement from a hook wins, also on an error. Recovery after a crash gives the model that result.
- An answer to a `secret: true` question that reaches a host that does not own the log goes through the `credentials` store of the host, as a one-time reference. Only the reference goes into the input lane, and the owner reads and deletes it. Without `createHarnessHost({ credentials })`, the answer is rejected with `secret_answer_needs_credentials`. A used or unknown reference is rejected with `unknown_secret_answer`. `credentials.list` does not show the references.
53 changes: 51 additions & 2 deletions docs/advanced/server-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ Your users now save a key once, and every device and job uses it.

## Write a store for production

A `CredentialStore` has four methods. You decide where the secrets live:
A `CredentialStore` has four methods, and two optional ones for [compare-and-set](#add-compare-and-set). You decide where the secrets live:

- A `credentials` table in the database that you already use, next to your chat tables.
- A secrets manager, for example AWS Secrets Manager or Vault.
Expand Down Expand Up @@ -181,6 +181,17 @@ interface CredentialStore {
list(
scope: CredentialScope,
): Promise<Array<{ id: string; type: Credential["type"]; expiresAt?: number }>>;
// Optional: compare-and-set.
getVersioned?(key: {
scope: CredentialScope;
id: string;
}): Promise<{ credential: Credential; revision: string } | null>;
setIf?(entry: {
scope: CredentialScope;
id: string;
credential: Credential;
expectedRevision: string | null;
}): Promise<{ ok: true; revision: string } | { ok: false; reason: "conflict" }>;
}
```

Expand All @@ -198,6 +209,44 @@ Rules for the secrets (`value`, `accessToken`, `refreshToken`, `client.clientSec
- Encrypt them at rest. Keep the encryption key in a KMS or a server-only env var, not in the same database.
- `list` returns ids and types only. It never returns a secret, not even masked.
- `delete` removes the row. Do not keep a soft-deleted copy of the secret.
- Do not write a secret to a log, an error message, or a trace.
- Do not write a secret to a log, an error message, or a trace. To remove known secrets from a text, use `scrubSecrets(text, secrets)` from `@tanstack/ai/byok`.

The [harness](../harness/provider-keys) uses the same store: pass it to `createHarnessHost({ credentials })`.

### Add compare-and-set

Two servers can refresh the same OAuth token at the same time. If the service rotates refresh tokens, the second save then keeps a token that no longer works. Add `getVersioned` and `setIf`, so the second save does not remove the first:

- `getVersioned` returns the credential and its revision, or `null` when the key is absent.
- `setIf` writes only when the stored revision equals `expectedRevision`. Pass `null` to write only when the key is absent. Success returns `{ ok: true, revision }`. A different revision returns `{ ok: false, reason: "conflict" }` and does not write.
- Every write gives the key a new revision. A plain `set` does this too. After `delete`, the deleted revision is never used again.
- Add both methods or neither. `memoryCredentialStore()` has both.

To refresh a token in your own route, read it with `readCredentialEntry`. It finds the credential like `readCredential` (the user first, then the tenant), and also returns the scope where it found it and its `revision`:

```typescript
import { readCredentialEntry } from "@tanstack/ai/byok/server";
import type { Credential, CredentialStore } from "@tanstack/ai/byok";

export async function refreshGithub(
store: CredentialStore,
scope: { tenantId: string; userId: string },
refresh: (current: Credential) => Promise<Credential>,
) {
const entry = await readCredentialEntry(store, scope, "github");
if (!entry) return null;
const next = await refresh(entry.credential);
// Save to the scope where the credential was, also when it is the tenant's.
const key = { scope: entry.scope, id: "github", credential: next };
if (!store.setIf || entry.revision === undefined) {
await store.set(key);
return next;
}
const saved = await store.setIf({ ...key, expectedRevision: entry.revision });
if (saved.ok) return next;
// Another server saved first. Use its token, and do not refresh again.
return (await readCredentialEntry(store, scope, "github"))?.credential ?? null;
}
```

In a harness, `run.credentials.refresh` does this for you. See [Refresh a token](../harness/auth#refresh-a-token).
7 changes: 5 additions & 2 deletions docs/api/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -567,9 +567,12 @@ import { maskKey } from "@tanstack/ai/byok";
maskKey("sk-abcdefghij"); // "ghij"
```

## `scrubSecrets(input, secrets)`
## `scrubSecrets(text, secrets)`

Replace each listed secret in `input` with `[redacted]`. Use this before you log an error string.
Replace each listed secret in `text` with `[redacted]`. Use this before you log an error string.

- It also replaces the JSON-escaped and the URL-encoded form of each secret.
- It skips a secret shorter than 6 characters, so a short value does not remove common words.

```typescript
import { scrubSecrets } from "@tanstack/ai/byok";
Expand Down
5 changes: 3 additions & 2 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -1366,7 +1366,8 @@
{
"label": "Keep Keys on the Server",
"to": "advanced/server-keys",
"addedAt": "2026-10-16"
"addedAt": "2026-10-16",
"updatedAt": "2026-10-17"
},
{
"label": "Debug Logging",
Expand Down Expand Up @@ -1716,7 +1717,7 @@
"label": "@tanstack/ai",
"to": "api/ai",
"addedAt": "2026-04-15",
"updatedAt": "2026-10-13"
"updatedAt": "2026-10-17"
},
{
"label": "@tanstack/ai-client",
Expand Down
72 changes: 62 additions & 10 deletions docs/harness/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const assistant = defineHarness({
})
```

`token({ run })` returns a fresh access token of the user of that run. It refreshes an expired token when the service gave a refresh token. Pass the `run` of the tool call, `ctx.context.run`, so each user gets their own token. See [Act as the user of a run](./plugins#act-as-the-user-of-a-run).
`token({ run })` returns a fresh access token of the user of that run. If the token is expired and the service gave a refresh token, `token` refreshes it first, one time for all the calls that need it. See [Refresh a token](#refresh-a-token). Pass the `run` of the tool call, `ctx.context.run`, so each user gets their own token. See [Act as the user of a run](./plugins#act-as-the-user-of-a-run).

## 2. Sign in

Expand Down Expand Up @@ -189,6 +189,7 @@ The store can be a table in your own database or a secrets manager. See the rule
- Encrypt tokens at rest in a real store.
- `list` returns ids and types only, never the secret values.
- A credential saved without a `userId` belongs to the whole tenant. A user who has no credential of their own uses it. See [Share one credential with the whole organization](./shared-threads#share-one-credential-with-the-whole-organization).
- If more than one host uses the store, add `getVersioned` and `setIf`. Then two hosts do not both save a refreshed token. See [Add compare-and-set](../advanced/server-keys#add-compare-and-set).

## Read credentials in your own plugin

Expand All @@ -200,6 +201,31 @@ Your plugin reads the credentials of the user of each run:

See [Act as the user of a run](./plugins#act-as-the-user-of-a-run).

## Keep tokens out of the log and the model

A tool can show a token by mistake: an error message with the request URL, or a debug field in its result. The session replaces each secret that it gave out with `[redacted]` before it keeps or sends anything.

The secrets that the session knows:

- The provider keys of `run.keys`, also a key from an env var.
- The credentials of `run.credentials` and of the connectors: API keys, OAuth tokens, and the OAuth client secret.
- The answer to a question with `secret: true`.

Where the session replaces them:

- The session log: your records, the transcript, plugin state, notes, tool results, and durable step results.
- The events that clients get.
- The model context, so the model never sees a secret.
- The error frames of the HTTP handler, the WebSocket, and the stdio worker.

The session finds the raw value, its JSON-escaped form, and its URL-encoded form. It does not find:

- A value shorter than 6 characters. So a short value does not remove common words.
- A secret that a stream splits across two deltas, in those two events. The message in the transcript does not have the secret.
- A secret that is JSON-escaped two times.

To scrub a text of your own, use `scrubSecrets(text, secrets)` from `@tanstack/ai/byok`. It uses the same rules.

## Write your own sign-in command

`oauthConnector` covers most services. For a sign-in of your own, use the helpers from `@tanstack/ai-harness/oauth`. Pass the `signal` of the command, so a cancel stops the sign-in at once:
Expand Down Expand Up @@ -238,7 +264,16 @@ export const gitlabLogin = definePlugin({
})
```

A tool refreshes an expired token with the `signal` of its run, so the refresh stops with the run:
- `deviceLogin`, `loopbackLogin`, `exchangeCode`, and `refreshCredential` take a `signal`. When it aborts, the call stops at once and rejects with the reason of the signal.
- The `signal` of a command aborts on a cancel of the command and on a close of the session.
- `loopbackLogin` takes `timeoutMs`. The default is 10 minutes. This timer does not keep the process alive.
- When another library runs the OAuth flow, `startLoopbackReceiver({ timeoutMs, signal })` only waits for the callback. `waitForCode(state)` gives the code and the `iss` of the callback.

### Refresh a token

An access token expires, and several tool calls can need it at the same time. Some services rotate the refresh token: after one refresh, the old refresh token stops working. Then a second refresh at the same time fails, and the user must sign in again.

`run.credentials.refresh(id, update, { signal })` runs one refresh, and every caller gets its result:

```ts group=harness-auth-own
import { toolDefinition } from '@tanstack/ai'
Expand All @@ -251,8 +286,17 @@ export const gitlabProjects = toolDefinition({
const { credentials, signal } = ctx.context.run
let credential = await credentials.require('gitlab')
if (isExpired(credential)) {
credential = await refreshCredential(gitlab, credential, { signal })
await credentials.set({ id: 'gitlab', credential })
const fresh = await credentials.refresh(
'gitlab',
// Another call can refresh first. Then keep its credential.
(current, hostSignal) =>
isExpired(current)
? refreshCredential(gitlab, current, { signal: hostSignal })
: Promise.resolve(current),
{ signal },
)
// The user disconnected meanwhile: ask for a sign-in.
credential = fresh ?? (await credentials.require('gitlab'))
}
const token = credential.type === 'api_key' ? credential.value : credential.accessToken
const response = await fetch('https://gitlab.com/api/v4/projects?membership=true', {
Expand All @@ -263,14 +307,22 @@ export const gitlabProjects = toolDefinition({
})
```

- `deviceLogin`, `loopbackLogin`, `exchangeCode`, and `refreshCredential` take a `signal`. When it aborts, the call stops at once and rejects with the reason of the signal.
- The `signal` of a command aborts on a cancel of the command and on a close of the session.
- `loopbackLogin` takes `timeoutMs`. The default is 10 minutes. This timer does not keep the process alive.
- When another library runs the OAuth flow, `startLoopbackReceiver({ timeoutMs, signal })` only waits for the callback. `waitForCode(state)` gives the code and the `iss` of the callback.
`update` gets the saved credential and returns the new one. Return `current` to keep it. `refresh` resolves with the credential to use, or `null` when none is saved. `oauthConnector` refreshes with `refresh` too.

What `refresh` does:

- One host runs one `update` at a time for each saved credential. A call that comes while one runs waits for it, and gets the same credential.
- It saves the new credential to the scope where it found it. A tenant token stays the tenant's, so every user of the tenant gets the new token.
- If the store has `getVersioned` and `setIf`, the save is a compare-and-set. When another host saved first, the call uses that token and does not refresh again.
- Without that pair, the one-at-a-time rule is per host only. Two hosts can refresh at the same time, and the last save wins.
- `update` gets a signal of the host, which only `host.close()` aborts. An abort of your `signal` rejects only your call. The refresh goes on for the other calls.

`refreshCredential` keeps the OAuth `client` of the old credential, so the next refresh has it too.

## What you have now

- Browser and device-code sign-in for any OAuth service.
- Browser and device-code sign-in for any OAuth service, and sign-ins of your own that stop at once on a cancel.
- Tools that get a fresh token without the model seeing it.
- One refresh for each expired token, also on many hosts.
- A log, events, and a model context with `[redacted]` in place of each secret.
- Turns that wait for a sign-in and then finish their work.
- Sign-ins of your own that stop at once on a cancel.
Loading
Loading