@oxygen-agent/cli 1.160.18 → 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 +214 -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) {
|
|
@@ -137,6 +162,82 @@ function parseJsonArray(value) {
|
|
|
137
162
|
}
|
|
138
163
|
return parsed;
|
|
139
164
|
}
|
|
165
|
+
async function readDeleteRowIdsOption(options) {
|
|
166
|
+
const rowIdsJson = readOption(options.rowIdsJson);
|
|
167
|
+
const rowIdsFile = readOption(options.rowIdsFile);
|
|
168
|
+
if (rowIdsJson && rowIdsFile) {
|
|
169
|
+
throw new OxygenError("invalid_request", "Pass either --row-ids-json or --row-ids-file, not both.", {
|
|
170
|
+
exitCode: 1,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (!rowIdsJson && !rowIdsFile) {
|
|
174
|
+
throw new OxygenError("invalid_request", "Pass --row-ids-json or --row-ids-file.", {
|
|
175
|
+
exitCode: 1,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (rowIdsJson)
|
|
179
|
+
return normalizeDeleteRowIds(parseJsonArray(rowIdsJson));
|
|
180
|
+
const filePath = resolve(rowIdsFile ?? "");
|
|
181
|
+
const buffer = readFileSync(filePath);
|
|
182
|
+
const format = normalizeRowsFormat(options.format, inferRowsFileFormat(filePath));
|
|
183
|
+
if (format === "json") {
|
|
184
|
+
const text = buffer.toString("utf8");
|
|
185
|
+
const inlineIds = tryParseJsonStringArray(text);
|
|
186
|
+
if (inlineIds)
|
|
187
|
+
return normalizeDeleteRowIds(inlineIds);
|
|
188
|
+
if (!options.format && !text.trimStart().startsWith("[")) {
|
|
189
|
+
return normalizeDeleteRowIds(parsePlainRowIdList(text));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const sheet = readOption(options.sheet);
|
|
193
|
+
const rows = await parseRowsFileBuffer(buffer, format, sheet ? { sheet } : {});
|
|
194
|
+
const rowIdColumn = readOption(options.rowIdColumn) ?? "_row_id";
|
|
195
|
+
return normalizeDeleteRowIds(rows.map((row, index) => {
|
|
196
|
+
const value = row[rowIdColumn];
|
|
197
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
198
|
+
throw new OxygenError("invalid_request", `Row id column "${rowIdColumn}" must contain row UUID strings.`, {
|
|
199
|
+
details: { row_number: index + 1, column: rowIdColumn },
|
|
200
|
+
exitCode: 1,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return value.trim();
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
function tryParseJsonStringArray(value) {
|
|
207
|
+
let parsed;
|
|
208
|
+
try {
|
|
209
|
+
parsed = JSON.parse(value);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string"))
|
|
215
|
+
return null;
|
|
216
|
+
return parsed;
|
|
217
|
+
}
|
|
218
|
+
function parsePlainRowIdList(value) {
|
|
219
|
+
return value
|
|
220
|
+
.split(/[\n,]/)
|
|
221
|
+
.map((entry) => entry.trim())
|
|
222
|
+
.filter(Boolean);
|
|
223
|
+
}
|
|
224
|
+
function normalizeDeleteRowIds(values) {
|
|
225
|
+
const rowIds = values.map((value, index) => {
|
|
226
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
227
|
+
throw new OxygenError("invalid_request", "Row IDs must be non-empty strings.", {
|
|
228
|
+
details: { index },
|
|
229
|
+
exitCode: 1,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return value.trim();
|
|
233
|
+
});
|
|
234
|
+
if (rowIds.length === 0) {
|
|
235
|
+
throw new OxygenError("invalid_request", "Row ID list cannot be empty.", {
|
|
236
|
+
exitCode: 1,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return rowIds;
|
|
240
|
+
}
|
|
140
241
|
function readCustomIntegrationManifest(options) {
|
|
141
242
|
const manifestPath = readOption(options.manifest);
|
|
142
243
|
const manifestJson = readOption(options.manifestJson);
|
|
@@ -353,13 +454,26 @@ export function createProgram() {
|
|
|
353
454
|
}));
|
|
354
455
|
program
|
|
355
456
|
.command("status")
|
|
356
|
-
.description("Compare the local Oxygen CLI version against
|
|
457
|
+
.description("Compare the local Oxygen CLI version against the active profile's deployed Oxygen API.")
|
|
357
458
|
.option("--json", "Print a JSON envelope.")
|
|
358
459
|
.action(async (options) => {
|
|
359
460
|
await handleAsyncAction("status", options, async () => {
|
|
360
|
-
|
|
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
|
+
});
|
|
361
474
|
return {
|
|
362
475
|
client_version: OXYGEN_VERSION,
|
|
476
|
+
api_url: apiUrl,
|
|
363
477
|
server_version: server.server_version,
|
|
364
478
|
minimum_cli_version: server.minimum_cli_version ?? null,
|
|
365
479
|
sha: server.sha,
|
|
@@ -766,6 +880,27 @@ export function createProgram() {
|
|
|
766
880
|
row_id: rowId,
|
|
767
881
|
},
|
|
768
882
|
}));
|
|
883
|
+
}))
|
|
884
|
+
.addCommand(new Command("delete-rows")
|
|
885
|
+
.description("Delete multiple workspace table rows.")
|
|
886
|
+
.argument("<table>", "Table id or slug.")
|
|
887
|
+
.option("--row-ids-json <json>", "JSON array of workspace row UUIDs.")
|
|
888
|
+
.option("--row-ids-file <path>", "File containing row UUIDs or rows with a _row_id column.")
|
|
889
|
+
.option("--row-id-column <key>", "Column to read from --row-ids-file. Defaults to _row_id.")
|
|
890
|
+
.option("--format <format>", "File format for --row-ids-file: json, jsonl, csv, or xlsx.")
|
|
891
|
+
.option("--sheet <name>", "Worksheet name when reading row IDs from an XLSX file.")
|
|
892
|
+
.option("--json", "Print a JSON envelope.")
|
|
893
|
+
.action(async (table, options) => {
|
|
894
|
+
await handleAsyncAction("tables delete-rows", options, async () => {
|
|
895
|
+
const rowIds = await readDeleteRowIdsOption(options);
|
|
896
|
+
return requestOxygen("/api/cli/tables/rows/delete", {
|
|
897
|
+
method: "POST",
|
|
898
|
+
body: {
|
|
899
|
+
table,
|
|
900
|
+
row_ids: rowIds,
|
|
901
|
+
},
|
|
902
|
+
});
|
|
903
|
+
});
|
|
769
904
|
}))
|
|
770
905
|
.addCommand(new Command("upsert")
|
|
771
906
|
.description("Insert or update rows in a workspace table by a column key.")
|
|
@@ -2965,8 +3100,11 @@ export function createProgram() {
|
|
|
2965
3100
|
.option("--return <mode>", "Legacy response shape: raw, compact, or summary. Defaults to raw.")
|
|
2966
3101
|
.option("--return-mode <mode>", "Response shape: raw, compact, or summary. Prefer summary for large search responses.")
|
|
2967
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.")
|
|
2968
3105
|
.option("--json", "Print a JSON envelope.")
|
|
2969
3106
|
.action(async (toolId, options) => {
|
|
3107
|
+
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
2970
3108
|
await handleAsyncAction("tools run", options, () => requestOxygen("/api/cli/tools/run", {
|
|
2971
3109
|
method: "POST",
|
|
2972
3110
|
body: {
|
|
@@ -2981,6 +3119,8 @@ export function createProgram() {
|
|
|
2981
3119
|
...(readOption(options["return"]) ? { return: readOption(options["return"]) } : {}),
|
|
2982
3120
|
...(readOption(options.returnMode) ? { return_mode: readOption(options.returnMode) } : {}),
|
|
2983
3121
|
...(readOption(options.oxygenCursor) ? { oxygen_cursor: readOption(options.oxygenCursor) } : {}),
|
|
3122
|
+
...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
|
|
3123
|
+
...(options.approved ? { approved: true } : {}),
|
|
2984
3124
|
},
|
|
2985
3125
|
}));
|
|
2986
3126
|
}));
|
|
@@ -3363,10 +3503,11 @@ export function createProgram() {
|
|
|
3363
3503
|
});
|
|
3364
3504
|
}))));
|
|
3365
3505
|
program.addCommand(new Command("inbox")
|
|
3366
|
-
.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.")
|
|
3367
3507
|
.addCommand(new Command("list")
|
|
3368
|
-
.description("List
|
|
3369
|
-
.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).")
|
|
3370
3511
|
.option("--unread", "Only show conversations with unread messages.")
|
|
3371
3512
|
.option("--search <text>", "Filter by attendee name or last-message text.")
|
|
3372
3513
|
.option("--include-archived", "Include archived conversations.")
|
|
@@ -3375,6 +3516,9 @@ export function createProgram() {
|
|
|
3375
3516
|
.action(async (options) => {
|
|
3376
3517
|
await handleAsyncAction("inbox list", options, () => {
|
|
3377
3518
|
const params = new URLSearchParams();
|
|
3519
|
+
const channel = readOption(options.channel);
|
|
3520
|
+
if (channel)
|
|
3521
|
+
params.set("channel", channel);
|
|
3378
3522
|
const account = readOption(options.account);
|
|
3379
3523
|
if (account)
|
|
3380
3524
|
params.set("account", account);
|
|
@@ -3393,13 +3537,17 @@ export function createProgram() {
|
|
|
3393
3537
|
});
|
|
3394
3538
|
}))
|
|
3395
3539
|
.addCommand(new Command("get")
|
|
3396
|
-
.description("Get one conversation with its full message thread. <conversation> accepts a conversation id
|
|
3397
|
-
.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.")
|
|
3398
3543
|
.option("--message-limit <n>", "Maximum messages to return (1-500). Defaults to 100.")
|
|
3399
3544
|
.option("--json", "Print a JSON envelope.")
|
|
3400
3545
|
.action(async (conversation, options) => {
|
|
3401
3546
|
await handleAsyncAction("inbox get", options, () => {
|
|
3402
3547
|
const params = new URLSearchParams();
|
|
3548
|
+
const channel = readOption(options.channel);
|
|
3549
|
+
if (channel)
|
|
3550
|
+
params.set("channel", channel);
|
|
3403
3551
|
const messageLimit = readOption(options.messageLimit);
|
|
3404
3552
|
if (messageLimit)
|
|
3405
3553
|
params.set("message_limit", messageLimit);
|
|
@@ -3433,12 +3581,16 @@ export function createProgram() {
|
|
|
3433
3581
|
}))
|
|
3434
3582
|
.addCommand(new Command("sync")
|
|
3435
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).")
|
|
3436
3585
|
.option("--chat-limit <n>", "Maximum chats to sync per account. Defaults to 30.")
|
|
3437
3586
|
.option("--message-limit <n>", "Maximum messages to sync per chat. Defaults to 20.")
|
|
3438
3587
|
.option("--json", "Print a JSON envelope.")
|
|
3439
3588
|
.action(async (options) => {
|
|
3440
3589
|
await handleAsyncAction("inbox sync", options, () => {
|
|
3441
3590
|
const body = {};
|
|
3591
|
+
const account = readOption(options.account);
|
|
3592
|
+
if (account)
|
|
3593
|
+
body.account = account;
|
|
3442
3594
|
const chatLimit = readOption(options.chatLimit);
|
|
3443
3595
|
if (chatLimit)
|
|
3444
3596
|
body.chat_limit = Number(chatLimit);
|
|
@@ -3677,6 +3829,35 @@ export function createProgram() {
|
|
|
3677
3829
|
.action(async (sequence, options) => {
|
|
3678
3830
|
await handleAsyncAction("sequences stats", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/stats`));
|
|
3679
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
|
+
})));
|
|
3680
3861
|
program.addCommand(new Command("mailboxes")
|
|
3681
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).")
|
|
3682
3863
|
.addCommand(new Command("list")
|
|
@@ -3698,18 +3879,26 @@ export function createProgram() {
|
|
|
3698
3879
|
.option("--file <path>", "Path to a JSON file: { \"mailboxes\": [{ email_address, provider, workspace_external_id? }] }.")
|
|
3699
3880
|
.option("--from <source>", "Import source: 'zapmail' to pull the connected Zapmail workspace's mailboxes.")
|
|
3700
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.")
|
|
3701
3883
|
.option("--json", "Print a JSON envelope.")
|
|
3702
3884
|
.action(async (options) => {
|
|
3703
3885
|
await handleAsyncAction("mailboxes import", options, () => {
|
|
3704
3886
|
const from = readOption(options.from);
|
|
3705
3887
|
const filePath = readOption(options.file);
|
|
3706
3888
|
const connection = readOption(options.connection);
|
|
3889
|
+
const provider = readOption(options.provider);
|
|
3707
3890
|
if (from === "zapmail") {
|
|
3708
3891
|
return requestOxygen("/api/cli/mailboxes", {
|
|
3709
3892
|
method: "POST",
|
|
3710
|
-
body: {
|
|
3893
|
+
body: {
|
|
3894
|
+
source: "zapmail",
|
|
3895
|
+
...(connection ? { connection_id: connection } : {}),
|
|
3896
|
+
...(provider ? { service_provider: provider } : {}),
|
|
3897
|
+
},
|
|
3711
3898
|
});
|
|
3712
3899
|
}
|
|
3900
|
+
if (provider)
|
|
3901
|
+
throw new Error("--provider only applies with --from zapmail (inline files carry a per-mailbox provider).");
|
|
3713
3902
|
if (!filePath)
|
|
3714
3903
|
throw new Error("Provide --file <path> (a { \"mailboxes\": [...] } JSON file) or --from zapmail.");
|
|
3715
3904
|
const parsed = JSON.parse(readFileSync(resolve(filePath), "utf8"));
|
|
@@ -3967,9 +4156,12 @@ export function createProgram() {
|
|
|
3967
4156
|
.option("--input-json <json>", "Workflow input object. Defaults to {}.")
|
|
3968
4157
|
.requiredOption("--mode <mode>", "Execution mode: live, dry-run, or smoke-test.")
|
|
3969
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.")
|
|
3970
4161
|
.option("--include-bundle", "Include durable recipe bundles in JSON output.")
|
|
3971
4162
|
.option("--json", "Print a JSON envelope.")
|
|
3972
4163
|
.action(async (workflowArg, options) => {
|
|
4164
|
+
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
3973
4165
|
await handleAsyncAction("workflows call", options, async () => prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/call", {
|
|
3974
4166
|
method: "POST",
|
|
3975
4167
|
body: {
|
|
@@ -3979,6 +4171,8 @@ export function createProgram() {
|
|
|
3979
4171
|
input: options.inputJson ? parseJsonObject(options.inputJson) : {},
|
|
3980
4172
|
...(readOption(options.mode) ? { mode: readOption(options.mode) } : {}),
|
|
3981
4173
|
...(readOption(options.idempotencyKey) ? { idempotency_key: readOption(options.idempotencyKey) } : {}),
|
|
4174
|
+
...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
|
|
4175
|
+
...(options.approved ? { approved: true } : {}),
|
|
3982
4176
|
},
|
|
3983
4177
|
}), options));
|
|
3984
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
|
}
|