cloudcruise 1.0.1 → 1.1.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.
@@ -6,7 +6,7 @@ export interface CloudCruiseParams {
6
6
  apiKey?: string;
7
7
  /**
8
8
  * CloudCruise API base URL. Authenticated requests are restricted to the
9
- * production CloudCruise API origin.
9
+ * production CloudCruise API origin or the staging API origin.
10
10
  */
11
11
  baseUrl?: string;
12
12
  encryptionKey?: string;
@@ -5,6 +5,8 @@ import { RunsClient } from './runs/RunsClient.js';
5
5
  import { WebhookClient } from './webhook/WebhookClient.js';
6
6
  import { ConnectionManager } from './utils/connectionManager.js';
7
7
  const DEFAULT_BASE_URL = 'https://api.cloudcruise.com';
8
+ const STAGING_BASE_URL = 'https://staging-api.cloudcruise.app';
9
+ const ALLOWED_BASE_URLS = new Set([DEFAULT_BASE_URL, STAGING_BASE_URL]);
8
10
  const DEFAULT_API_HOST = new URL(DEFAULT_BASE_URL).host.toLowerCase();
9
11
  function normalizeBaseUrl(baseUrl) {
10
12
  let url;
@@ -27,9 +29,9 @@ function assertBaseUrlAllowed(baseUrl) {
27
29
  if (host === DEFAULT_API_HOST && url.protocol !== 'https:') {
28
30
  throw new Error(`Refusing to send CloudCruise API key to "${baseUrl}". The default CloudCruise API host requires https:.`);
29
31
  }
30
- if (baseUrl !== DEFAULT_BASE_URL) {
32
+ if (!ALLOWED_BASE_URLS.has(baseUrl)) {
31
33
  throw new Error(`Refusing to send CloudCruise API key to unapproved baseUrl "${baseUrl}". ` +
32
- `Authenticated requests are restricted to ${DEFAULT_BASE_URL}.`);
34
+ `Authenticated requests are restricted to: ${Array.from(ALLOWED_BASE_URLS).join(", ")}.`);
33
35
  }
34
36
  }
35
37
  export class CloudCruise {
@@ -17,7 +17,8 @@ export declare enum EventType {
17
17
  InteractionWaiting = "interaction.waiting",
18
18
  InteractionFinished = "interaction.finished",
19
19
  InteractionFailed = "interaction.failed",
20
- AgentErrorAnalysis = "agent.error_analysis"
20
+ AgentErrorAnalysis = "agent.error_analysis",
21
+ ExecutionInputRequired = "execution.input_required"
21
22
  }
22
23
  export interface ExecutionQueuedPayload {
23
24
  session_id: string;
@@ -61,6 +62,37 @@ export interface AgentErrorAnalysisPayload {
61
62
  ai_analysis?: string;
62
63
  root_cause_analysis?: string;
63
64
  error_category?: string;
65
+ phase?: "modal_decision_dispatched" | "popup_dismiss_verified" | string;
66
+ session_id?: string;
67
+ modal_action?: string;
68
+ modal_action_label?: string;
69
+ response_time_ms?: number;
70
+ outcome?: "success" | "failure";
71
+ host?: string;
72
+ popup_signature?: string;
73
+ }
74
+ export interface AvailableAction {
75
+ id: string;
76
+ label: string;
77
+ }
78
+ export interface PopupRetry {
79
+ attempt: number;
80
+ max_attempts: number;
81
+ }
82
+ export interface PopupContext {
83
+ error_description: string;
84
+ error_sub_type?: string;
85
+ full_url?: string;
86
+ available_actions: AvailableAction[];
87
+ retry: PopupRetry;
88
+ }
89
+ export type InputRequiredReason = "input_required" | "incorrect_form_input" | "multiple_matching_results" | "non_dismissible_popup";
90
+ export interface ExecutionInputRequiredPayload {
91
+ session_id: string;
92
+ input_variables: Record<string, any>;
93
+ screenshot_url: string | null;
94
+ reason?: InputRequiredReason;
95
+ popup_context?: PopupContext;
64
96
  }
65
97
  export interface ExecutionRequeuedPayload {
66
98
  session_id: string;
@@ -132,6 +164,7 @@ export type EventPayloadMap = {
132
164
  [EventType.VideoUploaded]: never;
133
165
  [EventType.ExecutionPause]: never;
134
166
  [EventType.InteractionFailed]: never;
167
+ [EventType.ExecutionInputRequired]: ExecutionInputRequiredPayload;
135
168
  };
136
169
  export type WebhookMessage<E extends EventType = EventType> = {
137
170
  event: E;
@@ -19,4 +19,5 @@ export var EventType;
19
19
  EventType["InteractionFinished"] = "interaction.finished";
20
20
  EventType["InteractionFailed"] = "interaction.failed";
21
21
  EventType["AgentErrorAnalysis"] = "agent.error_analysis";
22
+ EventType["ExecutionInputRequired"] = "execution.input_required";
22
23
  })(EventType || (EventType = {}));
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export type { VaultEntry, GetVaultEntriesFilters, ProxyConfig, VaultPostPutHeade
12
12
  export type { Workflow, WorkflowInputSchema, WorkflowMetadata } from './workflows/types.js';
13
13
  export type { DryRun, Metadata, RunSpecificWebhook, PayloadWebhook, StartRunRequest, StartRunResponse, UserInteractionData, VideoUrl, SignedFileUrl, SignedScreenshotUrl, RunError, WorkflowError, RunResult, GetRunResult, WebhookEvent, WebhookReplayResponse, RunHandle, RunStreamOptions, SseEventName, SseMessage, RunEventEnvelope, RunHandleEventMap } from './runs/types.js';
14
14
  export { EventType } from './events/types.js';
15
- export type { WebhookMessage, RunEventMessage } from './events/types.js';
15
+ export type { WebhookMessage, RunEventMessage, AvailableAction, PopupRetry, PopupContext, InputRequiredReason, ExecutionInputRequiredPayload, AgentErrorAnalysisPayload } from './events/types.js';
16
16
  export type { WebhookVerificationOptions } from './webhook/types.js';
17
17
  export { VerificationError } from './webhook/types.js';
18
18
  export { InputValidationError } from './workflows/types.js';
@@ -1,4 +1,5 @@
1
1
  import type { StartRunRequest, UserInteractionData, GetRunResult, WebhookReplayResponse, RunHandle, RunStreamOptions } from './types.js';
2
+ import type { PopupContext, ExecutionInputRequiredPayload } from '../events/types.js';
2
3
  import { ConnectionManager } from '../utils/connectionManager.js';
3
4
  export declare class RunsClient {
4
5
  private readonly makeRequest;
@@ -22,6 +23,64 @@ export declare class RunsClient {
22
23
  * @param data - User input data as key-value pairs
23
24
  */
24
25
  submitUserInteraction(sessionId: string, data: UserInteractionData): Promise<void>;
26
+ /**
27
+ * Responds to an execution.input_required event whose reason is
28
+ * "non_dismissible_popup" by picking one of the CTA buttons surfaced in
29
+ * popup_context.available_actions. The backend dispatches a synthetic
30
+ * click on the chosen button and resumes the workflow.
31
+ *
32
+ * Only valid while the session is waiting for input. The backing endpoint
33
+ * returns 400 if the wait already expired (the workspace setting
34
+ * input_required_timeout_seconds, default 15s, max 300s).
35
+ *
36
+ * @param sessionId - The session waiting for input.
37
+ * @param actionId - One of the ids in popup_context.available_actions.
38
+ */
39
+ submitModalAction(sessionId: string, actionId: string): Promise<void>;
40
+ /**
41
+ * Responds to an execution.input_required event whose reason is
42
+ * "input_required", "incorrect_form_input", or "multiple_matching_results"
43
+ * by supplying the corrected/required input variables. Backend resumes from
44
+ * the appropriate recovery node with the new values substituted in.
45
+ *
46
+ * Mutually exclusive with submitModalAction at the endpoint level.
47
+ *
48
+ * @param sessionId - The session waiting for input.
49
+ * @param inputVariables - Mapping of variable name to new value.
50
+ */
51
+ submitInputVariables(sessionId: string, inputVariables: Record<string, any>): Promise<void>;
52
+ /**
53
+ * Registers a listener that auto-responds ONLY to non-dismissible modal
54
+ * input_required events (reason === "non_dismissible_popup"). The decider
55
+ * receives the popup_context and must return one of the action ids in
56
+ * popup_context.available_actions.
57
+ *
58
+ * The SDK never picks an action on its own. The customer's decider IS the
59
+ * decision point. If decider throws, the listener swallows it and skips
60
+ * submission; the backend's input wait will time out naturally.
61
+ *
62
+ * Other input_required reasons (incorrect_form_input, etc.) are ignored
63
+ * here and should be routed to onInputVariablesRequired.
64
+ *
65
+ * @returns An unsubscribe callable.
66
+ */
67
+ onPopupDecisionRequired(handle: RunHandle, decider: (ctx: PopupContext) => string | Promise<string>, onError?: (err: unknown) => void, options?: {
68
+ verbose?: boolean;
69
+ }): () => void;
70
+ /**
71
+ * Registers a listener that auto-responds ONLY to workflow-variable
72
+ * input_required events (reason in {"input_required",
73
+ * "incorrect_form_input", "multiple_matching_results"}). The decider
74
+ * receives the full payload and must return the input_variables dict.
75
+ *
76
+ * Counterpart to onPopupDecisionRequired. Modal events
77
+ * (reason === "non_dismissible_popup") are routed there and ignored here.
78
+ *
79
+ * @returns An unsubscribe callable.
80
+ */
81
+ onInputVariablesRequired(handle: RunHandle, decider: (payload: ExecutionInputRequiredPayload) => Record<string, any> | Promise<Record<string, any>>, onError?: (err: unknown) => void, options?: {
82
+ verbose?: boolean;
83
+ }): () => void;
25
84
  /**
26
85
  * Retrieves comprehensive results and execution details for a specific run
27
86
  * @param sessionId - The unique identifier for the workflow execution session
@@ -1,6 +1,37 @@
1
1
  import { EventType } from '../events/types.js';
2
2
  import { AsyncEventQueue } from '../utils/asyncQueue.js';
3
3
  import { SimpleEventEmitter } from '../utils/events.js';
4
+ /**
5
+ * Default error reporter for the recovery helpers. Writes to console.error
6
+ * so submission failures surface in customer logs even if no onError
7
+ * callback is provided. The runtime SimpleEventEmitter ignores async-handler
8
+ * returns, so without this default a rejection from /new_input_variables
9
+ * would become a silent unhandled rejection.
10
+ */
11
+ function defaultRecoverySubmitErrorLog(operation) {
12
+ return (err) => {
13
+ try {
14
+ const msg = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
15
+ console.error(`[CloudCruise SDK] ${operation} failed during recovery: ${msg}`);
16
+ }
17
+ catch {
18
+ // never throw from the error reporter
19
+ }
20
+ };
21
+ }
22
+ /**
23
+ * Helper for `verbose: true` mode on the recovery helpers. Writes a single
24
+ * line to console.error so customers can watch the recovery loop without
25
+ * instrumenting their own decider.
26
+ */
27
+ function verboseLog(operation, message) {
28
+ try {
29
+ console.error(`[CloudCruise SDK verbose] ${operation}: ${message}`);
30
+ }
31
+ catch {
32
+ // never throw
33
+ }
34
+ }
4
35
  export class RunsClient {
5
36
  makeRequest;
6
37
  workflows;
@@ -171,6 +202,175 @@ export class RunsClient {
171
202
  const path = `/run/${sessionId}/user_interaction`;
172
203
  await this.makeRequest('POST', path, data);
173
204
  }
205
+ /**
206
+ * Responds to an execution.input_required event whose reason is
207
+ * "non_dismissible_popup" by picking one of the CTA buttons surfaced in
208
+ * popup_context.available_actions. The backend dispatches a synthetic
209
+ * click on the chosen button and resumes the workflow.
210
+ *
211
+ * Only valid while the session is waiting for input. The backing endpoint
212
+ * returns 400 if the wait already expired (the workspace setting
213
+ * input_required_timeout_seconds, default 15s, max 300s).
214
+ *
215
+ * @param sessionId - The session waiting for input.
216
+ * @param actionId - One of the ids in popup_context.available_actions.
217
+ */
218
+ async submitModalAction(sessionId, actionId) {
219
+ const path = `/run/${sessionId}/new_input_variables`;
220
+ await this.makeRequest('POST', path, { modal_action: actionId });
221
+ }
222
+ /**
223
+ * Responds to an execution.input_required event whose reason is
224
+ * "input_required", "incorrect_form_input", or "multiple_matching_results"
225
+ * by supplying the corrected/required input variables. Backend resumes from
226
+ * the appropriate recovery node with the new values substituted in.
227
+ *
228
+ * Mutually exclusive with submitModalAction at the endpoint level.
229
+ *
230
+ * @param sessionId - The session waiting for input.
231
+ * @param inputVariables - Mapping of variable name to new value.
232
+ */
233
+ async submitInputVariables(sessionId, inputVariables) {
234
+ const path = `/run/${sessionId}/new_input_variables`;
235
+ await this.makeRequest('POST', path, { input_variables: inputVariables });
236
+ }
237
+ /**
238
+ * Registers a listener that auto-responds ONLY to non-dismissible modal
239
+ * input_required events (reason === "non_dismissible_popup"). The decider
240
+ * receives the popup_context and must return one of the action ids in
241
+ * popup_context.available_actions.
242
+ *
243
+ * The SDK never picks an action on its own. The customer's decider IS the
244
+ * decision point. If decider throws, the listener swallows it and skips
245
+ * submission; the backend's input wait will time out naturally.
246
+ *
247
+ * Other input_required reasons (incorrect_form_input, etc.) are ignored
248
+ * here and should be routed to onInputVariablesRequired.
249
+ *
250
+ * @returns An unsubscribe callable.
251
+ */
252
+ onPopupDecisionRequired(handle, decider, onError, options) {
253
+ const verbose = options?.verbose === true;
254
+ const reportError = (err) => {
255
+ const reporter = onError ?? defaultRecoverySubmitErrorLog('submitModalAction');
256
+ try {
257
+ reporter(err);
258
+ }
259
+ catch {
260
+ // defense: a buggy onError must not crash the event loop
261
+ }
262
+ };
263
+ if (verbose)
264
+ verboseLog('onPopupDecisionRequired', 'listener registered');
265
+ const listener = async (event) => {
266
+ const payload = (event?.data?.payload ?? event?.payload);
267
+ if (!payload || payload.reason !== 'non_dismissible_popup' || !payload.popup_context) {
268
+ if (verbose) {
269
+ verboseLog('onPopupDecisionRequired', `skipping reason=${payload?.reason ?? 'undefined'}`);
270
+ }
271
+ return;
272
+ }
273
+ if (verbose) {
274
+ const attempt = payload.popup_context.retry?.attempt;
275
+ const actions = payload.popup_context.available_actions?.map((a) => a.id) ?? [];
276
+ verboseLog('onPopupDecisionRequired', `event received attempt=${attempt} actions=${JSON.stringify(actions)}`);
277
+ }
278
+ let actionId;
279
+ try {
280
+ actionId = await decider(payload.popup_context);
281
+ }
282
+ catch (e) {
283
+ if (verbose)
284
+ verboseLog('onPopupDecisionRequired', `decider raised: ${e}`);
285
+ return;
286
+ }
287
+ if (typeof actionId !== 'string' || actionId.length === 0) {
288
+ if (verbose)
289
+ verboseLog('onPopupDecisionRequired', `decider returned non-string/empty (${JSON.stringify(actionId)}); skipping`);
290
+ return;
291
+ }
292
+ const sid = payload.session_id || handle.sessionId;
293
+ if (verbose)
294
+ verboseLog('onPopupDecisionRequired', `submitting modal_action=${JSON.stringify(actionId)} for session=${sid}`);
295
+ try {
296
+ await this.submitModalAction(sid, actionId);
297
+ if (verbose)
298
+ verboseLog('onPopupDecisionRequired', `submit ok for action=${JSON.stringify(actionId)}`);
299
+ }
300
+ catch (err) {
301
+ reportError(err);
302
+ }
303
+ };
304
+ const unsubscribe = handle.on(EventType.ExecutionInputRequired, listener);
305
+ return typeof unsubscribe === 'function' ? unsubscribe : () => { };
306
+ }
307
+ /**
308
+ * Registers a listener that auto-responds ONLY to workflow-variable
309
+ * input_required events (reason in {"input_required",
310
+ * "incorrect_form_input", "multiple_matching_results"}). The decider
311
+ * receives the full payload and must return the input_variables dict.
312
+ *
313
+ * Counterpart to onPopupDecisionRequired. Modal events
314
+ * (reason === "non_dismissible_popup") are routed there and ignored here.
315
+ *
316
+ * @returns An unsubscribe callable.
317
+ */
318
+ onInputVariablesRequired(handle, decider, onError, options) {
319
+ const verbose = options?.verbose === true;
320
+ const VARIABLE_REASONS = new Set([
321
+ 'input_required',
322
+ 'incorrect_form_input',
323
+ 'multiple_matching_results',
324
+ ]);
325
+ const reportError = (err) => {
326
+ const reporter = onError ?? defaultRecoverySubmitErrorLog('submitInputVariables');
327
+ try {
328
+ reporter(err);
329
+ }
330
+ catch {
331
+ // defense
332
+ }
333
+ };
334
+ if (verbose)
335
+ verboseLog('onInputVariablesRequired', 'listener registered');
336
+ const listener = async (event) => {
337
+ const payload = (event?.data?.payload ?? event?.payload);
338
+ if (!payload || !payload.reason || !VARIABLE_REASONS.has(payload.reason)) {
339
+ if (verbose)
340
+ verboseLog('onInputVariablesRequired', `skipping reason=${payload?.reason ?? 'undefined'}`);
341
+ return;
342
+ }
343
+ if (verbose)
344
+ verboseLog('onInputVariablesRequired', `event received reason=${payload.reason}`);
345
+ let inputVars;
346
+ try {
347
+ inputVars = await decider(payload);
348
+ }
349
+ catch (e) {
350
+ if (verbose)
351
+ verboseLog('onInputVariablesRequired', `decider raised: ${e}`);
352
+ return;
353
+ }
354
+ if (!inputVars || typeof inputVars !== 'object' || Array.isArray(inputVars)) {
355
+ if (verbose)
356
+ verboseLog('onInputVariablesRequired', `decider returned non-object (${Array.isArray(inputVars) ? 'array' : typeof inputVars}); skipping`);
357
+ return;
358
+ }
359
+ const sid = payload.session_id || handle.sessionId;
360
+ if (verbose)
361
+ verboseLog('onInputVariablesRequired', `submitting input_variables keys=${JSON.stringify(Object.keys(inputVars))} for session=${sid}`);
362
+ try {
363
+ await this.submitInputVariables(sid, inputVars);
364
+ if (verbose)
365
+ verboseLog('onInputVariablesRequired', 'submit ok');
366
+ }
367
+ catch (err) {
368
+ reportError(err);
369
+ }
370
+ };
371
+ const unsubscribe = handle.on(EventType.ExecutionInputRequired, listener);
372
+ return typeof unsubscribe === 'function' ? unsubscribe : () => { };
373
+ }
174
374
  /**
175
375
  * Retrieves comprehensive results and execution details for a specific run
176
376
  * @param sessionId - The unique identifier for the workflow execution session
@@ -38,6 +38,8 @@ export interface VaultEntry {
38
38
  cookie_domain_to_store?: string | null;
39
39
  proxy?: ProxyConfig;
40
40
  proxy_string?: string | null;
41
+ proxy_setting?: 'random' | 'static' | 'country' | 'custom' | null;
42
+ proxy_value?: string | null;
41
43
  headers?: VaultPostPutHeadersInBody[];
42
44
  created_at?: string | null;
43
45
  session_data_set_at?: string | null;
@@ -18,7 +18,8 @@ export declare function encryptData(data: any, keyHex: string): Promise<string>;
18
18
  export declare function decryptData(encryptedHex: string, keyHex: string): Promise<any>;
19
19
  /**
20
20
  * Encrypts sensitive fields in a vault entry
21
- * Fields encrypted: user_name, password, tfa_secret (if present)
21
+ * Fields encrypted: user_name, password, tfa_secret (if present), and
22
+ * proxy_value when proxy_setting is "custom" (the bring-your-own proxy URL).
22
23
  * @param entry - Vault entry with potentially sensitive data
23
24
  * @param encryptionKey - Hex-encoded encryption key
24
25
  * @returns Entry with encrypted sensitive fields
@@ -51,7 +51,8 @@ export async function decryptData(encryptedHex, keyHex) {
51
51
  }
52
52
  /**
53
53
  * Encrypts sensitive fields in a vault entry
54
- * Fields encrypted: user_name, password, tfa_secret (if present)
54
+ * Fields encrypted: user_name, password, tfa_secret (if present), and
55
+ * proxy_value when proxy_setting is "custom" (the bring-your-own proxy URL).
55
56
  * @param entry - Vault entry with potentially sensitive data
56
57
  * @param encryptionKey - Hex-encoded encryption key
57
58
  * @returns Entry with encrypted sensitive fields
@@ -67,6 +68,22 @@ export async function encryptSensitiveFields(entry, encryptionKey) {
67
68
  if (entry.tfa_secret !== undefined) {
68
69
  encryptedEntry.tfa_secret = await encryptData(entry.tfa_secret, encryptionKey);
69
70
  }
71
+ // proxy_value is meaningless to the backend without proxy_setting, which is
72
+ // also the discriminator that tells us whether to encrypt. Fail closed: a
73
+ // custom proxy URL (often with embedded credentials) must never be sent in
74
+ // plaintext because the caller forgot the mode on a partial update.
75
+ if (entry.proxy_value !== undefined &&
76
+ entry.proxy_value !== null &&
77
+ (entry.proxy_setting === undefined || entry.proxy_setting === null)) {
78
+ throw new Error("proxy_value requires proxy_setting. Pass proxy_setting: 'custom' for a " +
79
+ "bring-your-own proxy URL (so it is encrypted before sending), or " +
80
+ "'static'/'country' for a managed proxy.");
81
+ }
82
+ if (entry.proxy_setting === 'custom' &&
83
+ entry.proxy_value !== undefined &&
84
+ entry.proxy_value !== null) {
85
+ encryptedEntry.proxy_value = await encryptData(entry.proxy_value, encryptionKey);
86
+ }
70
87
  return encryptedEntry;
71
88
  }
72
89
  /**
@@ -95,5 +112,11 @@ export async function decryptSensitiveFields(entry, encryptionKey) {
95
112
  }
96
113
  catch { }
97
114
  }
115
+ if (entry.proxy_setting === 'custom' && typeof entry.proxy_value === 'string') {
116
+ try {
117
+ decryptedEntry.proxy_value = await decryptData(entry.proxy_value, encryptionKey);
118
+ }
119
+ catch { }
120
+ }
98
121
  return decryptedEntry;
99
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudcruise",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "The official CloudCruise JS/TS client.",
5
5
  "homepage": "https://github.com/CloudCruise/cloudcruise-js#readme",
6
6
  "bugs": {