@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 CHANGED
@@ -34,4 +34,4 @@ oxygen update
34
34
 
35
35
  For product documentation, visit https://oxygen-agent.com/docs. For support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.162.10
37
+ Version: 1.164.30
@@ -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
- addVercelProtectionBypassHeader(apiUrl, headers);
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 multi-row column runs require an explicit --max-credits spend cap; the
87
- // server rejects them with max_credits_required plus a recommended cap. Surface
88
- // that as a one-line stderr hint so users don't have to dig the value out of the
89
- // JSON envelope (stderr keeps --json stdout machine-clean).
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) || error.code !== "max_credits_required")
92
+ if (!(error instanceof OxygenError))
92
93
  return;
93
- const recommended = readRecommendedMaxCredits(error.details);
94
- if (recommended === null)
95
- return;
96
- process.stderr.write(`hint: re-run with --max-credits ${recommended} to approve the spend cap\n`);
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 readRecommendedMaxCredits(details) {
123
+ function readDetailsNumber(details, key) {
99
124
  if (!details || typeof details !== "object" || Array.isArray(details))
100
125
  return null;
101
- const value = details.recommended_max_credits;
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 what's deployed in prod.")
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
- const server = await requestOxygen("/api/health", { requireAuth: false, enforceMinimumCliVersion: false });
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("LinkedIn unified inbox (unibox): scan conversations across all sender accounts, read threads, and reply.")
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 LinkedIn conversations across all connected accounts, newest first.")
3466
- .option("--account <id>", "Filter to one sender account (sender id, connection id, or Unipile account id).")
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 or Unipile chat id.")
3494
- .argument("<conversation>", "Conversation id or Unipile chat 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: { source: "zapmail", ...(connection ? { connection_id: connection } : {}) },
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(String(entry), MAX_TELEMETRY_STRING_LENGTH));
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.162.10";
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.162.10";
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.162.10",
3
+ "version": "1.164.30",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",