Skip to content
Merged
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
173 changes: 173 additions & 0 deletions src/approvals/index.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

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<string> {
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<void> {
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<PollResponse> {
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<void> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
44 changes: 44 additions & 0 deletions src/approvals/types.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
/** 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;
}
24 changes: 24 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,21 +11,29 @@ 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;

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);
}

/**
Expand Down Expand Up @@ -58,4 +67,19 @@ export class OneCLI {
ensureAgent = (input: CreateAgentInput): Promise<EnsureAgentResponse> => {
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() };
};
}
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,3 +14,8 @@ export type {
CreateAgentResponse,
EnsureAgentResponse,
} from "./agents/types.js";
export type {
ApprovalRequest,
ManualApprovalCallback,
ManualApprovalHandle,
} from "./approvals/types.js";
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading