diff --git a/src/approvals/index.ts b/src/approvals/index.ts new file mode 100644 index 0000000..2776328 --- /dev/null +++ b/src/approvals/index.ts @@ -0,0 +1,173 @@ +import { OneCLIRequestError } from "../errors.js"; +import type { ApprovalRequest, ManualApprovalCallback } from "./types.js"; + +/** Internal response shape from the gateway long-poll endpoint. */ +interface PollResponse { + requests: ApprovalRequest[]; + timeoutSeconds: number; +} + +export class ApprovalClient { + private baseUrl: string; + private apiKey: string; + private gatewayUrl: string | null; + private running = false; + private abortController: AbortController | null = null; + + /** + * Tracks approval IDs currently being processed by a callback. + * Prevents duplicate callback invocations for the same request + * when the poll returns it again before the decision is submitted. + */ + private inFlight = new Set(); + + constructor(baseUrl: string, apiKey: string, gatewayUrl: string | null) { + this.baseUrl = baseUrl.replace(/\/+$/, ""); + this.apiKey = apiKey; + this.gatewayUrl = gatewayUrl; + } + + /** + * Resolve the gateway URL from the web app. + * Called once on first poll, then cached. + */ + private async resolveGatewayUrl(): Promise { + if (this.gatewayUrl) return this.gatewayUrl; + + const url = `${this.baseUrl}/api/gateway-url`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${this.apiKey}` }, + signal: AbortSignal.timeout(5000), + }); + + if (!res.ok) { + throw new OneCLIRequestError("Failed to resolve gateway URL", { + url, + statusCode: res.status, + }); + } + + const data = (await res.json()) as { url: string }; + this.gatewayUrl = data.url.replace(/\/+$/, ""); + return this.gatewayUrl; + } + + /** + * Start the long-polling loop. Runs until stop() is called. + * + * Dispatches callbacks concurrently — multiple approvals are handled + * in parallel without blocking each other or the polling loop. + * Each approval ID is tracked in `inFlight` to prevent duplicate + * callback invocations. On failure (callback throws or decision + * submission fails), the ID is removed from `inFlight` and the + * approval will be retried on the next poll cycle. + */ + async start(callback: ManualApprovalCallback): Promise { + this.running = true; + const gatewayUrl = await this.resolveGatewayUrl(); + + while (this.running) { + try { + const poll = await this.poll(gatewayUrl); + + for (const request of poll.requests) { + this.inFlight.add(request.id); + request.timeoutSeconds = poll.timeoutSeconds; + + this.handleRequest(gatewayUrl, request, callback); + } + } catch { + if (!this.running) return; + await this.sleep(5000); + } + } + } + + /** + * Process a single approval: call the callback, submit the decision. + * Runs independently — multiple calls execute concurrently. + * On any failure, removes from inFlight so the next poll retries. + */ + private handleRequest( + gatewayUrl: string, + request: ApprovalRequest, + callback: ManualApprovalCallback, + ): void { + (async () => { + try { + const decision = await callback(request); + await this.submitDecision(gatewayUrl, request.id, decision); + } finally { + this.inFlight.delete(request.id); + } + })().catch(() => { + this.inFlight.delete(request.id); + }); + } + + /** Stop the polling loop and abort any in-flight poll request. */ + stop(): void { + this.running = false; + this.abortController?.abort(); + } + + /** + * Long-poll the gateway for pending approvals. + * Server holds up to 30s; we set a 35s client timeout. + */ + private async poll(gatewayUrl: string): Promise { + this.abortController = new AbortController(); + + let url = `${gatewayUrl}/api/approvals/pending`; + if (this.inFlight.size > 0) { + const exclude = [...this.inFlight].join(","); + url += `?exclude=${encodeURIComponent(exclude)}`; + } + const res = await fetch(url, { + headers: { Authorization: `Bearer ${this.apiKey}` }, + signal: AbortSignal.any([ + this.abortController.signal, + AbortSignal.timeout(35_000), + ]), + }); + + if (!res.ok) { + throw new OneCLIRequestError("Approval poll failed", { + url, + statusCode: res.status, + }); + } + + return (await res.json()) as PollResponse; + } + + /** Submit a decision for a single approval request. */ + private async submitDecision( + gatewayUrl: string, + id: string, + decision: string, + ): Promise { + const url = `${gatewayUrl}/api/approvals/${encodeURIComponent(id)}/decision`; + + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ decision }), + signal: AbortSignal.timeout(5000), + }); + + if (!res.ok && res.status !== 410) { + throw new OneCLIRequestError("Decision submission failed", { + url, + statusCode: res.status, + }); + } + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/approvals/types.ts b/src/approvals/types.ts new file mode 100644 index 0000000..e4295b6 --- /dev/null +++ b/src/approvals/types.ts @@ -0,0 +1,44 @@ +/** A single request awaiting manual approval. */ +export interface ApprovalRequest { + /** Unique approval ID. */ + id: string; + /** HTTP method (e.g., "POST", "DELETE"). */ + method: string; + /** Full URL (e.g., "https://api.example.com/v1/send"). */ + url: string; + /** Hostname (e.g., "api.example.com"). */ + host: string; + /** Request path (e.g., "/v1/send"). */ + path: string; + /** Sanitized request headers (no auth headers). */ + headers: Record; + /** First ~4KB of request body as text, or null if no body. */ + bodyPreview: string | null; + /** The agent that made this request. */ + agent: { id: string; name: string }; + /** When the request arrived (ISO 8601). */ + createdAt: string; + /** When the approval expires (ISO 8601). */ + expiresAt: string; + /** Approval timeout in seconds (how long until auto-deny). */ + timeoutSeconds: number; +} + +/** + * Callback invoked once per approval request. + * Return `'approve'` to forward the request, `'deny'` to block it. + * + * The SDK calls this concurrently for multiple pending approvals — + * each invocation is independent. If the callback throws or the + * decision fails to submit, the same request will be retried on + * the next poll cycle. + */ +export type ManualApprovalCallback = ( + request: ApprovalRequest, +) => Promise<"approve" | "deny">; + +/** Handle returned by configureManualApproval() to stop polling. */ +export interface ManualApprovalHandle { + /** Stop polling and disconnect. */ + stop: () => void; +} diff --git a/src/client.ts b/src/client.ts index 6b3e87c..c592662 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,6 @@ import { ContainerClient } from "./container/index.js"; import { AgentsClient } from "./agents/index.js"; +import { ApprovalClient } from "./approvals/index.js"; import type { OneCLIOptions } from "./types.js"; import type { ApplyContainerConfigOptions, @@ -10,6 +11,10 @@ import type { CreateAgentResponse, EnsureAgentResponse, } from "./agents/types.js"; +import type { + ManualApprovalCallback, + ManualApprovalHandle, +} from "./approvals/types.js"; const DEFAULT_URL = "https://app.onecli.sh"; const DEFAULT_TIMEOUT = 5000; @@ -17,14 +22,18 @@ const DEFAULT_TIMEOUT = 5000; export class OneCLI { private containerClient: ContainerClient; private agentsClient: AgentsClient; + private approvalClient: ApprovalClient; constructor(options: OneCLIOptions = {}) { const apiKey = options.apiKey ?? process.env.ONECLI_API_KEY ?? ""; const url = options.url ?? process.env.ONECLI_URL ?? DEFAULT_URL; const timeout = options.timeout ?? DEFAULT_TIMEOUT; + const gatewayUrl = + options.gatewayUrl ?? process.env.ONECLI_GATEWAY_URL ?? null; this.containerClient = new ContainerClient(url, apiKey, timeout); this.agentsClient = new AgentsClient(url, apiKey, timeout); + this.approvalClient = new ApprovalClient(url, apiKey, gatewayUrl); } /** @@ -58,4 +67,19 @@ export class OneCLI { ensureAgent = (input: CreateAgentInput): Promise => { return this.agentsClient.ensureAgent(input); }; + + /** + * Register a callback for manual approval requests. + * Starts background long-polling to the gateway. The callback is called + * once per pending approval request, concurrently for multiple requests. + * Returns a handle to stop polling when shutting down. + */ + configureManualApproval = ( + callback: ManualApprovalCallback, + ): ManualApprovalHandle => { + this.approvalClient.start(callback).catch(() => { + // Errors handled internally with backoff + }); + return { stop: () => this.approvalClient.stop() }; + }; } diff --git a/src/index.ts b/src/index.ts index 1c6fb97..5c9d4f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export { OneCLI } from "./client.js"; export { ContainerClient } from "./container/index.js"; export { AgentsClient } from "./agents/index.js"; +export { ApprovalClient } from "./approvals/index.js"; export { OneCLIError, OneCLIRequestError } from "./errors.js"; export type { OneCLIOptions } from "./types.js"; @@ -13,3 +14,8 @@ export type { CreateAgentResponse, EnsureAgentResponse, } from "./agents/types.js"; +export type { + ApprovalRequest, + ManualApprovalCallback, + ManualApprovalHandle, +} from "./approvals/types.js"; diff --git a/src/types.ts b/src/types.ts index 1d30443..202beb2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,4 +16,11 @@ export interface OneCLIOptions { * @default 5000 */ timeout?: number; + + /** + * Gateway URL for manual approval polling. + * Falls back to `ONECLI_GATEWAY_URL` env var, then auto-resolved + * from the web app via `GET /api/gateway-url`. + */ + gatewayUrl?: string; }