@oxygen-agent/cli 1.162.10 → 1.164.30
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/README.md +1 -1
- package/dist/http-client.js +2 -78
- package/dist/index.js +117 -20
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +0 -5
- package/node_modules/@oxygen/shared/dist/cli-envelope.d.ts +27 -0
- package/node_modules/@oxygen/shared/dist/cli-envelope.js +102 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/index.js +1 -0
- package/node_modules/@oxygen/shared/dist/redaction.js +45 -4
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/workflows/dist/index.d.ts +12 -11
- package/node_modules/@oxygen/workflows/dist/index.js +58 -0
- package/package.json +1 -1
package/README.md
CHANGED
package/dist/http-client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { OXYGEN_VERSION, OxygenError, isVersionGreater } from "@oxygen/shared";
|
|
1
|
+
import { OXYGEN_VERSION, OxygenError, isCliResult, isVersionGreater, readEnvelopeCompatibility, vercelProtectionBypassHeaders, withRetryAfterDetails, } from "@oxygen/shared";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { defaultApiUrl, loadCredentials } from "./credentials.js";
|
|
4
4
|
import { resolveCliUpdateGuidance } from "./runtime.js";
|
|
@@ -28,7 +28,7 @@ path, options = {}) {
|
|
|
28
28
|
// even against clients that predate the client-side envelope gate.
|
|
29
29
|
"X-Oxygen-Client-Version": OXYGEN_VERSION,
|
|
30
30
|
};
|
|
31
|
-
|
|
31
|
+
Object.assign(headers, vercelProtectionBypassHeaders(apiUrl));
|
|
32
32
|
if (credentials?.token) {
|
|
33
33
|
headers.Authorization = `Bearer ${credentials.token}`;
|
|
34
34
|
}
|
|
@@ -188,56 +188,6 @@ function assertCliMeetsMinimumApiVersion(compatibility, options, apiUrl) {
|
|
|
188
188
|
exitCode: 1,
|
|
189
189
|
});
|
|
190
190
|
}
|
|
191
|
-
function readEnvelopeCompatibility(envelope) {
|
|
192
|
-
const meta = envelope.meta;
|
|
193
|
-
if (!meta || typeof meta !== "object" || Array.isArray(meta))
|
|
194
|
-
return {};
|
|
195
|
-
const record = meta;
|
|
196
|
-
const compatibility = {};
|
|
197
|
-
if (typeof record.version === "string")
|
|
198
|
-
compatibility.version = record.version;
|
|
199
|
-
if (typeof record.minimum_cli_version === "string") {
|
|
200
|
-
compatibility.minimumCliVersion = record.minimum_cli_version;
|
|
201
|
-
}
|
|
202
|
-
return compatibility;
|
|
203
|
-
}
|
|
204
|
-
// Surface the server's 429 backoff as a first-class `retry_after_seconds` detail
|
|
205
|
-
// so loop-driving callers can wait the right amount of time instead of dead-
|
|
206
|
-
// reckoning. Prefers a value the API already put in details; otherwise derives
|
|
207
|
-
// it from the RFC 6585 Retry-After header (or reset_at). Non-429 responses and
|
|
208
|
-
// unparseable values pass through untouched.
|
|
209
|
-
function withRetryAfterDetails(details, response) {
|
|
210
|
-
if (response.status !== 429)
|
|
211
|
-
return details;
|
|
212
|
-
const record = details && typeof details === "object" && !Array.isArray(details)
|
|
213
|
-
? details
|
|
214
|
-
: null;
|
|
215
|
-
if (record && typeof record.retry_after_seconds === "number")
|
|
216
|
-
return details;
|
|
217
|
-
const retryAfterSeconds = retryAfterSecondsFromResponse(response, record);
|
|
218
|
-
if (retryAfterSeconds === null)
|
|
219
|
-
return details;
|
|
220
|
-
if (record)
|
|
221
|
-
return { ...record, retry_after_seconds: retryAfterSeconds };
|
|
222
|
-
if (details === undefined)
|
|
223
|
-
return { retry_after_seconds: retryAfterSeconds };
|
|
224
|
-
return { details, retry_after_seconds: retryAfterSeconds };
|
|
225
|
-
}
|
|
226
|
-
function retryAfterSecondsFromResponse(response, details) {
|
|
227
|
-
const header = response.headers.get("retry-after");
|
|
228
|
-
if (header) {
|
|
229
|
-
const seconds = Number(header);
|
|
230
|
-
if (Number.isFinite(seconds) && seconds >= 0)
|
|
231
|
-
return Math.ceil(seconds);
|
|
232
|
-
}
|
|
233
|
-
const resetAt = details?.reset_at;
|
|
234
|
-
if (typeof resetAt === "string") {
|
|
235
|
-
const resetMs = Date.parse(resetAt);
|
|
236
|
-
if (!Number.isNaN(resetMs))
|
|
237
|
-
return Math.max(0, Math.ceil((resetMs - Date.now()) / 1000));
|
|
238
|
-
}
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
191
|
function withTraceDetails(details, traceId, compatibility, apiUrl) {
|
|
242
192
|
const serverVersion = compatibility.version;
|
|
243
193
|
const fields = {
|
|
@@ -266,21 +216,6 @@ function withTraceDetails(details, traceId, compatibility, apiUrl) {
|
|
|
266
216
|
...fields,
|
|
267
217
|
};
|
|
268
218
|
}
|
|
269
|
-
function addVercelProtectionBypassHeader(apiUrl, headers) {
|
|
270
|
-
const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET?.trim();
|
|
271
|
-
if (!secret)
|
|
272
|
-
return;
|
|
273
|
-
let hostname;
|
|
274
|
-
try {
|
|
275
|
-
hostname = new URL(apiUrl).hostname;
|
|
276
|
-
}
|
|
277
|
-
catch {
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
if (hostname === "vercel.app" || hostname.endsWith(".vercel.app")) {
|
|
281
|
-
headers["x-vercel-protection-bypass"] = secret;
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
219
|
function resolveRequestTimeoutMs(value) {
|
|
285
220
|
if (value === undefined)
|
|
286
221
|
return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
@@ -333,14 +268,3 @@ async function readEnvelope(response) {
|
|
|
333
268
|
exitCode: 1,
|
|
334
269
|
});
|
|
335
270
|
}
|
|
336
|
-
function isCliResult(value) {
|
|
337
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
338
|
-
return false;
|
|
339
|
-
const ok = value.ok;
|
|
340
|
-
if (ok === true)
|
|
341
|
-
return "data" in value;
|
|
342
|
-
if (ok !== false)
|
|
343
|
-
return false;
|
|
344
|
-
const error = value.error;
|
|
345
|
-
return Boolean(error) && typeof error === "object" && !Array.isArray(error);
|
|
346
|
-
}
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey,
|
|
|
14
14
|
import { assertRecipeBundleSafe, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowManifest, } from "@oxygen/workflows";
|
|
15
15
|
import { isRecipeDefinition } from "@oxygen/recipe-sdk";
|
|
16
16
|
import { createBrowserLoginSession, openBrowser } from "./browser-login.js";
|
|
17
|
-
import { clearCredentials, defaultApiUrl, listCredentialProfiles, normalizeApiUrl, pickProfileNameForIdentity, pickProfileNameForUserSession, resolveActiveProfile, saveCredentials, switchCredentialProfile, updateActiveOrganizationForProfile, } from "./credentials.js";
|
|
17
|
+
import { clearCredentials, defaultApiUrl, listCredentialProfiles, loadCredentials, normalizeApiUrl, pickProfileNameForIdentity, pickProfileNameForUserSession, resolveActiveProfile, saveCredentials, switchCredentialProfile, updateActiveOrganizationForProfile, } from "./credentials.js";
|
|
18
18
|
import { ensureFreshCliForApiUrl, requestOxygen } from "./http-client.js";
|
|
19
19
|
import { runLocalCustomHttpColumn } from "./local-custom-http-column.js";
|
|
20
20
|
import { captureCurrentTranscript, collectFeedbackEnvironment, TranscriptCaptureError, } from "./transcript.js";
|
|
@@ -83,22 +83,47 @@ async function handleAsyncAction(command, options, action) {
|
|
|
83
83
|
process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
-
// Paid
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
86
|
+
// Paid live runs are refused server-side with typed spend-gate errors
|
|
87
|
+
// (max_credits_required, approval_required, spend_cap_required,
|
|
88
|
+
// spend_cap_too_low). Surface each as a one-line stderr hint with the concrete
|
|
89
|
+
// re-run flags so users don't have to dig values out of the JSON envelope
|
|
90
|
+
// (stderr keeps --json stdout machine-clean).
|
|
90
91
|
function writeMaxCreditsHint(error) {
|
|
91
|
-
if (!(error instanceof OxygenError)
|
|
92
|
+
if (!(error instanceof OxygenError))
|
|
92
93
|
return;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
switch (error.code) {
|
|
95
|
+
case "max_credits_required": {
|
|
96
|
+
const recommended = readDetailsNumber(error.details, "recommended_max_credits");
|
|
97
|
+
if (recommended === null)
|
|
98
|
+
return;
|
|
99
|
+
process.stderr.write(`hint: re-run with --max-credits ${recommended} to approve the spend cap\n`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
case "approval_required": {
|
|
103
|
+
const estimated = readDetailsNumber(error.details, "estimated_credits");
|
|
104
|
+
process.stderr.write(`hint: inspect the dry run, then re-run with --approved --max-credits <n>${estimated !== null ? ` (estimated ~${estimated} credits)` : ""}\n`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
case "spend_cap_required": {
|
|
108
|
+
const estimated = readDetailsNumber(error.details, "estimated_credits");
|
|
109
|
+
process.stderr.write(`hint: re-run with --max-credits ${estimated !== null ? estimated : "<n>"} to cap the spend\n`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
case "spend_cap_too_low": {
|
|
113
|
+
const estimated = readDetailsNumber(error.details, "estimated_credits")
|
|
114
|
+
?? readDetailsNumber(error.details, "estimated_max_credits");
|
|
115
|
+
if (estimated === null)
|
|
116
|
+
return;
|
|
117
|
+
process.stderr.write(`hint: the estimate is ${estimated} credits; re-run with --max-credits ${estimated} or higher\n`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
default:
|
|
121
|
+
}
|
|
97
122
|
}
|
|
98
|
-
function
|
|
123
|
+
function readDetailsNumber(details, key) {
|
|
99
124
|
if (!details || typeof details !== "object" || Array.isArray(details))
|
|
100
125
|
return null;
|
|
101
|
-
const value = details
|
|
126
|
+
const value = details[key];
|
|
102
127
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
103
128
|
}
|
|
104
129
|
function parseJsonObject(value) {
|
|
@@ -429,13 +454,26 @@ export function createProgram() {
|
|
|
429
454
|
}));
|
|
430
455
|
program
|
|
431
456
|
.command("status")
|
|
432
|
-
.description("Compare the local Oxygen CLI version against
|
|
457
|
+
.description("Compare the local Oxygen CLI version against the active profile's deployed Oxygen API.")
|
|
433
458
|
.option("--json", "Print a JSON envelope.")
|
|
434
459
|
.action(async (options) => {
|
|
435
460
|
await handleAsyncAction("status", options, async () => {
|
|
436
|
-
|
|
461
|
+
// Resolve the API URL exactly like authenticated commands do: the
|
|
462
|
+
// active profile (honoring --profile / OXYGEN_PROFILE / OXYGEN_API_URL
|
|
463
|
+
// overrides), falling back to the default prod URL only when no
|
|
464
|
+
// credentials are stored. Without this, requestOxygen's
|
|
465
|
+
// requireAuth:false path skips loadCredentials() and always hits prod,
|
|
466
|
+
// so `status` reports a different deployment than every other command.
|
|
467
|
+
const credentials = await loadCredentials();
|
|
468
|
+
const apiUrl = credentials?.apiUrl ?? defaultApiUrl();
|
|
469
|
+
const server = await requestOxygen("/api/health", {
|
|
470
|
+
credentials: credentials ?? { token: "", apiUrl },
|
|
471
|
+
requireAuth: false,
|
|
472
|
+
enforceMinimumCliVersion: false,
|
|
473
|
+
});
|
|
437
474
|
return {
|
|
438
475
|
client_version: OXYGEN_VERSION,
|
|
476
|
+
api_url: apiUrl,
|
|
439
477
|
server_version: server.server_version,
|
|
440
478
|
minimum_cli_version: server.minimum_cli_version ?? null,
|
|
441
479
|
sha: server.sha,
|
|
@@ -3062,8 +3100,11 @@ export function createProgram() {
|
|
|
3062
3100
|
.option("--return <mode>", "Legacy response shape: raw, compact, or summary. Defaults to raw.")
|
|
3063
3101
|
.option("--return-mode <mode>", "Response shape: raw, compact, or summary. Prefer summary for large search responses.")
|
|
3064
3102
|
.option("--oxygen-cursor <cursor>", "Short Oxygen cursor returned as oxygen_next_cursor by a previous tool run.")
|
|
3103
|
+
.option("--max-credits <n>", "Required credit ceiling for live runs of paid tools.")
|
|
3104
|
+
.option("--approved", "Required for live runs of paid tools after inspecting dry-run output.")
|
|
3065
3105
|
.option("--json", "Print a JSON envelope.")
|
|
3066
3106
|
.action(async (toolId, options) => {
|
|
3107
|
+
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
3067
3108
|
await handleAsyncAction("tools run", options, () => requestOxygen("/api/cli/tools/run", {
|
|
3068
3109
|
method: "POST",
|
|
3069
3110
|
body: {
|
|
@@ -3078,6 +3119,8 @@ export function createProgram() {
|
|
|
3078
3119
|
...(readOption(options["return"]) ? { return: readOption(options["return"]) } : {}),
|
|
3079
3120
|
...(readOption(options.returnMode) ? { return_mode: readOption(options.returnMode) } : {}),
|
|
3080
3121
|
...(readOption(options.oxygenCursor) ? { oxygen_cursor: readOption(options.oxygenCursor) } : {}),
|
|
3122
|
+
...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
|
|
3123
|
+
...(options.approved ? { approved: true } : {}),
|
|
3081
3124
|
},
|
|
3082
3125
|
}));
|
|
3083
3126
|
}));
|
|
@@ -3460,10 +3503,11 @@ export function createProgram() {
|
|
|
3460
3503
|
});
|
|
3461
3504
|
}))));
|
|
3462
3505
|
program.addCommand(new Command("inbox")
|
|
3463
|
-
.description("
|
|
3506
|
+
.description("Unified inbox (unibox): LinkedIn conversations and (--channel email) the fleet's email conversations synced from Zapmail Zapbox. Scan, read threads, and reply.")
|
|
3464
3507
|
.addCommand(new Command("list")
|
|
3465
|
-
.description("List
|
|
3466
|
-
.option("--
|
|
3508
|
+
.description("List conversations across all connected accounts, newest first. --channel email lists the Zapbox-synced email inbox.")
|
|
3509
|
+
.option("--channel <channel>", "Inbox channel: linkedin (default) or email.")
|
|
3510
|
+
.option("--account <id>", "LinkedIn only: filter to one sender account (sender id, connection id, or Unipile account id).")
|
|
3467
3511
|
.option("--unread", "Only show conversations with unread messages.")
|
|
3468
3512
|
.option("--search <text>", "Filter by attendee name or last-message text.")
|
|
3469
3513
|
.option("--include-archived", "Include archived conversations.")
|
|
@@ -3472,6 +3516,9 @@ export function createProgram() {
|
|
|
3472
3516
|
.action(async (options) => {
|
|
3473
3517
|
await handleAsyncAction("inbox list", options, () => {
|
|
3474
3518
|
const params = new URLSearchParams();
|
|
3519
|
+
const channel = readOption(options.channel);
|
|
3520
|
+
if (channel)
|
|
3521
|
+
params.set("channel", channel);
|
|
3475
3522
|
const account = readOption(options.account);
|
|
3476
3523
|
if (account)
|
|
3477
3524
|
params.set("account", account);
|
|
@@ -3490,13 +3537,17 @@ export function createProgram() {
|
|
|
3490
3537
|
});
|
|
3491
3538
|
}))
|
|
3492
3539
|
.addCommand(new Command("get")
|
|
3493
|
-
.description("Get one conversation with its full message thread. <conversation> accepts a conversation id
|
|
3494
|
-
.argument("<conversation>", "Conversation id
|
|
3540
|
+
.description("Get one conversation with its full message thread. <conversation> accepts a conversation id, Unipile chat id, or (email) Zapbox thread id.")
|
|
3541
|
+
.argument("<conversation>", "Conversation id, Unipile chat id, or Zapbox thread id.")
|
|
3542
|
+
.option("--channel <channel>", "Inbox channel: linkedin (default) or email.")
|
|
3495
3543
|
.option("--message-limit <n>", "Maximum messages to return (1-500). Defaults to 100.")
|
|
3496
3544
|
.option("--json", "Print a JSON envelope.")
|
|
3497
3545
|
.action(async (conversation, options) => {
|
|
3498
3546
|
await handleAsyncAction("inbox get", options, () => {
|
|
3499
3547
|
const params = new URLSearchParams();
|
|
3548
|
+
const channel = readOption(options.channel);
|
|
3549
|
+
if (channel)
|
|
3550
|
+
params.set("channel", channel);
|
|
3500
3551
|
const messageLimit = readOption(options.messageLimit);
|
|
3501
3552
|
if (messageLimit)
|
|
3502
3553
|
params.set("message_limit", messageLimit);
|
|
@@ -3530,12 +3581,16 @@ export function createProgram() {
|
|
|
3530
3581
|
}))
|
|
3531
3582
|
.addCommand(new Command("sync")
|
|
3532
3583
|
.description("Force a backstop inbox sync from Unipile for all active sender accounts (pulls recent chats + messages into the unibox).")
|
|
3584
|
+
.option("--account <id>", "Force-sync one sender account (sender id, connection id, or Unipile account id).")
|
|
3533
3585
|
.option("--chat-limit <n>", "Maximum chats to sync per account. Defaults to 30.")
|
|
3534
3586
|
.option("--message-limit <n>", "Maximum messages to sync per chat. Defaults to 20.")
|
|
3535
3587
|
.option("--json", "Print a JSON envelope.")
|
|
3536
3588
|
.action(async (options) => {
|
|
3537
3589
|
await handleAsyncAction("inbox sync", options, () => {
|
|
3538
3590
|
const body = {};
|
|
3591
|
+
const account = readOption(options.account);
|
|
3592
|
+
if (account)
|
|
3593
|
+
body.account = account;
|
|
3539
3594
|
const chatLimit = readOption(options.chatLimit);
|
|
3540
3595
|
if (chatLimit)
|
|
3541
3596
|
body.chat_limit = Number(chatLimit);
|
|
@@ -3774,6 +3829,35 @@ export function createProgram() {
|
|
|
3774
3829
|
.action(async (sequence, options) => {
|
|
3775
3830
|
await handleAsyncAction("sequences stats", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/stats`));
|
|
3776
3831
|
})));
|
|
3832
|
+
program.addCommand(new Command("email")
|
|
3833
|
+
.description("Ad-hoc email from the org's sending fleet: one-off sends with no sequence, enrollment, or contact sync. Zapbox-connected mailboxes send through Zapmail's API (no Google/Microsoft consent); BYOK — 0 Oxygen credits.")
|
|
3834
|
+
.addCommand(new Command("send")
|
|
3835
|
+
.description("Send ONE email right now. Without --approved, returns a preview naming the exact mailbox + transport. --from pins a mailbox; otherwise the pool's LRU rotation picks one.")
|
|
3836
|
+
.requiredOption("--to <email>", "Recipient email address (exactly one).")
|
|
3837
|
+
.requiredOption("--subject <text>", "Subject line.")
|
|
3838
|
+
.option("--body <text>", "Plain-text body (sent verbatim, no templating).")
|
|
3839
|
+
.option("--body-file <path>", "Read the body from a file instead of --body.")
|
|
3840
|
+
.option("--from <mailbox>", "Sending mailbox (address or id). Defaults to the pool rotation.")
|
|
3841
|
+
.option("--approved", "Approve and send. Without this flag, returns a preview only.")
|
|
3842
|
+
.option("--json", "Print a JSON envelope.")
|
|
3843
|
+
.action(async (options) => {
|
|
3844
|
+
await handleAsyncAction("email send", options, async () => {
|
|
3845
|
+
const bodyText = readOption(options.body) ?? (options.bodyFile ? readFileSync(resolve(options.bodyFile), "utf8") : undefined);
|
|
3846
|
+
if (!bodyText || !bodyText.trim()) {
|
|
3847
|
+
throw new Error("Provide --body or --body-file.");
|
|
3848
|
+
}
|
|
3849
|
+
return requestOxygen("/api/cli/email/send", {
|
|
3850
|
+
method: "POST",
|
|
3851
|
+
body: {
|
|
3852
|
+
to: readOption(options.to),
|
|
3853
|
+
subject: readOption(options.subject),
|
|
3854
|
+
body: bodyText,
|
|
3855
|
+
...(readOption(options.from) ? { from: readOption(options.from) } : {}),
|
|
3856
|
+
...(options.approved ? { approved: true } : {}),
|
|
3857
|
+
},
|
|
3858
|
+
});
|
|
3859
|
+
});
|
|
3860
|
+
})));
|
|
3777
3861
|
program.addCommand(new Command("mailboxes")
|
|
3778
3862
|
.description("Native email sending pool: register/refresh Google/Microsoft mailboxes a campaign rotates over, pause/disable inboxes, and delegate warmup to Instantly (BYOK — Instantly bills your account, 0 Oxygen credits).")
|
|
3779
3863
|
.addCommand(new Command("list")
|
|
@@ -3795,18 +3879,26 @@ export function createProgram() {
|
|
|
3795
3879
|
.option("--file <path>", "Path to a JSON file: { \"mailboxes\": [{ email_address, provider, workspace_external_id? }] }.")
|
|
3796
3880
|
.option("--from <source>", "Import source: 'zapmail' to pull the connected Zapmail workspace's mailboxes.")
|
|
3797
3881
|
.option("--connection <id>", "Zapmail connection id (--from zapmail). Defaults to the org's active Zapmail connection.")
|
|
3882
|
+
.option("--provider <provider>", "Zapmail pool to pull (--from zapmail): google or microsoft. Zapmail's mailbox list is provider-scoped, so the Microsoft pool is only reachable with --provider microsoft; Microsoft mailboxes get their Entra tenant id stamped on import.")
|
|
3798
3883
|
.option("--json", "Print a JSON envelope.")
|
|
3799
3884
|
.action(async (options) => {
|
|
3800
3885
|
await handleAsyncAction("mailboxes import", options, () => {
|
|
3801
3886
|
const from = readOption(options.from);
|
|
3802
3887
|
const filePath = readOption(options.file);
|
|
3803
3888
|
const connection = readOption(options.connection);
|
|
3889
|
+
const provider = readOption(options.provider);
|
|
3804
3890
|
if (from === "zapmail") {
|
|
3805
3891
|
return requestOxygen("/api/cli/mailboxes", {
|
|
3806
3892
|
method: "POST",
|
|
3807
|
-
body: {
|
|
3893
|
+
body: {
|
|
3894
|
+
source: "zapmail",
|
|
3895
|
+
...(connection ? { connection_id: connection } : {}),
|
|
3896
|
+
...(provider ? { service_provider: provider } : {}),
|
|
3897
|
+
},
|
|
3808
3898
|
});
|
|
3809
3899
|
}
|
|
3900
|
+
if (provider)
|
|
3901
|
+
throw new Error("--provider only applies with --from zapmail (inline files carry a per-mailbox provider).");
|
|
3810
3902
|
if (!filePath)
|
|
3811
3903
|
throw new Error("Provide --file <path> (a { \"mailboxes\": [...] } JSON file) or --from zapmail.");
|
|
3812
3904
|
const parsed = JSON.parse(readFileSync(resolve(filePath), "utf8"));
|
|
@@ -4064,9 +4156,12 @@ export function createProgram() {
|
|
|
4064
4156
|
.option("--input-json <json>", "Workflow input object. Defaults to {}.")
|
|
4065
4157
|
.requiredOption("--mode <mode>", "Execution mode: live, dry-run, or smoke-test.")
|
|
4066
4158
|
.option("--idempotency-key <key>", "Optional idempotency key.")
|
|
4159
|
+
.option("--max-credits <n>", "Required credit ceiling for live calls.")
|
|
4160
|
+
.option("--approved", "Required for live calls after inspecting a dry run.")
|
|
4067
4161
|
.option("--include-bundle", "Include durable recipe bundles in JSON output.")
|
|
4068
4162
|
.option("--json", "Print a JSON envelope.")
|
|
4069
4163
|
.action(async (workflowArg, options) => {
|
|
4164
|
+
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
4070
4165
|
await handleAsyncAction("workflows call", options, async () => prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/call", {
|
|
4071
4166
|
method: "POST",
|
|
4072
4167
|
body: {
|
|
@@ -4076,6 +4171,8 @@ export function createProgram() {
|
|
|
4076
4171
|
input: options.inputJson ? parseJsonObject(options.inputJson) : {},
|
|
4077
4172
|
...(readOption(options.mode) ? { mode: readOption(options.mode) } : {}),
|
|
4078
4173
|
...(readOption(options.idempotencyKey) ? { idempotency_key: readOption(options.idempotencyKey) } : {}),
|
|
4174
|
+
...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
|
|
4175
|
+
...(options.approved ? { approved: true } : {}),
|
|
4079
4176
|
},
|
|
4080
4177
|
}), options));
|
|
4081
4178
|
}))
|
|
@@ -105,7 +105,6 @@ export type RecipeContext = {
|
|
|
105
105
|
};
|
|
106
106
|
export type DurableRecipeContext = RecipeContext;
|
|
107
107
|
export type RecipeRunFunction = (ctx: RecipeContext) => unknown | Promise<unknown>;
|
|
108
|
-
export type DurableRecipeRunFunction = RecipeRunFunction;
|
|
109
108
|
export type RecipeVisualBaseStep = {
|
|
110
109
|
id: string;
|
|
111
110
|
label: string;
|
|
@@ -155,10 +154,6 @@ export type DefineRecipeInput = {
|
|
|
155
154
|
visualPlan?: RecipeVisualPlan;
|
|
156
155
|
run: RecipeRunFunction;
|
|
157
156
|
};
|
|
158
|
-
export type DurableRecipeDefinition = RecipeDefinition;
|
|
159
|
-
export type DefineDurableRecipeInput = DefineRecipeInput & {
|
|
160
|
-
runtime: "durable";
|
|
161
|
-
};
|
|
162
157
|
export declare function defineRecipe(input: DefineRecipeInput): RecipeDefinition;
|
|
163
158
|
export declare function isRecipeDefinition(value: unknown): value is RecipeDefinition;
|
|
164
159
|
export declare function recipeVisualPlan(steps: RecipeVisualStep[]): RecipeVisualPlan;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CliResult } from "./index.js";
|
|
2
|
+
/** Server compatibility metadata extracted from a `CliResult.meta` envelope. */
|
|
3
|
+
export type ServerCompatibility = {
|
|
4
|
+
version?: string;
|
|
5
|
+
minimumCliVersion?: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Type guard for the shared `CliResult` envelope. Accepts `{ ok: true, data }`
|
|
9
|
+
* and `{ ok: false, error: {...} }`; rejects anything else (non-objects,
|
|
10
|
+
* arrays, missing discriminant, malformed error).
|
|
11
|
+
*/
|
|
12
|
+
export declare function isCliResult<T>(value: unknown): value is CliResult<T>;
|
|
13
|
+
/**
|
|
14
|
+
* Read the server version and minimum-CLI floor out of a `CliResult.meta`
|
|
15
|
+
* block. Missing or malformed metadata yields an empty object so callers can
|
|
16
|
+
* treat "no compatibility info" uniformly.
|
|
17
|
+
*/
|
|
18
|
+
export declare function readEnvelopeCompatibility(envelope: CliResult<unknown>): ServerCompatibility;
|
|
19
|
+
export declare function withRetryAfterDetails(details: unknown, response: Response): unknown;
|
|
20
|
+
/**
|
|
21
|
+
* Header fragment that lets automated clients reach a password-protected Vercel
|
|
22
|
+
* preview deployment. Returns `{ "x-vercel-protection-bypass": <secret> }` only
|
|
23
|
+
* when `VERCEL_AUTOMATION_BYPASS_SECRET` is set and the API URL targets a
|
|
24
|
+
* `*.vercel.app` host; otherwise an empty object. Callers spread the result
|
|
25
|
+
* into their request headers, so a non-preview URL is a no-op.
|
|
26
|
+
*/
|
|
27
|
+
export declare function vercelProtectionBypassHeaders(apiUrl: string): Record<string, string>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Shared mechanics for the Oxygen CLI-result envelope that both the CLI HTTP
|
|
2
|
+
// client (`packages/cli/src/http-client.ts`) and the MCP API client
|
|
3
|
+
// (`packages/mcp-server/src/api-client.ts`) consume. These are pure functions —
|
|
4
|
+
// no runtime imports from runtime-specific modules — so each client keeps its
|
|
5
|
+
// own error class (`OxygenError` vs `OxygenApiError`) and surface-specific
|
|
6
|
+
// guidance while sharing the parsing/extraction logic that previously drifted
|
|
7
|
+
// after being copy-pasted between the two clients.
|
|
8
|
+
/**
|
|
9
|
+
* Type guard for the shared `CliResult` envelope. Accepts `{ ok: true, data }`
|
|
10
|
+
* and `{ ok: false, error: {...} }`; rejects anything else (non-objects,
|
|
11
|
+
* arrays, missing discriminant, malformed error).
|
|
12
|
+
*/
|
|
13
|
+
export function isCliResult(value) {
|
|
14
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
15
|
+
return false;
|
|
16
|
+
const ok = value.ok;
|
|
17
|
+
if (ok === true)
|
|
18
|
+
return "data" in value;
|
|
19
|
+
if (ok !== false)
|
|
20
|
+
return false;
|
|
21
|
+
const error = value.error;
|
|
22
|
+
return Boolean(error) && typeof error === "object" && !Array.isArray(error);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Read the server version and minimum-CLI floor out of a `CliResult.meta`
|
|
26
|
+
* block. Missing or malformed metadata yields an empty object so callers can
|
|
27
|
+
* treat "no compatibility info" uniformly.
|
|
28
|
+
*/
|
|
29
|
+
export function readEnvelopeCompatibility(envelope) {
|
|
30
|
+
const meta = envelope.meta;
|
|
31
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta))
|
|
32
|
+
return {};
|
|
33
|
+
const record = meta;
|
|
34
|
+
const compatibility = {};
|
|
35
|
+
if (typeof record.version === "string")
|
|
36
|
+
compatibility.version = record.version;
|
|
37
|
+
if (typeof record.minimum_cli_version === "string") {
|
|
38
|
+
compatibility.minimumCliVersion = record.minimum_cli_version;
|
|
39
|
+
}
|
|
40
|
+
return compatibility;
|
|
41
|
+
}
|
|
42
|
+
// Surface the server's 429 backoff as a first-class `retry_after_seconds` detail
|
|
43
|
+
// so loop-driving callers can wait the right amount of time instead of dead-
|
|
44
|
+
// reckoning. Prefers a value the API already put in details; otherwise derives
|
|
45
|
+
// it from the RFC 6585 Retry-After header (or reset_at). Non-429 responses and
|
|
46
|
+
// unparseable values pass through untouched.
|
|
47
|
+
export function withRetryAfterDetails(details, response) {
|
|
48
|
+
if (response.status !== 429)
|
|
49
|
+
return details;
|
|
50
|
+
const record = details && typeof details === "object" && !Array.isArray(details)
|
|
51
|
+
? details
|
|
52
|
+
: null;
|
|
53
|
+
if (record && typeof record.retry_after_seconds === "number")
|
|
54
|
+
return details;
|
|
55
|
+
const retryAfterSeconds = retryAfterSecondsFromResponse(response, record);
|
|
56
|
+
if (retryAfterSeconds === null)
|
|
57
|
+
return details;
|
|
58
|
+
if (record)
|
|
59
|
+
return { ...record, retry_after_seconds: retryAfterSeconds };
|
|
60
|
+
if (details === undefined)
|
|
61
|
+
return { retry_after_seconds: retryAfterSeconds };
|
|
62
|
+
return { details, retry_after_seconds: retryAfterSeconds };
|
|
63
|
+
}
|
|
64
|
+
function retryAfterSecondsFromResponse(response, details) {
|
|
65
|
+
const header = response.headers.get("retry-after");
|
|
66
|
+
if (header) {
|
|
67
|
+
const seconds = Number(header);
|
|
68
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
69
|
+
return Math.ceil(seconds);
|
|
70
|
+
}
|
|
71
|
+
const resetAt = details?.reset_at;
|
|
72
|
+
if (typeof resetAt === "string") {
|
|
73
|
+
const resetMs = Date.parse(resetAt);
|
|
74
|
+
if (!Number.isNaN(resetMs))
|
|
75
|
+
return Math.max(0, Math.ceil((resetMs - Date.now()) / 1000));
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const VERCEL_PROTECTION_BYPASS_HEADER = "x-vercel-protection-bypass";
|
|
80
|
+
/**
|
|
81
|
+
* Header fragment that lets automated clients reach a password-protected Vercel
|
|
82
|
+
* preview deployment. Returns `{ "x-vercel-protection-bypass": <secret> }` only
|
|
83
|
+
* when `VERCEL_AUTOMATION_BYPASS_SECRET` is set and the API URL targets a
|
|
84
|
+
* `*.vercel.app` host; otherwise an empty object. Callers spread the result
|
|
85
|
+
* into their request headers, so a non-preview URL is a no-op.
|
|
86
|
+
*/
|
|
87
|
+
export function vercelProtectionBypassHeaders(apiUrl) {
|
|
88
|
+
const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET?.trim();
|
|
89
|
+
if (!secret)
|
|
90
|
+
return {};
|
|
91
|
+
let hostname;
|
|
92
|
+
try {
|
|
93
|
+
hostname = new URL(apiUrl).hostname;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return {};
|
|
97
|
+
}
|
|
98
|
+
if (hostname === "vercel.app" || hostname.endsWith(".vercel.app")) {
|
|
99
|
+
return { [VERCEL_PROTECTION_BYPASS_HEADER]: secret };
|
|
100
|
+
}
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
@@ -2,6 +2,7 @@ export { OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_VERSION } from "./version.js";
|
|
|
2
2
|
export { WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS, clearWorkflowTriggerAutoPauseMetadata, } from "./workflow-trigger-metadata.js";
|
|
3
3
|
export * from "./billing.js";
|
|
4
4
|
export * from "./cell-format.js";
|
|
5
|
+
export * from "./cli-envelope.js";
|
|
5
6
|
export * from "./column-types.js";
|
|
6
7
|
export * from "./credit-guidance.js";
|
|
7
8
|
export * from "./linkedin-sequences.js";
|
|
@@ -3,6 +3,7 @@ export { OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_VERSION } from "./version.js";
|
|
|
3
3
|
export { WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS, clearWorkflowTriggerAutoPauseMetadata, } from "./workflow-trigger-metadata.js";
|
|
4
4
|
export * from "./billing.js";
|
|
5
5
|
export * from "./cell-format.js";
|
|
6
|
+
export * from "./cli-envelope.js";
|
|
6
7
|
export * from "./column-types.js";
|
|
7
8
|
export * from "./credit-guidance.js";
|
|
8
9
|
export * from "./linkedin-sequences.js";
|
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
const SECRET_KEY_PATTERN = /(api[_-]?key|x[_-]?key|authorization|bearer|cookie|password|secret|token|ciphertext|connection[_-]?string|connection[_-]?uri|database[_-]?url|dsn)/i;
|
|
2
2
|
const OMITTED_KEY_PATTERN = /^(body|payload|prompt|prompts|raw_prompt|raw_prompts|row|rows|input|inputs|output|outputs|request|response|provider_payload|provider_response|customer_data)$/i;
|
|
3
|
+
// OXY-125: keyed redaction (SECRET_KEY_PATTERN) only catches whole fields named
|
|
4
|
+
// like a secret. Credentials also leak as *substrings* of otherwise-ordinary
|
|
5
|
+
// fields — most commonly an Authorization header dumped into error_message /
|
|
6
|
+
// error_stack. This matches the HTTP bearer scheme followed by its token (JWT /
|
|
7
|
+
// base64 / opaque, including our `oxy_live_`/`oxy_sess_` prefixes) anywhere in a
|
|
8
|
+
// string.
|
|
9
|
+
const BEARER_TOKEN_PATTERN = /\bBearer\s+[\w.~+/=-]+/gi;
|
|
10
|
+
// OXY-139: the prod log-hygiene sweep also flags `sk-…` API keys and DB
|
|
11
|
+
// connection URLs that ride along inside ordinary (non-secret-named) fields, so
|
|
12
|
+
// keyed + Bearer redaction is not enough. We scrub the two shapes that carry real
|
|
13
|
+
// secret *material* as substrings of any value:
|
|
14
|
+
// • DB URLs — the whole `postgres(ql)://…` (incl. scheme) so the marker cannot
|
|
15
|
+
// re-trip the sweep's `postgres://[^ ]+` clause.
|
|
16
|
+
// • `sk-…` keys — body allowed to contain `_`/`-` and no word boundary, matching
|
|
17
|
+
// the sweep regex byte-for-byte. This also neutralises the OXY-139 false
|
|
18
|
+
// positive: a Vercel `x-vercel-id` whose first segment ends in `sk`
|
|
19
|
+
// (e.g. `2g5sk-1781047899561-…`, logged as request_id on every cron summary
|
|
20
|
+
// line) is not a credential but is sweep-shaped, so it must be redacted to
|
|
21
|
+
// clear the signal; trace_id stays intact as the correlation key.
|
|
22
|
+
// The bare `OPENAI_API_KEY` *name* is deliberately NOT scrubbed — it is a variable
|
|
23
|
+
// name, not a secret, and hiding it would suppress legitimate "OPENAI_API_KEY is
|
|
24
|
+
// missing" diagnostics (its actual value is an `sk-…` string, already covered).
|
|
25
|
+
const DB_URL_PATTERN = /(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|rediss?|amqps?):\/\/[^\s"'<>()]+/gi;
|
|
26
|
+
const SK_TOKEN_PATTERN = /sk-[A-Za-z0-9_-]{20,}/g;
|
|
3
27
|
const MAX_LOG_STRING_LENGTH = 8_000;
|
|
4
28
|
const MAX_TELEMETRY_STRING_LENGTH = 500;
|
|
5
29
|
const MAX_ARRAY_LENGTH = 20;
|
|
@@ -36,7 +60,7 @@ function sanitizeValueForLog(value, key, depth) {
|
|
|
36
60
|
if (value === null || value === undefined)
|
|
37
61
|
return value;
|
|
38
62
|
if (typeof value === "string")
|
|
39
|
-
return truncateString(value, MAX_LOG_STRING_LENGTH);
|
|
63
|
+
return truncateString(redactSecretsInString(value), MAX_LOG_STRING_LENGTH);
|
|
40
64
|
if (typeof value === "number")
|
|
41
65
|
return Number.isFinite(value) ? value : null;
|
|
42
66
|
if (typeof value === "boolean")
|
|
@@ -67,7 +91,7 @@ function normalizeTelemetryValue(value, key) {
|
|
|
67
91
|
if (OMITTED_KEY_PATTERN.test(key))
|
|
68
92
|
return "[omitted]";
|
|
69
93
|
if (typeof value === "string")
|
|
70
|
-
return truncateString(value, MAX_TELEMETRY_STRING_LENGTH);
|
|
94
|
+
return truncateString(redactSecretsInString(value), MAX_TELEMETRY_STRING_LENGTH);
|
|
71
95
|
if (typeof value === "number")
|
|
72
96
|
return Number.isFinite(value) ? value : undefined;
|
|
73
97
|
if (typeof value === "boolean")
|
|
@@ -80,8 +104,12 @@ function normalizeTelemetryValue(value, key) {
|
|
|
80
104
|
.filter((entry) => typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean");
|
|
81
105
|
if (primitive.length === 0)
|
|
82
106
|
return undefined;
|
|
107
|
+
// Array elements need the same substring scrub as scalar strings: a string[]
|
|
108
|
+
// attribute (e.g. dumped header lines) carries the same Bearer/sk-/DB-URL
|
|
109
|
+
// material, and this branch was the one telemetry path that exported it
|
|
110
|
+
// verbatim. Redact before truncating so a cut cannot expose token material.
|
|
83
111
|
if (primitive.every((entry) => typeof entry === "string")) {
|
|
84
|
-
return primitive.map((entry) => truncateString(
|
|
112
|
+
return primitive.map((entry) => truncateString(redactSecretsInString(entry), MAX_TELEMETRY_STRING_LENGTH));
|
|
85
113
|
}
|
|
86
114
|
if (primitive.every((entry) => typeof entry === "number" && Number.isFinite(entry))) {
|
|
87
115
|
return primitive;
|
|
@@ -89,7 +117,7 @@ function normalizeTelemetryValue(value, key) {
|
|
|
89
117
|
if (primitive.every((entry) => typeof entry === "boolean")) {
|
|
90
118
|
return primitive;
|
|
91
119
|
}
|
|
92
|
-
return primitive.map((entry) => truncateString(String(entry), MAX_TELEMETRY_STRING_LENGTH));
|
|
120
|
+
return primitive.map((entry) => truncateString(redactSecretsInString(String(entry)), MAX_TELEMETRY_STRING_LENGTH));
|
|
93
121
|
}
|
|
94
122
|
if (typeof value === "object" && value) {
|
|
95
123
|
return truncateString(JSON.stringify(sanitizeValueForLog(value, key, 0)), MAX_TELEMETRY_STRING_LENGTH);
|
|
@@ -100,6 +128,19 @@ function sanitizeAttributeKey(key) {
|
|
|
100
128
|
const normalized = key.trim().replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
|
|
101
129
|
return normalized || null;
|
|
102
130
|
}
|
|
131
|
+
// Replaces credential-shaped substrings with token-free markers. Order matters:
|
|
132
|
+
// Bearer first so an `Authorization: Bearer sk-…` header collapses to a single
|
|
133
|
+
// `Bearer[REDACTED]` instead of leaving a stray `Bearer ` (the sweep matches
|
|
134
|
+
// `Bearer ` with a trailing space). Each marker is chosen so a second pass — and
|
|
135
|
+
// every prod-sweep regex (`Bearer `, `sk-…`, `postgres://…`) — finds nothing, so
|
|
136
|
+
// redaction stays idempotent. Sharing the global regexes is safe because
|
|
137
|
+
// String.replace resets their lastIndex between calls.
|
|
138
|
+
function redactSecretsInString(value) {
|
|
139
|
+
return value
|
|
140
|
+
.replace(BEARER_TOKEN_PATTERN, "Bearer[REDACTED]")
|
|
141
|
+
.replace(DB_URL_PATTERN, "[REDACTED_DB_URL]")
|
|
142
|
+
.replace(SK_TOKEN_PATTERN, "[REDACTED_SK]");
|
|
143
|
+
}
|
|
103
144
|
function truncateString(value, maxLength) {
|
|
104
145
|
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
|
|
105
146
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const OXYGEN_VERSION = "1.
|
|
1
|
+
export declare const OXYGEN_VERSION = "1.164.30";
|
|
2
2
|
export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.154.0";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const OXYGEN_VERSION = "1.
|
|
1
|
+
export const OXYGEN_VERSION = "1.164.30";
|
|
2
2
|
// Bump this only when deployed CLI/API contracts require a newer CLI.
|
|
3
3
|
// 1.154.0: LinkedIn → Sequencer rename moved the CLI/API/MCP surface
|
|
4
4
|
// (oxygen sequences|inbox|senders, /api/cli/{sequences,inbox,senders}) and
|
|
@@ -9,7 +9,6 @@ export declare const DEFAULT_WORKFLOW_CRON_TIMEZONE = "UTC";
|
|
|
9
9
|
export type WorkflowMode = "dry_run" | "live" | "smoke_test";
|
|
10
10
|
export type WorkflowTriggerType = "api" | "webhook" | "cron" | "event";
|
|
11
11
|
export type WorkflowStatus = "active" | "disabled";
|
|
12
|
-
export type WorkflowStepKind = "transform" | "tool" | "branch";
|
|
13
12
|
export type WorkflowStepEffect = "none" | "external_read" | "external_write";
|
|
14
13
|
export type RecipeRuntime = "durable";
|
|
15
14
|
export type WorkflowEventFilterOp = "eq" | "neq" | "exists" | "not_exists";
|
|
@@ -211,16 +210,6 @@ export type Blueprint = {
|
|
|
211
210
|
source_hash: string;
|
|
212
211
|
compiler_version: typeof BLUEPRINT_COMPILER_VERSION;
|
|
213
212
|
};
|
|
214
|
-
export type WorkflowApplyInput = {
|
|
215
|
-
manifest: WorkflowManifest;
|
|
216
|
-
};
|
|
217
|
-
export type WorkflowCallInput = {
|
|
218
|
-
workflow_id?: string;
|
|
219
|
-
workflow_name?: string;
|
|
220
|
-
input?: Record<string, unknown>;
|
|
221
|
-
mode: WorkflowMode;
|
|
222
|
-
idempotency_key?: string;
|
|
223
|
-
};
|
|
224
213
|
type WorkflowFunction = (context: Record<string, unknown>) => unknown | Promise<unknown>;
|
|
225
214
|
export type WorkflowDefinition = {
|
|
226
215
|
readonly __oxygen_workflow_definition: true;
|
|
@@ -815,4 +804,16 @@ export declare function getWorkflowSchema(subject?: "apply" | "call" | "event" |
|
|
|
815
804
|
};
|
|
816
805
|
};
|
|
817
806
|
};
|
|
807
|
+
export declare const WORKFLOW_MAX_CREDITS_EXCEEDED_ERROR_CODE = "max_credits_exceeded";
|
|
808
|
+
export declare function readWorkflowRunMaxCredits(metadata: Record<string, unknown> | null | undefined): number | null;
|
|
809
|
+
export declare function readManagedToolRunCredits(output: unknown): number;
|
|
810
|
+
export type WorkflowSpendCapDecision = {
|
|
811
|
+
allowed: boolean;
|
|
812
|
+
projectedCredits: number;
|
|
813
|
+
};
|
|
814
|
+
export declare function evaluateWorkflowRunSpendCap(input: {
|
|
815
|
+
maxCredits: number;
|
|
816
|
+
creditsUsed: number;
|
|
817
|
+
estimatedCredits?: number | null;
|
|
818
|
+
}): WorkflowSpendCapDecision;
|
|
818
819
|
export {};
|
|
@@ -1393,6 +1393,64 @@ function isRecord(value) {
|
|
|
1393
1393
|
function isNonEmptyString(value) {
|
|
1394
1394
|
return typeof value === "string" && value.trim().length > 0;
|
|
1395
1395
|
}
|
|
1396
|
+
// --- Workflow run spend cap (max_credits) -------------------------------
|
|
1397
|
+
// Live `workflows call` requests carry an approved max_credits spend cap in
|
|
1398
|
+
// the run's metadata (R-E.22(c)). These pure helpers are the single source of
|
|
1399
|
+
// truth for reading that cap and deciding whether the next paid tool step may
|
|
1400
|
+
// run; the worker's step loop and the durable-recipe runtime wrap the refusal
|
|
1401
|
+
// in an OxygenError with this code so the stop is visible as run/step state.
|
|
1402
|
+
export const WORKFLOW_MAX_CREDITS_EXCEEDED_ERROR_CODE = "max_credits_exceeded";
|
|
1403
|
+
// Matches the soft-ceiling epsilon used by table action runs so float drift
|
|
1404
|
+
// never refuses a run that is exactly at its cap.
|
|
1405
|
+
const WORKFLOW_SPEND_CAP_EPSILON = 0.000001;
|
|
1406
|
+
export function readWorkflowRunMaxCredits(metadata) {
|
|
1407
|
+
if (!isRecord(metadata))
|
|
1408
|
+
return null;
|
|
1409
|
+
const raw = metadata.max_credits ?? metadata.maxCredits;
|
|
1410
|
+
const parsed = typeof raw === "number"
|
|
1411
|
+
? raw
|
|
1412
|
+
: typeof raw === "string" && raw.trim()
|
|
1413
|
+
? Number(raw)
|
|
1414
|
+
: Number.NaN;
|
|
1415
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
1416
|
+
return null;
|
|
1417
|
+
return parsed;
|
|
1418
|
+
}
|
|
1419
|
+
// Managed tool runs attach `meta.billing` with the credits the run charged
|
|
1420
|
+
// (`managed_credit_estimate` is reserved and captured in full). BYOK and
|
|
1421
|
+
// user-connection runs consume no OXYGEN credits, so they never count
|
|
1422
|
+
// against the cap.
|
|
1423
|
+
export function readManagedToolRunCredits(output) {
|
|
1424
|
+
if (!isRecord(output))
|
|
1425
|
+
return 0;
|
|
1426
|
+
const meta = output.meta;
|
|
1427
|
+
if (!isRecord(meta))
|
|
1428
|
+
return 0;
|
|
1429
|
+
const billing = meta.billing;
|
|
1430
|
+
if (!isRecord(billing))
|
|
1431
|
+
return 0;
|
|
1432
|
+
if (billing.credential_mode !== "managed")
|
|
1433
|
+
return 0;
|
|
1434
|
+
const credits = billing.managed_credit_estimate;
|
|
1435
|
+
if (typeof credits !== "number" || !Number.isFinite(credits) || credits <= 0)
|
|
1436
|
+
return 0;
|
|
1437
|
+
return credits;
|
|
1438
|
+
}
|
|
1439
|
+
// `estimatedCredits` is null when the next tool has no managed-credit price
|
|
1440
|
+
// (free, BYOK, or unknown catalog entry): such a step is only refused when
|
|
1441
|
+
// the cap is already breached, never pre-emptively.
|
|
1442
|
+
export function evaluateWorkflowRunSpendCap(input) {
|
|
1443
|
+
const estimated = typeof input.estimatedCredits === "number"
|
|
1444
|
+
&& Number.isFinite(input.estimatedCredits)
|
|
1445
|
+
&& input.estimatedCredits > 0
|
|
1446
|
+
? input.estimatedCredits
|
|
1447
|
+
: 0;
|
|
1448
|
+
const projectedCredits = input.creditsUsed + estimated;
|
|
1449
|
+
return {
|
|
1450
|
+
allowed: projectedCredits <= input.maxCredits + WORKFLOW_SPEND_CAP_EPSILON,
|
|
1451
|
+
projectedCredits,
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1396
1454
|
function escapeRegExp(value) {
|
|
1397
1455
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1398
1456
|
}
|