@the-open-engine/zeroshot 6.25.1 → 6.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@the-open-engine/zeroshot",
3
- "version": "6.25.1",
3
+ "version": "6.26.0",
4
4
  "description": "Multi-agent orchestration engine for Claude, Codex, and Gemini",
5
5
  "main": "src/orchestrator.js",
6
6
  "bin": {
@@ -40,8 +40,10 @@
40
40
  "test:coverage:report": "c8 --reporter=html npm run test:unit && echo 'Coverage report generated at coverage/index.html'",
41
41
  "postinstall": "node scripts/fix-node-pty-permissions.js && node scripts/check-path.js",
42
42
  "start": "node cli/index.js",
43
- "typecheck": "tsc --noEmit && npm run typecheck:cluster",
43
+ "typecheck": "tsc --noEmit && npm run typecheck:cluster && npm run typecheck:hosted-target",
44
44
  "typecheck:agent-cli-provider": "tsc --project tsconfig.agent-cli-provider.json",
45
+ "typecheck:hosted-target": "tsc --project tsconfig.hosted-target.json",
46
+ "test:hosted-target": "node --test tests/hosted-target/*.test.ts",
45
47
  "typecheck:cluster": "tsc --project tsconfig.cluster.json",
46
48
  "lint:agent-cli-provider": "eslint \"src/agent-cli-provider/**/*.ts\" \"tests/agent-cli-provider/**/*.ts\"",
47
49
  "build:agent-cli-provider": "tsc --project tsconfig.agent-cli-provider.build.json",
@@ -0,0 +1,6 @@
1
+ export const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
2
+ export const MAX_PAGINATION_PAGES = 100;
3
+ export const MAX_RETRY_ATTEMPTS = 3;
4
+ export const MAX_RETRY_ELAPSED_MS = 30_000;
5
+ export const MAX_ERROR_BODY_BYTES = 8192;
6
+ export const IDEMPOTENCY_KEY_PATTERN = /^[a-zA-Z0-9_-]{1,128}$/;
@@ -0,0 +1,90 @@
1
+ function sanitize(text: string): string {
2
+ return text
3
+ .replace(/Authorization:\s*Bearer\s+\S+/gi, 'Authorization: Bearer [REDACTED]')
4
+ .replace(/token["']?\s*[:=]\s*["'][^"']+["']/gi, 'token: "[REDACTED]"')
5
+ .replace(/https?:\/\/[^\s]*(?:token|key|secret|credential|auth)[^\s]*/gi, '[REDACTED_URL]');
6
+ }
7
+
8
+ function sanitizeCause(cause: unknown): unknown {
9
+ if (!cause) return cause;
10
+ if (cause instanceof Error) {
11
+ const cleaned = new Error(sanitize(cause.message));
12
+ cleaned.name = cause.name;
13
+ if (cause.cause) cleaned.cause = sanitizeCause(cause.cause);
14
+ return cleaned;
15
+ }
16
+ if (typeof cause === 'string') return sanitize(cause);
17
+ return cause;
18
+ }
19
+
20
+ export class TargetAdapterError extends Error {
21
+ readonly code: string;
22
+ readonly retryable: boolean;
23
+
24
+ constructor(code: string, message: string, retryable: boolean, cause?: unknown) {
25
+ super(sanitize(message), { cause: sanitizeCause(cause) });
26
+ this.name = 'TargetAdapterError';
27
+ this.code = code;
28
+ this.retryable = retryable;
29
+ }
30
+ }
31
+
32
+ export class TargetAuthError extends TargetAdapterError {
33
+ constructor(message: string, cause?: unknown) {
34
+ super('AUTH_FAILED', message, false, cause);
35
+ this.name = 'TargetAuthError';
36
+ }
37
+ }
38
+
39
+ export class TargetConflictError extends TargetAdapterError {
40
+ readonly idempotencyKey: string;
41
+
42
+ constructor(idempotencyKey: string, message: string, cause?: unknown) {
43
+ super('CONFLICT', message, true, cause);
44
+ this.name = 'TargetConflictError';
45
+ this.idempotencyKey = idempotencyKey;
46
+ }
47
+ }
48
+
49
+ export class TargetRateLimitError extends TargetAdapterError {
50
+ readonly retryAfterMs: number | undefined;
51
+
52
+ constructor(message: string, retryAfterMs?: number, cause?: unknown) {
53
+ super('RATE_LIMITED', message, true, cause);
54
+ this.name = 'TargetRateLimitError';
55
+ this.retryAfterMs = retryAfterMs;
56
+ }
57
+ }
58
+
59
+ export class TargetTransportError extends TargetAdapterError {
60
+ constructor(message: string, cause?: unknown) {
61
+ super('TRANSPORT', message, true, cause);
62
+ this.name = 'TargetTransportError';
63
+ }
64
+ }
65
+
66
+ export class TargetProtocolError extends TargetAdapterError {
67
+ constructor(message: string, cause?: unknown) {
68
+ super('PROTOCOL', message, false, cause);
69
+ this.name = 'TargetProtocolError';
70
+ }
71
+ }
72
+
73
+ export class TargetCapacityError extends TargetAdapterError {
74
+ constructor(message: string, cause?: unknown) {
75
+ super('CAPACITY', message, false, cause);
76
+ this.name = 'TargetCapacityError';
77
+ }
78
+ }
79
+
80
+ export class TargetNotFoundError extends TargetAdapterError {
81
+ constructor(message: string, cause?: unknown) {
82
+ super('NOT_FOUND', message, false, cause);
83
+ this.name = 'TargetNotFoundError';
84
+ }
85
+ }
86
+
87
+ export function isRetryable(error: unknown): boolean {
88
+ if (error instanceof TargetAdapterError) return error.retryable;
89
+ return false;
90
+ }
@@ -0,0 +1,44 @@
1
+ export type { TargetAdapter } from './target-adapter.ts';
2
+ export { ZeroCloudV1TargetAdapter } from './zero-cloud-v1-adapter.ts';
3
+ export {
4
+ TargetAdapterError,
5
+ TargetAuthError,
6
+ TargetConflictError,
7
+ TargetRateLimitError,
8
+ TargetTransportError,
9
+ TargetProtocolError,
10
+ TargetCapacityError,
11
+ TargetNotFoundError,
12
+ isRetryable,
13
+ } from './errors.ts';
14
+ export type {
15
+ TargetAccessTokenProvider,
16
+ CapsuleState,
17
+ Capsule,
18
+ CapsuleAccess,
19
+ CapsuleListPage,
20
+ CapsuleLimits,
21
+ AllocateRequest,
22
+ HttpTransport,
23
+ Clock,
24
+ RetryPolicy,
25
+ TargetDiscovery,
26
+ } from './types.ts';
27
+ export { KNOWN_CAPSULE_STATES } from './types.ts';
28
+ export {
29
+ MAX_RESPONSE_BYTES,
30
+ MAX_PAGINATION_PAGES,
31
+ MAX_RETRY_ATTEMPTS,
32
+ MAX_RETRY_ELAPSED_MS,
33
+ MAX_ERROR_BODY_BYTES,
34
+ IDEMPOTENCY_KEY_PATTERN,
35
+ } from './bounds.ts';
36
+ export { DefaultRetryPolicy, parseRetryAfter } from './retry.ts';
37
+ export {
38
+ assertRequiredFields,
39
+ assertKnownEnum,
40
+ assertCapsule,
41
+ assertCapsuleAccess,
42
+ assertCapsuleLimits,
43
+ assertCapsuleListPage,
44
+ } from './response-validation.ts';
@@ -0,0 +1,86 @@
1
+ import { TargetProtocolError } from './errors.ts';
2
+ import { KNOWN_CAPSULE_STATES } from './types.ts';
3
+ import type { Capsule, CapsuleAccess, CapsuleLimits, CapsuleListPage } from './types.ts';
4
+
5
+ export function assertRequiredFields(
6
+ body: unknown,
7
+ fields: readonly string[],
8
+ context: string,
9
+ ): asserts body is Record<string, unknown> {
10
+ if (body === null || typeof body !== 'object') {
11
+ throw new TargetProtocolError(`${context}: expected object, got ${typeof body}`);
12
+ }
13
+ const record = body as Record<string, unknown>;
14
+ for (const field of fields) {
15
+ if (record[field] === undefined || record[field] === null) {
16
+ throw new TargetProtocolError(`${context}: missing required field "${field}"`);
17
+ }
18
+ }
19
+ }
20
+
21
+ export function assertKnownEnum(value: string, known: readonly string[], field: string): void {
22
+ if (!known.includes(value)) {
23
+ // eslint-disable-next-line no-console
24
+ console.warn(`Unknown ${field} value: "${value}". Known values: ${known.join(', ')}`);
25
+ }
26
+ }
27
+
28
+ export function assertCapsule(body: unknown): Capsule {
29
+ assertRequiredFields(body, ['id', 'state', 'createdAt'], 'Capsule');
30
+ const record = body as Record<string, unknown>;
31
+ if (typeof record['id'] !== 'string') {
32
+ throw new TargetProtocolError('Capsule: "id" must be a string');
33
+ }
34
+ if (typeof record['state'] !== 'string') {
35
+ throw new TargetProtocolError('Capsule: "state" must be a string');
36
+ }
37
+ if (typeof record['createdAt'] !== 'string') {
38
+ throw new TargetProtocolError('Capsule: "createdAt" must be a string');
39
+ }
40
+ assertKnownEnum(record['state'] as string, KNOWN_CAPSULE_STATES, 'CapsuleState');
41
+ return record as unknown as Capsule;
42
+ }
43
+
44
+ export function assertCapsuleAccess(body: unknown): CapsuleAccess {
45
+ assertRequiredFields(body, ['endpoint', 'token', 'expiresAt'], 'CapsuleAccess');
46
+ const record = body as Record<string, unknown>;
47
+ if (typeof record['endpoint'] !== 'string') {
48
+ throw new TargetProtocolError('CapsuleAccess: "endpoint" must be a string');
49
+ }
50
+ if (typeof record['token'] !== 'string') {
51
+ throw new TargetProtocolError('CapsuleAccess: "token" must be a string');
52
+ }
53
+ if (typeof record['expiresAt'] !== 'string') {
54
+ throw new TargetProtocolError('CapsuleAccess: "expiresAt" must be a string');
55
+ }
56
+ return record as unknown as CapsuleAccess;
57
+ }
58
+
59
+ export function assertCapsuleLimits(body: unknown): CapsuleLimits {
60
+ assertRequiredFields(body, ['maxConcurrent', 'maxPerHour'], 'CapsuleLimits');
61
+ const record = body as Record<string, unknown>;
62
+ if (typeof record['maxConcurrent'] !== 'number') {
63
+ throw new TargetProtocolError('CapsuleLimits: "maxConcurrent" must be a number');
64
+ }
65
+ if (typeof record['maxPerHour'] !== 'number') {
66
+ throw new TargetProtocolError('CapsuleLimits: "maxPerHour" must be a number');
67
+ }
68
+ return record as unknown as CapsuleLimits;
69
+ }
70
+
71
+ export function assertCapsuleListPage(body: unknown): CapsuleListPage {
72
+ assertRequiredFields(body, ['items'], 'CapsuleListPage');
73
+ const record = body as Record<string, unknown>;
74
+ if (!Array.isArray(record['items'])) {
75
+ throw new TargetProtocolError('CapsuleListPage: "items" must be an array');
76
+ }
77
+ const items = (record['items'] as unknown[]).map((item) => assertCapsule(item));
78
+ const cursor = record['cursor'];
79
+ if (cursor !== undefined && cursor !== null && typeof cursor !== 'string') {
80
+ throw new TargetProtocolError('CapsuleListPage: "cursor" must be a string if present');
81
+ }
82
+ if (typeof cursor === 'string') {
83
+ return { items, cursor };
84
+ }
85
+ return { items };
86
+ }
@@ -0,0 +1,46 @@
1
+ import { MAX_RETRY_ATTEMPTS, MAX_RETRY_ELAPSED_MS } from './bounds.ts';
2
+ import type { TargetAdapterError } from './errors.ts';
3
+ import { TargetAuthError, TargetRateLimitError } from './errors.ts';
4
+ import type { Clock, RetryPolicy } from './types.ts';
5
+
6
+ export class DefaultRetryPolicy implements RetryPolicy {
7
+ shouldRetry(
8
+ attempt: number,
9
+ elapsed: number,
10
+ error: TargetAdapterError,
11
+ ): { retry: boolean; delayMs: number } {
12
+ if (error instanceof TargetAuthError) return { retry: false, delayMs: 0 };
13
+ if (!error.retryable) return { retry: false, delayMs: 0 };
14
+ if (attempt >= MAX_RETRY_ATTEMPTS) return { retry: false, delayMs: 0 };
15
+ if (elapsed >= MAX_RETRY_ELAPSED_MS) return { retry: false, delayMs: 0 };
16
+
17
+ const requestedDelay =
18
+ error instanceof TargetRateLimitError && error.retryAfterMs !== undefined
19
+ ? Math.max(0, error.retryAfterMs)
20
+ : Math.min(1000 * Math.pow(2, attempt), 10_000);
21
+ const remaining = MAX_RETRY_ELAPSED_MS - elapsed;
22
+ if (!Number.isFinite(requestedDelay) || requestedDelay >= remaining) {
23
+ return { retry: false, delayMs: 0 };
24
+ }
25
+
26
+ return { retry: true, delayMs: requestedDelay };
27
+ }
28
+ }
29
+
30
+ export function parseRetryAfter(header: string | null, clock: Clock): number | null {
31
+ if (header === null) return null;
32
+
33
+ const trimmed = header.trim();
34
+ if (trimmed.length === 0) return null;
35
+
36
+ const numericSeconds = Number(trimmed);
37
+ if (Number.isFinite(numericSeconds) && numericSeconds >= 0) {
38
+ return Math.ceil(numericSeconds * 1000);
39
+ }
40
+
41
+ const date = Date.parse(trimmed);
42
+ if (Number.isNaN(date)) return null;
43
+
44
+ const delayMs = date - clock.now();
45
+ return delayMs > 0 ? Math.ceil(delayMs) : 0;
46
+ }
@@ -0,0 +1,10 @@
1
+ import type { AllocateRequest, Capsule, CapsuleAccess, CapsuleLimits, CapsuleListPage } from './types.ts';
2
+
3
+ export interface TargetAdapter {
4
+ allocate(req: AllocateRequest, signal?: AbortSignal): Promise<Capsule>;
5
+ list(cursor?: string, signal?: AbortSignal): Promise<CapsuleListPage>;
6
+ inspect(capsuleId: string, signal?: AbortSignal): Promise<Capsule>;
7
+ terminate(capsuleId: string, signal?: AbortSignal): Promise<void>;
8
+ limits(signal?: AbortSignal): Promise<CapsuleLimits>;
9
+ access(capsuleId: string, signal?: AbortSignal): Promise<CapsuleAccess>;
10
+ }
@@ -0,0 +1,58 @@
1
+ import type { TargetAdapterError } from './errors.ts';
2
+
3
+ export interface TargetAccessTokenProvider {
4
+ getAccessToken(signal?: AbortSignal): Promise<string>;
5
+ }
6
+
7
+ export type CapsuleState = 'provisioning' | 'running' | 'stopping' | 'terminated' | 'failed' | (string & {});
8
+
9
+ export const KNOWN_CAPSULE_STATES = ['provisioning', 'running', 'stopping', 'terminated', 'failed'] as const;
10
+
11
+ export interface Capsule {
12
+ readonly id: string;
13
+ readonly state: CapsuleState;
14
+ readonly createdAt: string;
15
+ readonly [key: string]: unknown;
16
+ }
17
+
18
+ export interface CapsuleAccess {
19
+ readonly endpoint: string;
20
+ readonly token: string;
21
+ readonly expiresAt: string;
22
+ }
23
+
24
+ export interface CapsuleListPage {
25
+ readonly items: readonly Capsule[];
26
+ readonly cursor?: string;
27
+ }
28
+
29
+ export interface CapsuleLimits {
30
+ readonly maxConcurrent: number;
31
+ readonly maxPerHour: number;
32
+ readonly [key: string]: unknown;
33
+ }
34
+
35
+ export interface AllocateRequest {
36
+ readonly idempotencyKey: string;
37
+ readonly profile: string;
38
+ }
39
+
40
+ export interface HttpTransport {
41
+ fetch(url: string, init: RequestInit & { redirect: 'error' }): Promise<Response>;
42
+ }
43
+
44
+ export interface Clock {
45
+ now(): number;
46
+ }
47
+
48
+ export interface RetryPolicy {
49
+ shouldRetry(
50
+ attempt: number,
51
+ elapsed: number,
52
+ error: TargetAdapterError,
53
+ ): { retry: boolean; delayMs: number };
54
+ }
55
+
56
+ export interface TargetDiscovery {
57
+ readonly capsuleV1: string;
58
+ }
@@ -0,0 +1,386 @@
1
+ import { MAX_ERROR_BODY_BYTES, MAX_RESPONSE_BYTES, MAX_RETRY_ELAPSED_MS, IDEMPOTENCY_KEY_PATTERN } from './bounds.ts';
2
+ import {
3
+ TargetAuthError,
4
+ TargetConflictError,
5
+ TargetNotFoundError,
6
+ TargetProtocolError,
7
+ TargetRateLimitError,
8
+ TargetTransportError,
9
+ } from './errors.ts';
10
+ import { TargetAdapterError } from './errors.ts';
11
+ import { DefaultRetryPolicy, parseRetryAfter } from './retry.ts';
12
+ import {
13
+ assertCapsule,
14
+ assertCapsuleAccess,
15
+ assertCapsuleLimits,
16
+ assertCapsuleListPage,
17
+ } from './response-validation.ts';
18
+ import type { TargetAdapter } from './target-adapter.ts';
19
+ import type {
20
+ AllocateRequest,
21
+ Capsule,
22
+ CapsuleAccess,
23
+ CapsuleLimits,
24
+ CapsuleListPage,
25
+ Clock,
26
+ HttpTransport,
27
+ RetryPolicy,
28
+ TargetAccessTokenProvider,
29
+ TargetDiscovery,
30
+ } from './types.ts';
31
+
32
+ interface ZeroCloudV1Options {
33
+ readonly discovery: TargetDiscovery;
34
+ readonly organization: string;
35
+ readonly tokenProvider: TargetAccessTokenProvider;
36
+ readonly transport?: HttpTransport;
37
+ readonly clock?: Clock;
38
+ readonly retryPolicy?: RetryPolicy;
39
+ }
40
+
41
+ const DEFAULT_TRANSPORT: HttpTransport = {
42
+ fetch(url: string, init: RequestInit & { redirect: 'error' }): Promise<Response> {
43
+ return globalThis.fetch(url, init);
44
+ },
45
+ };
46
+
47
+ const DEFAULT_CLOCK: Clock = { now: () => Date.now() };
48
+
49
+ function throwIfAborted(signal?: AbortSignal): void {
50
+ if (signal?.aborted) {
51
+ throw signal.reason === undefined
52
+ ? new DOMException('The operation was aborted', 'AbortError')
53
+ : signal.reason;
54
+ }
55
+ }
56
+
57
+ async function waitForRetryDelay(delayMs: number, signal?: AbortSignal): Promise<void> {
58
+ throwIfAborted(signal);
59
+ if (delayMs <= 0) return;
60
+
61
+ await new Promise<void>((resolve, reject) => {
62
+ const onAbort = (): void => {
63
+ clearTimeout(timer);
64
+ reject(
65
+ signal!.reason === undefined
66
+ ? new DOMException('The operation was aborted', 'AbortError')
67
+ : signal!.reason,
68
+ );
69
+ };
70
+ const timer = setTimeout(() => {
71
+ signal?.removeEventListener('abort', onAbort);
72
+ resolve();
73
+ }, delayMs);
74
+ signal?.addEventListener('abort', onAbort, { once: true });
75
+ });
76
+ }
77
+
78
+ function originOf(url: string): string {
79
+ try {
80
+ const u = new URL(url);
81
+ return u.origin;
82
+ } catch {
83
+ throw new TargetProtocolError(`Invalid URL: ${url}`);
84
+ }
85
+ }
86
+
87
+ export class ZeroCloudV1TargetAdapter implements TargetAdapter {
88
+ private readonly discovery: TargetDiscovery;
89
+ private readonly organization: string;
90
+ private readonly tokenProvider: TargetAccessTokenProvider;
91
+ private readonly transport: HttpTransport;
92
+ private readonly clock: Clock;
93
+ private readonly retryPolicy: RetryPolicy;
94
+ private readonly expectedOrigin: string;
95
+
96
+ constructor(opts: ZeroCloudV1Options) {
97
+ this.discovery = opts.discovery;
98
+ this.organization = opts.organization;
99
+ this.tokenProvider = opts.tokenProvider;
100
+ this.transport = opts.transport ?? DEFAULT_TRANSPORT;
101
+ this.clock = opts.clock ?? DEFAULT_CLOCK;
102
+ this.retryPolicy = opts.retryPolicy ?? new DefaultRetryPolicy();
103
+ this.expectedOrigin = originOf(opts.discovery.capsuleV1);
104
+ }
105
+
106
+ async allocate(req: AllocateRequest, signal?: AbortSignal): Promise<Capsule> {
107
+ if (!IDEMPOTENCY_KEY_PATTERN.test(req.idempotencyKey)) {
108
+ throw new TargetProtocolError(
109
+ `Invalid idempotency key: must match ${IDEMPOTENCY_KEY_PATTERN}`,
110
+ );
111
+ }
112
+
113
+ const body = JSON.stringify({ profile: req.profile, organization: this.organization });
114
+
115
+ return this._withRetry(async () => {
116
+ const resp = await this._request('POST', '/capsules', {
117
+ signal,
118
+ body,
119
+ headers: { 'Idempotency-Key': req.idempotencyKey },
120
+ });
121
+ const json = await this._readJson(resp);
122
+ return assertCapsule(json);
123
+ }, signal);
124
+ }
125
+
126
+ async list(cursor?: string, signal?: AbortSignal): Promise<CapsuleListPage> {
127
+ return this._withRetry(async () => {
128
+ const params = new URLSearchParams({ organization: this.organization });
129
+ if (cursor) params.set('cursor', cursor);
130
+ const resp = await this._request('GET', `/capsules?${params.toString()}`, { signal });
131
+ const json = await this._readJson(resp);
132
+ const page = assertCapsuleListPage(json);
133
+ if (page.cursor !== undefined && page.cursor === cursor) {
134
+ throw new TargetProtocolError(
135
+ `Pagination loop detected: server returned the same cursor "${cursor}"`,
136
+ );
137
+ }
138
+ return page;
139
+ }, signal);
140
+ }
141
+
142
+ async inspect(capsuleId: string, signal?: AbortSignal): Promise<Capsule> {
143
+ return this._withRetry(async () => {
144
+ const resp = await this._request('GET', `/capsules/${encodeURIComponent(capsuleId)}`, {
145
+ signal,
146
+ });
147
+ const json = await this._readJson(resp);
148
+ return assertCapsule(json);
149
+ }, signal);
150
+ }
151
+
152
+ async terminate(capsuleId: string, signal?: AbortSignal): Promise<void> {
153
+ await this._withRetry(async () => {
154
+ const resp = await this._request(
155
+ 'DELETE',
156
+ `/capsules/${encodeURIComponent(capsuleId)}`,
157
+ { signal },
158
+ );
159
+ if (resp.status !== 204) {
160
+ const json = await this._readJson(resp);
161
+ throw new TargetProtocolError(`Unexpected terminate response: ${JSON.stringify(json)}`);
162
+ }
163
+ }, signal);
164
+ }
165
+
166
+ async limits(signal?: AbortSignal): Promise<CapsuleLimits> {
167
+ return this._withRetry(async () => {
168
+ const params = new URLSearchParams({ organization: this.organization });
169
+ const resp = await this._request('GET', `/limits?${params.toString()}`, { signal });
170
+ const json = await this._readJson(resp);
171
+ return assertCapsuleLimits(json);
172
+ }, signal);
173
+ }
174
+
175
+ async access(capsuleId: string, signal?: AbortSignal): Promise<CapsuleAccess> {
176
+ return this._withRetry(async () => {
177
+ const resp = await this._request(
178
+ 'POST',
179
+ `/capsules/${encodeURIComponent(capsuleId)}/access`,
180
+ { signal },
181
+ );
182
+ const json = await this._readJson(resp);
183
+ return assertCapsuleAccess(json);
184
+ }, signal);
185
+ }
186
+
187
+ private async _request(
188
+ method: string,
189
+ path: string,
190
+ opts: { signal?: AbortSignal | undefined; body?: string | undefined; headers?: Record<string, string> | undefined },
191
+ ): Promise<Response> {
192
+ const url = `${this.discovery.capsuleV1}${path}`;
193
+
194
+ const responseOrigin = originOf(url);
195
+ if (responseOrigin !== this.expectedOrigin) {
196
+ throw new TargetProtocolError(
197
+ `Origin mismatch: expected ${this.expectedOrigin}, got ${responseOrigin}`,
198
+ );
199
+ }
200
+
201
+ let token: string;
202
+ try {
203
+ token = await this.tokenProvider.getAccessToken(opts.signal);
204
+ } catch (err) {
205
+ throw new TargetTransportError('Failed to acquire access token', err);
206
+ }
207
+
208
+ const headers: Record<string, string> = {
209
+ 'Authorization': `Bearer ${token}`,
210
+ 'Content-Type': 'application/json',
211
+ 'Accept': 'application/json',
212
+ ...opts.headers,
213
+ };
214
+
215
+ let response: Response;
216
+ const fetchInit: RequestInit & { redirect: 'error' } = {
217
+ method,
218
+ headers,
219
+ redirect: 'error',
220
+ };
221
+ if (opts.body !== undefined) fetchInit.body = opts.body;
222
+ if (opts.signal !== undefined) fetchInit.signal = opts.signal;
223
+
224
+ try {
225
+ response = await this.transport.fetch(url, fetchInit);
226
+ } catch (err) {
227
+ if (err instanceof TargetAdapterError) throw err;
228
+ const msg = err instanceof Error ? err.message : String(err);
229
+ if (msg.includes('redirect')) {
230
+ throw new TargetProtocolError(`Redirect rejected for ${method} ${path}`, err);
231
+ }
232
+ throw new TargetTransportError(`Network error during ${method} ${path}`, err);
233
+ }
234
+
235
+ if (response.status < 200 || response.status >= 300) {
236
+ await this._mapStatusError(response, method, path, opts.headers);
237
+ }
238
+
239
+ return response;
240
+ }
241
+
242
+ private async _mapStatusError(
243
+ response: Response,
244
+ method: string,
245
+ path: string,
246
+ headers?: Record<string, string>,
247
+ ): Promise<never> {
248
+ const status = response.status;
249
+ const errorBody = await this._readErrorBody(response);
250
+ const context = `${status} ${method} ${path}: ${errorBody}`;
251
+
252
+ if (status === 401 || status === 403) throw new TargetAuthError(context);
253
+ if (status === 404) throw new TargetNotFoundError(context);
254
+ if (status === 409) {
255
+ const idempotencyKey = headers?.['Idempotency-Key'] ?? 'unknown';
256
+ throw new TargetConflictError(idempotencyKey, context);
257
+ }
258
+ if (status === 429) {
259
+ const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'), this.clock);
260
+ throw new TargetRateLimitError(context, retryAfterMs ?? undefined);
261
+ }
262
+ if (status >= 500) throw new TargetTransportError(context);
263
+ throw new TargetProtocolError(`Unexpected status ${context}`);
264
+ }
265
+
266
+ private async _readJson(response: Response): Promise<unknown> {
267
+ const contentLength = response.headers.get('Content-Length');
268
+ if (contentLength !== null) {
269
+ const len = parseInt(contentLength, 10);
270
+ if (Number.isFinite(len) && len > MAX_RESPONSE_BYTES) {
271
+ throw new TargetProtocolError(
272
+ `Response too large: ${len} bytes exceeds limit of ${MAX_RESPONSE_BYTES}`,
273
+ );
274
+ }
275
+ }
276
+
277
+ let text: string;
278
+ try {
279
+ const reader = response.body?.getReader();
280
+ if (!reader) {
281
+ text = await response.text();
282
+ } else {
283
+ const chunks: Uint8Array[] = [];
284
+ let totalBytes = 0;
285
+ while (true) {
286
+ const { done, value } = await reader.read();
287
+ if (done) break;
288
+ totalBytes += value.byteLength;
289
+ if (totalBytes > MAX_RESPONSE_BYTES) {
290
+ reader.cancel();
291
+ throw new TargetProtocolError(
292
+ `Response body exceeds limit of ${MAX_RESPONSE_BYTES} bytes`,
293
+ );
294
+ }
295
+ chunks.push(value);
296
+ }
297
+ const combined = new Uint8Array(totalBytes);
298
+ let offset = 0;
299
+ for (const chunk of chunks) {
300
+ combined.set(chunk, offset);
301
+ offset += chunk.byteLength;
302
+ }
303
+ text = new TextDecoder().decode(combined);
304
+ }
305
+ } catch (err) {
306
+ if (err instanceof TargetProtocolError) throw err;
307
+ throw new TargetProtocolError('Failed to read response body', err);
308
+ }
309
+
310
+ try {
311
+ return JSON.parse(text);
312
+ } catch (err) {
313
+ throw new TargetProtocolError('Invalid JSON in response body', err);
314
+ }
315
+ }
316
+
317
+ private async _readErrorBody(response: Response): Promise<string> {
318
+ const reader = response.body?.getReader();
319
+ if (!reader) return '';
320
+
321
+ const chunks: Uint8Array[] = [];
322
+ let totalBytes = 0;
323
+ try {
324
+ while (totalBytes < MAX_ERROR_BODY_BYTES) {
325
+ const { done, value } = await reader.read();
326
+ if (done) break;
327
+
328
+ const remaining = MAX_ERROR_BODY_BYTES - totalBytes;
329
+ const retained = value.byteLength > remaining ? value.slice(0, remaining) : value;
330
+ chunks.push(retained);
331
+ totalBytes += retained.byteLength;
332
+ if (retained.byteLength !== value.byteLength || totalBytes === MAX_ERROR_BODY_BYTES) {
333
+ void reader.cancel().catch(() => undefined);
334
+ break;
335
+ }
336
+ }
337
+ } catch {
338
+ void reader.cancel().catch(() => undefined);
339
+ return '<unreadable>';
340
+ }
341
+
342
+ const combined = new Uint8Array(totalBytes);
343
+ let offset = 0;
344
+ for (const chunk of chunks) {
345
+ combined.set(chunk, offset);
346
+ offset += chunk.byteLength;
347
+ }
348
+ return new TextDecoder().decode(combined);
349
+ }
350
+
351
+ private async _withRetry<T>(
352
+ fn: () => Promise<T>,
353
+ signal?: AbortSignal,
354
+ ): Promise<T> {
355
+ const startTime = this.clock.now();
356
+ let attempt = 0;
357
+
358
+ while (true) {
359
+ throwIfAborted(signal);
360
+ try {
361
+ return await fn();
362
+ } catch (err) {
363
+ throwIfAborted(signal);
364
+ if (!(err instanceof TargetAdapterError)) throw err;
365
+ if (err instanceof TargetAuthError) throw err;
366
+
367
+ attempt++;
368
+ const elapsed = this.clock.now() - startTime;
369
+ const decision = this.retryPolicy.shouldRetry(attempt, elapsed, err);
370
+ const remaining = MAX_RETRY_ELAPSED_MS - elapsed;
371
+
372
+ if (
373
+ !decision.retry ||
374
+ !Number.isFinite(decision.delayMs) ||
375
+ decision.delayMs < 0 ||
376
+ decision.delayMs >= remaining
377
+ ) {
378
+ throw err;
379
+ }
380
+
381
+ await waitForRetryDelay(decision.delayMs, signal);
382
+ if (this.clock.now() - startTime >= MAX_RETRY_ELAPSED_MS) throw err;
383
+ }
384
+ }
385
+ }
386
+ }