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
20 changes: 20 additions & 0 deletions sandbox-agent/docs/content/docs/sdk-overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,26 @@ await sdk.destroySandbox(); // provider-defined cleanup + disposes client

Use `sdk.dispose()` to disconnect without changing sandbox state, `sdk.pauseSandbox()` for graceful suspension when supported, or `sdk.killSandbox()` for permanent deletion.

## Adapter authentication

Both `SandboxAgent.connect(...)` and `SandboxAgent.start(...)` accept an optional `authentication` setting. Its `selectMethod(agent, methods)` callback receives the agent name and advertised methods before a session is created. Return the selected request, including any required metadata, or `undefined` to skip authentication. The callback may be asynchronous.

```ts
import { SandboxAgent } from "sandbox-agent";

const sdk = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
authentication: {
selectMethod: (_agent, methods) => {
const method = methods.find((method) => method.id === "api-key");
return method ? { methodId: method.id } : undefined;
},
},
});
```

Use `authentication.clientCapabilities` when an adapter requires capabilities to advertise a custom gateway login. Authentication errors and callback failures propagate to the caller. Without a selector, the SDK retains its best-effort automatic selection of the legacy environment-backed methods. Credentials needed by an environment-backed method must already be available to the adapter process.

## Session flow

```ts
Expand Down
9 changes: 9 additions & 0 deletions sandbox-agent/research/acp/friction.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ Update this file continuously during the migration.

## Entries

- Date: 2026-10-18
- Area: High-level SDK authentication
- Issue: Adapters can advertise authentication IDs outside the SDK's legacy list, and custom gateway methods may depend on initialization capabilities.
- Impact: Applications cannot authenticate those adapters through the high-level SDK.
- Proposed direction: Add optional initialization capabilities and an asynchronous method selector, retaining legacy automatic selection when no selector is supplied.
- Decision: Explicit selection errors propagate and close the failed connection; returning undefined skips authentication without fallback.
- Status: in_progress
- Links: https://github.com/rivet-dev/agents/issues/313

- Date: 2026-02-10
- Area: Agent process availability
- Issue: Amp does not have a confirmed official ACP agent process in current ACP docs/research.
Expand Down
30 changes: 28 additions & 2 deletions sandbox-agent/sdks/typescript/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
type AcpEnvelopeDirection,
type AnyMessage,
type AuthMethod,
type AuthenticateRequest,
type InitializeRequest,
type CancelNotification,
type NewSessionRequest,
type NewSessionResponse,
Expand Down Expand Up @@ -130,7 +132,15 @@ export interface SandboxAgentHealthWaitOptions {
timeoutMs?: number;
}

export interface SandboxAgentAuthenticationOptions {
/** Capabilities sent while initializing each adapter connection. */
clientCapabilities?: InitializeRequest["clientCapabilities"];
/** Select an advertised authentication method. Return undefined to skip authentication. Errors propagate to the caller. */
selectMethod?: (agent: string, methods: readonly AuthMethod[]) => AuthenticateRequest | undefined | Promise<AuthenticateRequest | undefined>;
}

interface SandboxAgentConnectCommonOptions {
authentication?: SandboxAgentAuthenticationOptions;
headers?: HeadersInit;
persist?: SessionPersistDriver;
replayMaxEvents?: number;
Expand All @@ -153,6 +163,7 @@ export type SandboxAgentConnectOptions =
});

export interface SandboxAgentStartOptions {
authentication?: SandboxAgentAuthenticationOptions;
sandbox: SandboxProvider;
sandboxId?: string;
skipHealthCheck?: boolean;
Expand Down Expand Up @@ -488,6 +499,7 @@ export class LiveAcpConnection {
headers?: HeadersInit;
agent: string;
serverId: string;
authentication?: SandboxAgentAuthenticationOptions;
onObservedEnvelope: (connection: LiveAcpConnection, envelope: AnyMessage, direction: AcpEnvelopeDirection, localSessionId: string | null) => void;
onPermissionRequest: (
connection: LiveAcpConnection,
Expand Down Expand Up @@ -535,13 +547,23 @@ export class LiveAcpConnection {

const initResult = await acp.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: options.authentication?.clientCapabilities,
clientInfo: {
name: "sandbox-agent-sdk",
version: "v1",
},
});
if (initResult.authMethods && initResult.authMethods.length > 0) {
await autoAuthenticate(acp, initResult.authMethods);
try {
const methods = initResult.authMethods ?? [];
if (options.authentication?.selectMethod) {
const request = await options.authentication.selectMethod(options.agent, methods);
if (request) await acp.authenticate(request);
} else if (methods.length > 0) {
await autoAuthenticate(acp, methods);
}
} catch (error) {
await acp.disconnect().catch(() => {});
throw error;
}
return live;
}
Expand Down Expand Up @@ -883,6 +905,7 @@ export class SandboxAgent {
private readonly token?: string;
private readonly fetcher: typeof fetch;
private readonly defaultHeaders?: HeadersInit;
private readonly authentication?: SandboxAgentAuthenticationOptions;
private readonly healthWait: NormalizedHealthWaitOptions;
private readonly healthWaitAbortController = new AbortController();
private sandboxProvider?: SandboxProvider;
Expand Down Expand Up @@ -921,6 +944,7 @@ export class SandboxAgent {
}
this.fetcher = resolvedFetch;
this.defaultHeaders = options.headers;
this.authentication = options.authentication;
this.healthWait = normalizeHealthWaitOptions(options.skipHealthCheck, options.waitForHealth, options.signal);
this.persist = options.persist ?? new InMemorySessionPersistDriver();

Expand Down Expand Up @@ -963,6 +987,7 @@ export class SandboxAgent {
const inspectorUrl = provider.getInspectorUrl ? await provider.getInspectorUrl(rawSandboxId, baseUrl) : undefined;
const providerFetch = options.fetch ?? fetcher;
const commonConnectOptions = {
authentication: options.authentication,
headers: options.headers,
persist: options.persist,
replayMaxEvents: options.replayMaxEvents,
Expand Down Expand Up @@ -2033,6 +2058,7 @@ export class SandboxAgent {
headers: this.defaultHeaders,
agent,
serverId,
authentication: this.authentication,
onObservedEnvelope: (connection, envelope, direction, localSessionId) => {
void this.enqueueObservedEnvelopePersistence(connection, envelope, direction, localSessionId).catch((error) => {
console.error("Failed to persist observed sandbox-agent envelope", error);
Expand Down
1 change: 1 addition & 0 deletions sandbox-agent/sdks/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type {
} from "./desktop-stream.ts";

export type {
SandboxAgentAuthenticationOptions,
SandboxAgentHealthWaitOptions,
AgentQueryOptions,
DesktopStreamSessionOptions,
Expand Down
116 changes: 116 additions & 0 deletions sandbox-agent/sdks/typescript/tests/authentication.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AuthMethod } from "acp-http-client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SandboxAgent } from "../src/index.ts";
import { local } from "../src/providers/local.ts";
import { prepareMockAgentDataHome } from "./helpers/mock-agent.ts";

describe("adapter authentication over the real server", () => {
let sdk: SandboxAgent | undefined;
let dataHome: string | undefined;

afterEach(async () => {
await sdk?.destroySandbox();
if (dataHome) await rm(dataHome, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
sdk = undefined;
dataHome = undefined;
});

async function start(
agent: Parameters<typeof prepareMockAgentDataHome>[1],
authentication?: NonNullable<Parameters<typeof SandboxAgent.start>[0]["authentication"]>,
) {
dataHome = mkdtempSync(join(tmpdir(), "sandbox-agent-auth-"));
const env = prepareMockAgentDataHome(dataHome, agent);
sdk = await SandboxAgent.start({ sandbox: local({ env, log: "pipe", token: "test-server-token" }), authentication });
return sdk;
}

it("selects an advertised method not in the legacy hard-coded list", async () => {
const selectMethod = vi.fn(async (_agent: string, methods: readonly AuthMethod[]) => ({ methodId: methods[0].id }));
const client = await start({ methodId: "api-key" }, { selectMethod });

const session = await client.createSession({ agent: "mock" });

expect(session.agent).toBe("mock");
expect(selectMethod).toHaveBeenCalledWith("mock", [{ id: "api-key", name: "Test authentication" }]);
await client.createSession({ agent: "mock" });
expect(selectMethod).toHaveBeenCalledOnce();
});

it("advertises gateway capabilities and sends the selected method metadata", async () => {
const selectMethod = vi.fn((_agent: string, methods: readonly AuthMethod[]) => ({
methodId: methods[0].id,
_meta: { gateway: { baseUrl: "https://gateway.example.com", headers: { Authorization: "Bearer test-gateway-token" } } },
}));
const client = await start(
{ methodId: "gateway", gateway: true },
{
clientCapabilities: { auth: { _meta: { gateway: true } } },
selectMethod,
},
);

const session = await client.createSession({ agent: "mock" });

expect(session.agent).toBe("mock");
expect(selectMethod).toHaveBeenCalledWith("mock", [{ id: "gateway", name: "Test authentication" }]);
});

it("surfaces a selected method's authentication failure", async () => {
const client = await start(
{ methodId: "api-key", reject: true },
{
selectMethod: () => ({ methodId: "api-key" }),
},
);

await expect(client.createSession({ agent: "mock" })).rejects.toThrow("Test authentication rejected");
});

it("preserves automatic authentication when no selector is supplied", async () => {
const client = await start({ methodId: "anthropic-api-key" });

const session = await client.createSession({ agent: "mock" });

expect(session.agent).toBe("mock");
});

it("allows a selector to skip authentication without falling back to a legacy method", async () => {
const client = await start({ methodId: "anthropic-api-key" }, { selectMethod: () => undefined });

await expect(client.createSession({ agent: "mock" })).rejects.toThrow("Authentication required");
});

it("preserves an error thrown by an application's selector", async () => {
const failure = new Error("Application authentication unavailable");
const client = await start(
{ methodId: "api-key" },
{
selectMethod: () => {
throw failure;
},
},
);

await expect(client.createSession({ agent: "mock" })).rejects.toBe(failure);
});

it("uses the same selection options when connecting to an existing server", async () => {
const owner = await start({ methodId: "api-key" });
const client = await SandboxAgent.connect({
baseUrl: new URL(owner.inspectorUrl).origin,
token: "test-server-token",
authentication: { selectMethod: (_agent, methods) => ({ methodId: methods[0].id }) },
});
try {
const session = await client.createSession({ agent: "mock" });
expect(session.agent).toBe("mock");
} finally {
await client.dispose();
}
});
});
24 changes: 23 additions & 1 deletion sandbox-agent/sdks/typescript/tests/helpers/mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function candidateInstallDirs(dataHome: string): string[] {
return dirs;
}

export function prepareMockAgentDataHome(dataHome: string): Record<string, string> {
export function prepareMockAgentDataHome(dataHome: string, authentication?: { methodId: string; gateway?: boolean; reject?: boolean }): Record<string, string> {
const runtimeEnv: Record<string, string> = {};
if (process.platform === "darwin") {
runtimeEnv.HOME = dataHome;
Expand All @@ -31,6 +31,8 @@ const { createInterface } = require("node:readline");
let nextSession = 0;
let nextPermission = 0;
const pendingPermissions = new Map();
const authentication = ${JSON.stringify(authentication ?? null)};
let authenticated = !authentication;

function emit(value) {
process.stdout.write(JSON.stringify(value) + "\n");
Expand Down Expand Up @@ -173,6 +175,11 @@ rl.on("line", (line) => {
id: msg.id,
result: {
protocolVersion: 1,
...(authentication ? {
authMethods: !authentication.gateway || msg.params?.clientCapabilities?.auth?._meta?.gateway === true
? [{ id: authentication.methodId, name: "Test authentication" }]
: [],
} : {}),
capabilities: {},
serverInfo: {
name: "mock-acp-agent",
Expand All @@ -183,7 +190,22 @@ rl.on("line", (line) => {
return;
}

if (method === "authenticate") {
if (authentication && (authentication.reject || msg.params?.methodId !== authentication.methodId ||
(authentication.gateway && msg.params?._meta?.gateway?.headers?.Authorization !== "Bearer test-gateway-token"))) {
emit({ jsonrpc: "2.0", id: msg.id, error: { code: -32000, message: "Test authentication rejected" } });
return;
}
authenticated = true;
emit({ jsonrpc: "2.0", id: msg.id, result: {} });
return;
}

if (method === "session/new") {
if (!authenticated) {
emit({ jsonrpc: "2.0", id: msg.id, error: { code: -32000, message: "Authentication required" } });
return;
}
nextSession += 1;
emit({
jsonrpc: "2.0",
Expand Down