Skip to content

Commit 3356d0e

Browse files
authored
feat: add manual approval policy action for gateway requests (#17)
* feat: add manual approval policy action for gateway requests * chore: trigger CI
1 parent 55a9fc6 commit 3356d0e

5 files changed

Lines changed: 254 additions & 0 deletions

File tree

src/approvals/index.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { OneCLIRequestError } from "../errors.js";
2+
import type { ApprovalRequest, ManualApprovalCallback } from "./types.js";
3+
4+
/** Internal response shape from the gateway long-poll endpoint. */
5+
interface PollResponse {
6+
requests: ApprovalRequest[];
7+
timeoutSeconds: number;
8+
}
9+
10+
export class ApprovalClient {
11+
private baseUrl: string;
12+
private apiKey: string;
13+
private gatewayUrl: string | null;
14+
private running = false;
15+
private abortController: AbortController | null = null;
16+
17+
/**
18+
* Tracks approval IDs currently being processed by a callback.
19+
* Prevents duplicate callback invocations for the same request
20+
* when the poll returns it again before the decision is submitted.
21+
*/
22+
private inFlight = new Set<string>();
23+
24+
constructor(baseUrl: string, apiKey: string, gatewayUrl: string | null) {
25+
this.baseUrl = baseUrl.replace(/\/+$/, "");
26+
this.apiKey = apiKey;
27+
this.gatewayUrl = gatewayUrl;
28+
}
29+
30+
/**
31+
* Resolve the gateway URL from the web app.
32+
* Called once on first poll, then cached.
33+
*/
34+
private async resolveGatewayUrl(): Promise<string> {
35+
if (this.gatewayUrl) return this.gatewayUrl;
36+
37+
const url = `${this.baseUrl}/api/gateway-url`;
38+
const res = await fetch(url, {
39+
headers: { Authorization: `Bearer ${this.apiKey}` },
40+
signal: AbortSignal.timeout(5000),
41+
});
42+
43+
if (!res.ok) {
44+
throw new OneCLIRequestError("Failed to resolve gateway URL", {
45+
url,
46+
statusCode: res.status,
47+
});
48+
}
49+
50+
const data = (await res.json()) as { url: string };
51+
this.gatewayUrl = data.url.replace(/\/+$/, "");
52+
return this.gatewayUrl;
53+
}
54+
55+
/**
56+
* Start the long-polling loop. Runs until stop() is called.
57+
*
58+
* Dispatches callbacks concurrently — multiple approvals are handled
59+
* in parallel without blocking each other or the polling loop.
60+
* Each approval ID is tracked in `inFlight` to prevent duplicate
61+
* callback invocations. On failure (callback throws or decision
62+
* submission fails), the ID is removed from `inFlight` and the
63+
* approval will be retried on the next poll cycle.
64+
*/
65+
async start(callback: ManualApprovalCallback): Promise<void> {
66+
this.running = true;
67+
const gatewayUrl = await this.resolveGatewayUrl();
68+
69+
while (this.running) {
70+
try {
71+
const poll = await this.poll(gatewayUrl);
72+
73+
for (const request of poll.requests) {
74+
this.inFlight.add(request.id);
75+
request.timeoutSeconds = poll.timeoutSeconds;
76+
77+
this.handleRequest(gatewayUrl, request, callback);
78+
}
79+
} catch {
80+
if (!this.running) return;
81+
await this.sleep(5000);
82+
}
83+
}
84+
}
85+
86+
/**
87+
* Process a single approval: call the callback, submit the decision.
88+
* Runs independently — multiple calls execute concurrently.
89+
* On any failure, removes from inFlight so the next poll retries.
90+
*/
91+
private handleRequest(
92+
gatewayUrl: string,
93+
request: ApprovalRequest,
94+
callback: ManualApprovalCallback,
95+
): void {
96+
(async () => {
97+
try {
98+
const decision = await callback(request);
99+
await this.submitDecision(gatewayUrl, request.id, decision);
100+
} finally {
101+
this.inFlight.delete(request.id);
102+
}
103+
})().catch(() => {
104+
this.inFlight.delete(request.id);
105+
});
106+
}
107+
108+
/** Stop the polling loop and abort any in-flight poll request. */
109+
stop(): void {
110+
this.running = false;
111+
this.abortController?.abort();
112+
}
113+
114+
/**
115+
* Long-poll the gateway for pending approvals.
116+
* Server holds up to 30s; we set a 35s client timeout.
117+
*/
118+
private async poll(gatewayUrl: string): Promise<PollResponse> {
119+
this.abortController = new AbortController();
120+
121+
let url = `${gatewayUrl}/api/approvals/pending`;
122+
if (this.inFlight.size > 0) {
123+
const exclude = [...this.inFlight].join(",");
124+
url += `?exclude=${encodeURIComponent(exclude)}`;
125+
}
126+
const res = await fetch(url, {
127+
headers: { Authorization: `Bearer ${this.apiKey}` },
128+
signal: AbortSignal.any([
129+
this.abortController.signal,
130+
AbortSignal.timeout(35_000),
131+
]),
132+
});
133+
134+
if (!res.ok) {
135+
throw new OneCLIRequestError("Approval poll failed", {
136+
url,
137+
statusCode: res.status,
138+
});
139+
}
140+
141+
return (await res.json()) as PollResponse;
142+
}
143+
144+
/** Submit a decision for a single approval request. */
145+
private async submitDecision(
146+
gatewayUrl: string,
147+
id: string,
148+
decision: string,
149+
): Promise<void> {
150+
const url = `${gatewayUrl}/api/approvals/${encodeURIComponent(id)}/decision`;
151+
152+
const res = await fetch(url, {
153+
method: "POST",
154+
headers: {
155+
"Content-Type": "application/json",
156+
Authorization: `Bearer ${this.apiKey}`,
157+
},
158+
body: JSON.stringify({ decision }),
159+
signal: AbortSignal.timeout(5000),
160+
});
161+
162+
if (!res.ok && res.status !== 410) {
163+
throw new OneCLIRequestError("Decision submission failed", {
164+
url,
165+
statusCode: res.status,
166+
});
167+
}
168+
}
169+
170+
private sleep(ms: number): Promise<void> {
171+
return new Promise((resolve) => setTimeout(resolve, ms));
172+
}
173+
}

src/approvals/types.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/** A single request awaiting manual approval. */
2+
export interface ApprovalRequest {
3+
/** Unique approval ID. */
4+
id: string;
5+
/** HTTP method (e.g., "POST", "DELETE"). */
6+
method: string;
7+
/** Full URL (e.g., "https://api.example.com/v1/send"). */
8+
url: string;
9+
/** Hostname (e.g., "api.example.com"). */
10+
host: string;
11+
/** Request path (e.g., "/v1/send"). */
12+
path: string;
13+
/** Sanitized request headers (no auth headers). */
14+
headers: Record<string, string>;
15+
/** First ~4KB of request body as text, or null if no body. */
16+
bodyPreview: string | null;
17+
/** The agent that made this request. */
18+
agent: { id: string; name: string };
19+
/** When the request arrived (ISO 8601). */
20+
createdAt: string;
21+
/** When the approval expires (ISO 8601). */
22+
expiresAt: string;
23+
/** Approval timeout in seconds (how long until auto-deny). */
24+
timeoutSeconds: number;
25+
}
26+
27+
/**
28+
* Callback invoked once per approval request.
29+
* Return `'approve'` to forward the request, `'deny'` to block it.
30+
*
31+
* The SDK calls this concurrently for multiple pending approvals —
32+
* each invocation is independent. If the callback throws or the
33+
* decision fails to submit, the same request will be retried on
34+
* the next poll cycle.
35+
*/
36+
export type ManualApprovalCallback = (
37+
request: ApprovalRequest,
38+
) => Promise<"approve" | "deny">;
39+
40+
/** Handle returned by configureManualApproval() to stop polling. */
41+
export interface ManualApprovalHandle {
42+
/** Stop polling and disconnect. */
43+
stop: () => void;
44+
}

src/client.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ContainerClient } from "./container/index.js";
22
import { AgentsClient } from "./agents/index.js";
3+
import { ApprovalClient } from "./approvals/index.js";
34
import type { OneCLIOptions } from "./types.js";
45
import type {
56
ApplyContainerConfigOptions,
@@ -10,21 +11,29 @@ import type {
1011
CreateAgentResponse,
1112
EnsureAgentResponse,
1213
} from "./agents/types.js";
14+
import type {
15+
ManualApprovalCallback,
16+
ManualApprovalHandle,
17+
} from "./approvals/types.js";
1318

1419
const DEFAULT_URL = "https://app.onecli.sh";
1520
const DEFAULT_TIMEOUT = 5000;
1621

1722
export class OneCLI {
1823
private containerClient: ContainerClient;
1924
private agentsClient: AgentsClient;
25+
private approvalClient: ApprovalClient;
2026

2127
constructor(options: OneCLIOptions = {}) {
2228
const apiKey = options.apiKey ?? process.env.ONECLI_API_KEY ?? "";
2329
const url = options.url ?? process.env.ONECLI_URL ?? DEFAULT_URL;
2430
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
31+
const gatewayUrl =
32+
options.gatewayUrl ?? process.env.ONECLI_GATEWAY_URL ?? null;
2533

2634
this.containerClient = new ContainerClient(url, apiKey, timeout);
2735
this.agentsClient = new AgentsClient(url, apiKey, timeout);
36+
this.approvalClient = new ApprovalClient(url, apiKey, gatewayUrl);
2837
}
2938

3039
/**
@@ -58,4 +67,19 @@ export class OneCLI {
5867
ensureAgent = (input: CreateAgentInput): Promise<EnsureAgentResponse> => {
5968
return this.agentsClient.ensureAgent(input);
6069
};
70+
71+
/**
72+
* Register a callback for manual approval requests.
73+
* Starts background long-polling to the gateway. The callback is called
74+
* once per pending approval request, concurrently for multiple requests.
75+
* Returns a handle to stop polling when shutting down.
76+
*/
77+
configureManualApproval = (
78+
callback: ManualApprovalCallback,
79+
): ManualApprovalHandle => {
80+
this.approvalClient.start(callback).catch(() => {
81+
// Errors handled internally with backoff
82+
});
83+
return { stop: () => this.approvalClient.stop() };
84+
};
6185
}

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { OneCLI } from "./client.js";
22
export { ContainerClient } from "./container/index.js";
33
export { AgentsClient } from "./agents/index.js";
4+
export { ApprovalClient } from "./approvals/index.js";
45
export { OneCLIError, OneCLIRequestError } from "./errors.js";
56

67
export type { OneCLIOptions } from "./types.js";
@@ -13,3 +14,8 @@ export type {
1314
CreateAgentResponse,
1415
EnsureAgentResponse,
1516
} from "./agents/types.js";
17+
export type {
18+
ApprovalRequest,
19+
ManualApprovalCallback,
20+
ManualApprovalHandle,
21+
} from "./approvals/types.js";

src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,11 @@ export interface OneCLIOptions {
1616
* @default 5000
1717
*/
1818
timeout?: number;
19+
20+
/**
21+
* Gateway URL for manual approval polling.
22+
* Falls back to `ONECLI_GATEWAY_URL` env var, then auto-resolved
23+
* from the web app via `GET /api/gateway-url`.
24+
*/
25+
gatewayUrl?: string;
1926
}

0 commit comments

Comments
 (0)