Videos and audio can be hundreds of megabytes. Artifacts only travelled as base64 inside RPC calls, capped at 10 MiB and held whole in memory, so media could neither be uploaded, played with seeking nor produced by media jobs. - ArtifactStore gains stat, createReadStream (inclusive byte ranges), putStream (size cap enforced while writing, real SHA-256) and an optional localPath so ffmpeg can read stored files in place. LocalArtifactStore implements them with the same safety as before (O_EXCL, O_NOFOLLOW, 0600) and now validates space ids and keys before building paths. - storeArtifactStream / storeArtifactFromFile store a stream or a job's output file as an artifact row, with width, height, duration and parent, and remove the blob again if the row cannot be written. - GET /api/artifacts/:id serves an artifact with Range/206/416, ETag, inline disposition for media and attachment otherwise (safe RFC 6266 filenames), nosniff and same-origin CORP. Authorization matches artifacts.get: the owner's artifact of an active bot thread or group, with membership of the artifact's space (media elements cannot send the space header; an explicit header must name the same space). - POST /api/artifacts takes a raw streamed body with the target and name in the query, authorized like artifacts.create, with the MIME allowlist and the per-kind caps checked from Content-Length and again while writing. - The API CSP allows media from the app origin and blob URLs. - Web: artifactUrl(artifactId) and uploadArtifactFile for the UI. Tests cover the streaming store, range parsing, filename sanitizing, the file helper, and a Postgres suite for both routes: ranges, caching, other users, shared-space members, other spaces, archived threads, signed-out requests and upload limits including chunked bodies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
463 lines
16 KiB
TypeScript
463 lines
16 KiB
TypeScript
import type {
|
|
AdapterContext,
|
|
AdapterDescriptor,
|
|
AgentRunRequest,
|
|
AgentRuntimeCapabilities,
|
|
AgentRuntimeEvent,
|
|
ArtifactByteRange,
|
|
ArtifactPut,
|
|
ArtifactStreamPut,
|
|
BackgroundJob,
|
|
BackgroundJobHandlers,
|
|
BrowserActRequest,
|
|
BrowserActResult,
|
|
BrowserCapabilities,
|
|
BrowserNavigateRequest,
|
|
BrowserNavigateResult,
|
|
BrowserSnapshotRequest,
|
|
BrowserSnapshotResult,
|
|
CloudAgentCapabilities,
|
|
CloudAgentHandle,
|
|
CloudAgentLaunchRequest,
|
|
CloudAgentReplyRequest,
|
|
CloudAgentSnapshot,
|
|
CommandRequest,
|
|
ComputerActionRequest,
|
|
ComputerActionResult,
|
|
ComputerFileEntry,
|
|
ComputerInput,
|
|
ComputerObservation,
|
|
ComputerRef,
|
|
ConnectorCall,
|
|
ConnectorCapabilities,
|
|
ConnectorCatalogItem,
|
|
ConnectorEvent,
|
|
ConnectorTool,
|
|
ControlLeaseRef,
|
|
EmbeddingCapabilities,
|
|
MemoryCapabilities,
|
|
MemoryCommitRequest,
|
|
MemoryExportRequest,
|
|
MemoryReadRequest,
|
|
MemoryRevision,
|
|
MemorySearchRequest,
|
|
MemorySearchResult,
|
|
MemorySnapshot,
|
|
MessagingInboundEvent,
|
|
MessagingPlatformDescriptor,
|
|
MessagingSendRequest,
|
|
MessagingSendResult,
|
|
NotificationMessage,
|
|
PageBrowserCommand,
|
|
PageBrowserResult,
|
|
PortableFile,
|
|
ProcessEvent,
|
|
SandboxCapabilities,
|
|
ScreenRequest,
|
|
ScreenSession,
|
|
SecretRecord,
|
|
SemanticMemoryCapabilities,
|
|
SemanticMemoryForgetRequest,
|
|
SemanticMemoryPurgeHistoryRequest,
|
|
SemanticMemoryRecallRequest,
|
|
SemanticMemoryResponse,
|
|
SemanticMemoryResult,
|
|
SemanticMemorySaveRequest,
|
|
SnapshotRef,
|
|
SpeechClip,
|
|
StoredArtifactStream,
|
|
TransactionalEmail,
|
|
VoiceCapabilities,
|
|
VoiceInfo,
|
|
VoiceSynthesizeRequest,
|
|
VoiceTranscribeRequest,
|
|
VoiceVerifyResult,
|
|
WebFetchCapabilities,
|
|
WebFetchRequest,
|
|
WebFetchResult,
|
|
WebSearchCapabilities,
|
|
WebSearchHit,
|
|
WebSearchRequest,
|
|
} from "./types.js";
|
|
|
|
export interface SandboxProvider {
|
|
describe(): AdapterDescriptor<SandboxCapabilities>;
|
|
/** Optional live browser on the same leased screen as observe/act. */
|
|
pageBrowser?(
|
|
computer: ComputerRef,
|
|
request: PageBrowserCommand,
|
|
context: AdapterContext,
|
|
): Promise<PageBrowserResult>;
|
|
/** Allocate or reconnect the computer, returning its reference before fallible setup. */
|
|
provision(
|
|
request: {
|
|
botId: string;
|
|
homePath: string;
|
|
providerRef?: string;
|
|
providerKind?: ComputerRef["kind"];
|
|
},
|
|
context: AdapterContext,
|
|
): Promise<ComputerRef>;
|
|
/** Perform idempotent provider setup after the lifecycle has captured the reference. */
|
|
prepare(computer: ComputerRef, context: AdapterContext): Promise<void>;
|
|
execute(
|
|
computer: ComputerRef,
|
|
request: CommandRequest,
|
|
context: AdapterContext,
|
|
): AsyncIterable<ProcessEvent>;
|
|
inspectBackgroundWork?(
|
|
computer: ComputerRef,
|
|
markerId: string,
|
|
context: AdapterContext,
|
|
): Promise<"active" | "idle" | "unknown">;
|
|
connectScreen(
|
|
computer: ComputerRef,
|
|
request: ScreenRequest,
|
|
context: AdapterContext,
|
|
): Promise<ScreenSession>;
|
|
setScreenControl?(
|
|
computer: ComputerRef,
|
|
interactive: boolean,
|
|
context: AdapterContext,
|
|
controlToken?: string,
|
|
): Promise<void>;
|
|
sendInput(
|
|
computer: ComputerRef,
|
|
input: ComputerInput,
|
|
lease: ControlLeaseRef,
|
|
context: AdapterContext,
|
|
): Promise<void>;
|
|
observe(computer: ComputerRef, context: AdapterContext): Promise<ComputerObservation>;
|
|
act(
|
|
computer: ComputerRef,
|
|
request: ComputerActionRequest,
|
|
context: AdapterContext,
|
|
): Promise<ComputerActionResult>;
|
|
listFiles(
|
|
computer: ComputerRef,
|
|
path: string,
|
|
context: AdapterContext,
|
|
): Promise<ComputerFileEntry[]>;
|
|
readFile(
|
|
computer: ComputerRef,
|
|
path: string,
|
|
context: AdapterContext,
|
|
options?: { maxBytes?: number },
|
|
): Promise<Uint8Array>;
|
|
writeFile(computer: ComputerRef, file: PortableFile, context: AdapterContext): Promise<void>;
|
|
exportWorkspace(computer: ComputerRef, context: AdapterContext): AsyncIterable<PortableFile>;
|
|
importWorkspace(
|
|
computer: ComputerRef,
|
|
files: AsyncIterable<PortableFile>,
|
|
context: AdapterContext,
|
|
): Promise<void>;
|
|
snapshot(computer: ComputerRef, context: AdapterContext): Promise<SnapshotRef>;
|
|
keepAlive?(computer: ComputerRef): Promise<void>;
|
|
/** Drop a single-screen graphical claim for this bot so another Team bot can use the display. */
|
|
releaseScreen?(computer: ComputerRef, context: AdapterContext): Promise<void>;
|
|
stop(computer: ComputerRef, context: AdapterContext): Promise<void>;
|
|
destroy(computer: ComputerRef, context: AdapterContext): Promise<void>;
|
|
}
|
|
|
|
export interface ConnectorProvider {
|
|
describe(): AdapterDescriptor<ConnectorCapabilities>;
|
|
discoverTools(context: AdapterContext): Promise<ConnectorTool[]>;
|
|
/** Resolve a lazy catalog call to its authoritative authorized tool before approval. */
|
|
resolveCall?(
|
|
call: ConnectorCall,
|
|
context: AdapterContext,
|
|
): Promise<{ call: ConnectorCall; tool: ConnectorTool } | undefined>;
|
|
execute(call: ConnectorCall, context: AdapterContext): AsyncIterable<ConnectorEvent>;
|
|
}
|
|
|
|
export interface ConnectionAuthProvider {
|
|
describe(): AdapterDescriptor<{ oauth: boolean }>;
|
|
begin(
|
|
request: { provider: string; redirectUrl: string },
|
|
context: AdapterContext,
|
|
): Promise<{ authorizationUrl: string | null; state: string }>;
|
|
complete(
|
|
request: { state: string; code?: string },
|
|
context: AdapterContext,
|
|
): Promise<{ connectionRef: string }>;
|
|
revoke(connectionRef: string, context: AdapterContext): Promise<void>;
|
|
}
|
|
|
|
/** A connector that also owns an end-user app catalog and connection lifecycle. */
|
|
export interface ManagedConnectorProvider
|
|
extends ConnectorProvider,
|
|
Omit<ConnectionAuthProvider, "describe"> {
|
|
catalog(context: AdapterContext, query?: string): Promise<ConnectorCatalogItem[]>;
|
|
listConnectedExternalIds(context: AdapterContext): Promise<string[]>;
|
|
connectionReady(context: AdapterContext, externalId: string): Promise<boolean>;
|
|
warmDirectory?(): Promise<void>;
|
|
}
|
|
|
|
export interface MemoryStore {
|
|
describe(): AdapterDescriptor<MemoryCapabilities>;
|
|
read(request: MemoryReadRequest, context: AdapterContext): Promise<MemorySnapshot>;
|
|
search(request: MemorySearchRequest, context: AdapterContext): Promise<MemorySearchResult[]>;
|
|
commit(request: MemoryCommitRequest, context: AdapterContext): Promise<MemoryRevision>;
|
|
exportMarkdown(
|
|
request: MemoryExportRequest,
|
|
context: AdapterContext,
|
|
): AsyncIterable<PortableFile>;
|
|
importMarkdown(
|
|
files: AsyncIterable<PortableFile>,
|
|
context: AdapterContext,
|
|
): Promise<MemoryRevision>;
|
|
}
|
|
|
|
/** Optional semantic memory. Durable Markdown memory remains owned by MemoryStore. */
|
|
export interface SemanticMemoryProvider {
|
|
describe(): AdapterDescriptor<SemanticMemoryCapabilities>;
|
|
recall(
|
|
request: SemanticMemoryRecallRequest,
|
|
context: AdapterContext,
|
|
): Promise<SemanticMemoryResponse<SemanticMemoryResult[]>>;
|
|
save(
|
|
request: SemanticMemorySaveRequest,
|
|
context: AdapterContext,
|
|
): Promise<SemanticMemoryResponse>;
|
|
purgeHistory(
|
|
request: SemanticMemoryPurgeHistoryRequest,
|
|
context: AdapterContext,
|
|
): Promise<SemanticMemoryResponse>;
|
|
/** Optional durable forget. Providers without a forget verb omit this. */
|
|
forget?(
|
|
request: SemanticMemoryForgetRequest,
|
|
context: AdapterContext,
|
|
): Promise<SemanticMemoryResponse<{ id: string; expired: boolean; reason: string | null }>>;
|
|
}
|
|
|
|
/** Optional text embeddings, such as a self-hosted model server, for recall and deduplication. */
|
|
export interface EmbeddingProvider {
|
|
describe(): AdapterDescriptor<EmbeddingCapabilities>;
|
|
/** One vector per text, in input order, all of the same length. */
|
|
embed(texts: string[], signal?: AbortSignal): Promise<number[][]>;
|
|
}
|
|
|
|
export interface AgentRuntime {
|
|
describe(): AdapterDescriptor<AgentRuntimeCapabilities>;
|
|
run(
|
|
request: AgentRunRequest,
|
|
context?: Partial<AdapterContext>,
|
|
): AsyncIterable<AgentRuntimeEvent>;
|
|
abort(runId: string): Promise<void>;
|
|
}
|
|
|
|
export interface ModelProvider {
|
|
describe(): AdapterDescriptor<{ catalog: boolean; byok: boolean }>;
|
|
listModels(): Promise<Array<{ provider: string; id: string; label: string; billing: string }>>;
|
|
}
|
|
|
|
export interface JobPublisher {
|
|
enqueue(job: BackgroundJob): Promise<void>;
|
|
cancel(key: string): Promise<void>;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
export interface JobWorkerHost {
|
|
start(handlers: BackgroundJobHandlers): Promise<void>;
|
|
stop(): Promise<void>;
|
|
}
|
|
|
|
export interface AgentHomeStore {
|
|
describe(): AdapterDescriptor<{ revisions: boolean }>;
|
|
checkout(botId: string, dest: string, context: AdapterContext): Promise<string>;
|
|
commit(botId: string, src: string, context: AdapterContext): Promise<string>;
|
|
restore(botId: string, revision: string, dest: string, context: AdapterContext): Promise<void>;
|
|
exportHome(
|
|
botId: string,
|
|
context: AdapterContext,
|
|
options?: { prefix?: string },
|
|
): AsyncIterable<PortableFile>;
|
|
readFile(
|
|
botId: string,
|
|
path: string,
|
|
context: AdapterContext,
|
|
options?: { maxBytes?: number },
|
|
): Promise<string>;
|
|
writeFile(botId: string, path: string, content: string, context: AdapterContext): Promise<void>;
|
|
list(
|
|
botId: string,
|
|
path: string,
|
|
context: AdapterContext,
|
|
): Promise<Array<{ path: string; kind: "file" | "dir"; size: number }>>;
|
|
}
|
|
|
|
/**
|
|
* Artifact bytes, keyed per space (`context.spaceId`). `stream: true` means large media can be
|
|
* stored and served without holding it in memory.
|
|
*/
|
|
export interface ArtifactStore {
|
|
describe(): AdapterDescriptor<{ stream: boolean }>;
|
|
put(artifact: ArtifactPut, context: AdapterContext): Promise<{ id: string; hash: string }>;
|
|
get(id: string, context: AdapterContext): Promise<Uint8Array>;
|
|
remove(id: string, context: AdapterContext): Promise<void>;
|
|
stat(key: string, context: AdapterContext): Promise<{ size: number }>;
|
|
/** Rejects with a RangeError when the range does not fit the stored size. */
|
|
createReadStream(
|
|
key: string,
|
|
context: AdapterContext,
|
|
range?: ArtifactByteRange,
|
|
): Promise<ReadableStream<Uint8Array>>;
|
|
putStream(artifact: ArtifactStreamPut, context: AdapterContext): Promise<StoredArtifactStream>;
|
|
/** A local file path for tools such as ffmpeg that read in place; only stores on local disk. */
|
|
localPath?(key: string, context: AdapterContext): Promise<string>;
|
|
}
|
|
|
|
export interface SecretStore {
|
|
describe(): AdapterDescriptor<{ rotate: boolean }>;
|
|
/** Optional recordId binds ciphertext AAD to the persisted secret/session row id. */
|
|
put(plaintext: string, context: AdapterContext, recordId?: string): Promise<SecretRecord>;
|
|
get(id: string, context: AdapterContext): Promise<string>;
|
|
redact(value: string): string;
|
|
}
|
|
|
|
export interface RealtimeFanout {
|
|
describe(): AdapterDescriptor<{ distributed: boolean; push: boolean }>;
|
|
publish(topic: string, payload: string): Promise<void>;
|
|
subscribe(topic: string, onMessage: (payload: string) => void): Promise<() => Promise<void>>;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
export interface NotificationProvider {
|
|
describe(): AdapterDescriptor<{ push: boolean; email: boolean }>;
|
|
send(message: NotificationMessage, context: AdapterContext): Promise<void>;
|
|
}
|
|
|
|
/** Outbound account and security email. Product code owns content; adapters own delivery. */
|
|
export interface TransactionalEmailProvider {
|
|
describe(): AdapterDescriptor<{ transactional: boolean }>;
|
|
send(message: TransactionalEmail): Promise<void>;
|
|
/** Wait for accepted in-flight deliveries before a graceful shutdown completes. */
|
|
drain?(): Promise<void>;
|
|
}
|
|
|
|
export interface ExecutionRunner {
|
|
describe(): AdapterDescriptor<{ cloud: boolean; selfHosted: boolean; desktop: boolean }>;
|
|
dispatch(runId: string, target: "cloud" | "self-hosted" | "desktop"): Promise<void>;
|
|
}
|
|
|
|
export interface VoiceProvider {
|
|
describe(): AdapterDescriptor<VoiceCapabilities>;
|
|
verify(apiKey: string, context: AdapterContext): Promise<VoiceVerifyResult>;
|
|
listVoices(apiKey: string, context: AdapterContext): Promise<VoiceInfo[]>;
|
|
synthesize(request: VoiceSynthesizeRequest, context: AdapterContext): Promise<SpeechClip>;
|
|
transcribe?(request: VoiceTranscribeRequest, context: AdapterContext): Promise<{ text: string }>;
|
|
}
|
|
|
|
/**
|
|
* Deployment-wide external chat surface: one bot presence across messaging
|
|
* platforms (iMessage/SMS, Slack, WhatsApp, …). Conversations are addressed
|
|
* by opaque provider-prefixed thread ids ("sendblue:…", "slack:C1:…"), so
|
|
* orchestration never sees platform wire formats. Webhook verification and
|
|
* payload translation live behind handleWebhook, per platform.
|
|
*/
|
|
export interface MessagingSurface {
|
|
describe(): AdapterDescriptor<{ providers: string[] }>;
|
|
/** Enabled platforms and what each supports. */
|
|
platforms(): MessagingPlatformDescriptor[];
|
|
/**
|
|
* Verify and process one platform webhook request. Parsed events reach the
|
|
* sink registered via onInbound before the returned response resolves.
|
|
* Returns null for a provider this surface does not host.
|
|
*/
|
|
handleWebhook(provider: string, request: Request): Promise<Response> | null;
|
|
/** Register the single downstream consumer of inbound events. */
|
|
onInbound(sink: (event: MessagingInboundEvent) => Promise<void>): void;
|
|
sendToThread(
|
|
request: MessagingSendRequest,
|
|
context: AdapterContext,
|
|
): Promise<MessagingSendResult>;
|
|
/** Resolve (or create) the 1:1 thread for a provider address. */
|
|
openDirectThread(provider: string, address: string, context: AdapterContext): Promise<string>;
|
|
/**
|
|
* Best-effort "…" typing bubbles for 1:1 chats. Cosmetic: never gates
|
|
* message delivery, and silently no-ops on platforms without support.
|
|
*/
|
|
sendTyping(threadId: string, context: AdapterContext): Promise<void>;
|
|
/**
|
|
* Start the surface eagerly instead of waiting for the first inbound
|
|
* webhook or outbound send to touch it. Needed for adapters that pull
|
|
* updates themselves (e.g. Telegram in "auto"/"polling" mode, used when
|
|
* no public webhook URL is reachable): without an explicit call here,
|
|
* nothing kicks off that pull loop until something else happens to
|
|
* initialize the adapter first.
|
|
*/
|
|
initialize?(): Promise<void>;
|
|
/**
|
|
* Release long-lived inbound resources (Telegram getUpdates polling) so a
|
|
* replacement process can claim them. No-op when the surface never started.
|
|
*/
|
|
shutdown?(): Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Provider-neutral web search. Builtin `web_search` routes here so backends
|
|
* (keyless HTTP, model-native search, future paid APIs) can be swapped without
|
|
* changing tool names or the executor.
|
|
*/
|
|
export interface WebSearchProvider {
|
|
describe(): AdapterDescriptor<WebSearchCapabilities>;
|
|
search(request: WebSearchRequest, context: AdapterContext): Promise<WebSearchHit[]>;
|
|
}
|
|
|
|
/**
|
|
* Provider-neutral page fetch. Builtin `web_fetch` routes here. Read-only:
|
|
* no JS execution, no headless browser.
|
|
*/
|
|
export interface WebFetchProvider {
|
|
describe(): AdapterDescriptor<WebFetchCapabilities>;
|
|
fetch(request: WebFetchRequest, context: AdapterContext): Promise<WebFetchResult>;
|
|
}
|
|
|
|
/** Convenience when one adapter owns both search and fetch. */
|
|
export interface WebProvider extends WebSearchProvider, WebFetchProvider {
|
|
describe(): AdapterDescriptor<WebSearchCapabilities & WebFetchCapabilities>;
|
|
}
|
|
|
|
/**
|
|
* Provider-neutral page browser on the bot computer. Builtin browser_* tools
|
|
* route here so backends (in-process page session, CDP against the computer's
|
|
* Chrome, future adapters) can swap without changing tool names. Not a second
|
|
* hosted browser product. Prefer this for web pages; use computer_act when the
|
|
* page tool cannot operate or the task needs the desktop itself.
|
|
*/
|
|
export interface BrowserProvider {
|
|
describe(): AdapterDescriptor<BrowserCapabilities>;
|
|
navigate(
|
|
computer: ComputerRef,
|
|
request: BrowserNavigateRequest,
|
|
context: AdapterContext,
|
|
): Promise<BrowserNavigateResult>;
|
|
snapshot(
|
|
computer: ComputerRef,
|
|
request: BrowserSnapshotRequest,
|
|
context: AdapterContext,
|
|
): Promise<BrowserSnapshotResult>;
|
|
act(
|
|
computer: ComputerRef,
|
|
request: BrowserActRequest,
|
|
context: AdapterContext,
|
|
): Promise<BrowserActResult>;
|
|
}
|
|
|
|
/**
|
|
* Provider-neutral remote cloud coding agents (clone a repo on a vendor VM,
|
|
* open a PR). Not the bot computer — shell/sandbox stays separate. Core runs
|
|
* with none configured; tools are injected only when a provider is present.
|
|
*/
|
|
export interface CloudAgentProvider {
|
|
describe(): AdapterDescriptor<CloudAgentCapabilities>;
|
|
launch(request: CloudAgentLaunchRequest, context: AdapterContext): Promise<CloudAgentHandle>;
|
|
get(id: string, context: AdapterContext, runId?: string): Promise<CloudAgentSnapshot>;
|
|
reply(
|
|
id: string,
|
|
request: CloudAgentReplyRequest,
|
|
context: AdapterContext,
|
|
): Promise<CloudAgentHandle>;
|
|
cancel(id: string, context: AdapterContext, runId?: string): Promise<CloudAgentSnapshot>;
|
|
}
|