@oxygen-agent/cli 1.177.1 → 1.209.6
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 +6 -4
- package/dist/index.d.ts +0 -1
- package/dist/index.js +1144 -24
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +41 -0
- package/node_modules/@oxygen/shared/dist/billing.d.ts +28 -6
- package/node_modules/@oxygen/shared/dist/billing.js +41 -0
- package/node_modules/@oxygen/shared/dist/budget-scopes.d.ts +4 -0
- package/node_modules/@oxygen/shared/dist/budget-scopes.js +9 -0
- package/node_modules/@oxygen/shared/dist/cell-format.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/cell-format.js +26 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/index.js +2 -0
- package/node_modules/@oxygen/shared/dist/networks.d.ts +21 -0
- package/node_modules/@oxygen/shared/dist/networks.js +25 -0
- package/node_modules/@oxygen/shared/dist/search-vocab.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/select-options.d.ts +7 -0
- package/node_modules/@oxygen/shared/dist/select-options.js +9 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +91 -1
- package/node_modules/@oxygen/shared/dist/sequences.js +288 -15
- package/node_modules/@oxygen/shared/dist/signup-lead-webhook.d.ts +39 -0
- package/node_modules/@oxygen/shared/dist/signup-lead-webhook.js +78 -0
- package/node_modules/@oxygen/shared/dist/sql-error.d.ts +12 -0
- package/node_modules/@oxygen/shared/dist/sql-error.js +15 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/version.js +4 -2
- package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +2 -5
- package/node_modules/@oxygen/workflows/dist/index.d.ts +23 -0
- package/node_modules/@oxygen/workflows/dist/index.js +199 -24
- package/oxygen.js +2 -0
- package/package.json +2 -2
|
@@ -8,6 +8,9 @@ export const BLUEPRINT_COMPILER_VERSION = "oxygen-blueprints-v1";
|
|
|
8
8
|
export const MAX_RECIPE_BUNDLE_BYTES = 2_000_000;
|
|
9
9
|
export const MAX_BLUEPRINT_BYTES = 4_000_000;
|
|
10
10
|
export const DEFAULT_WORKFLOW_CRON_TIMEZONE = "UTC";
|
|
11
|
+
// Step ids that must not be used: they collide with JavaScript object internals
|
|
12
|
+
// when the runtime assembles ctx.steps, so their outputs would be silently lost.
|
|
13
|
+
const RESERVED_STEP_IDS = new Set(["__proto__", "constructor", "prototype"]);
|
|
11
14
|
// Compatibility and determinism lint only. The Vercel sandbox process,
|
|
12
15
|
// denied network policy, and runtime global guards are the security boundary.
|
|
13
16
|
const UNSAFE_RECIPE_BUNDLE_PATTERNS = [
|
|
@@ -18,7 +21,11 @@ const UNSAFE_RECIPE_BUNDLE_PATTERNS = [
|
|
|
18
21
|
{ token: "XMLHttpRequest", pattern: /\bXMLHttpRequest\b/ }, // skipcq: SCT-A000
|
|
19
22
|
{ token: "WebSocket", pattern: /\bWebSocket\b/ },
|
|
20
23
|
{ token: "EventSource", pattern: /\bEventSource\b/ }, // skipcq: SCT-A000
|
|
21
|
-
|
|
24
|
+
// Block CommonJS require(...) but allow a `.require` method access (the
|
|
25
|
+
// ctx.approvals.require() durable-approval API). A real global require is a
|
|
26
|
+
// bare identifier; any X.require on a dangerous global (globalThis/process/…)
|
|
27
|
+
// is already caught by that global's own rule above.
|
|
28
|
+
{ token: "require", pattern: /(?<!\.)\brequire\b/ },
|
|
22
29
|
{ token: "dynamic import", pattern: /\bimport(?:\s|\/\*[\s\S]*?\*\/)*\(/ }, // skipcq: SCT-A000
|
|
23
30
|
{ token: "node module import", pattern: /\b(?:node:)?(?:fs|child_process|net|tls|http|https|dns|dgram|worker_threads)\b/ }, // skipcq: SCT-A000
|
|
24
31
|
{ token: "eval", pattern: /\beval\b/ },
|
|
@@ -44,6 +51,7 @@ export function defineWorkflow(input) {
|
|
|
44
51
|
...(input.specification ? { specification: input.specification } : {}),
|
|
45
52
|
...(input.trigger ? { trigger: input.trigger } : {}),
|
|
46
53
|
...(input.inputSchema ? { inputSchema: input.inputSchema } : {}),
|
|
54
|
+
...(input.maxCredits != null ? { maxCredits: input.maxCredits } : {}),
|
|
47
55
|
steps: input.steps,
|
|
48
56
|
};
|
|
49
57
|
}
|
|
@@ -85,18 +93,49 @@ export function eventTrigger(input) {
|
|
|
85
93
|
...(input.status ? { status: input.status } : {}),
|
|
86
94
|
};
|
|
87
95
|
}
|
|
96
|
+
// Lookahead window for the next cron run. Must exceed the largest gap a *valid*
|
|
97
|
+
// schedule can have, or legitimate sparse crons get falsely rejected: the worst
|
|
98
|
+
// case is "Feb 29" across a century boundary (e.g. 2096 → 2104, an ~8-year gap,
|
|
99
|
+
// since 2100 is not a leap year). The day-skipping scan below keeps even this
|
|
100
|
+
// wide window cheap (~one zonedDateParts call per non-matching day).
|
|
101
|
+
const MAX_CRON_LOOKAHEAD_DAYS = 366 * 8 + 2;
|
|
88
102
|
export function nextCronRunAfter(input) {
|
|
89
103
|
const schedule = parseCronSchedule(input.cron);
|
|
90
104
|
const timezone = input.timezone?.trim() || DEFAULT_WORKFLOW_CRON_TIMEZONE;
|
|
91
105
|
let candidate = roundUpToNextMinute(input.after ?? new Date());
|
|
92
|
-
const deadline = candidate.getTime() +
|
|
106
|
+
const deadline = candidate.getTime() + MAX_CRON_LOOKAHEAD_DAYS * 24 * 60 * 60 * 1000;
|
|
93
107
|
while (candidate.getTime() <= deadline) {
|
|
94
|
-
|
|
108
|
+
const parts = zonedDateParts(candidate, timezone);
|
|
109
|
+
if (matchesCronSchedule(schedule, parts)) {
|
|
95
110
|
return candidate;
|
|
96
111
|
}
|
|
97
|
-
|
|
112
|
+
// If the calendar date itself can't match, no minute that day matches —
|
|
113
|
+
// jump to the next day instead of scanning all 1440 minutes. This keeps
|
|
114
|
+
// sparse schedules (e.g. "0 9 29 2 *") and unsatisfiable ones fast over the
|
|
115
|
+
// multi-year window. The jump targets ~00:00 of the next day, so a
|
|
116
|
+
// subsequently matching date is scanned from its start (no missed minutes).
|
|
117
|
+
if (!matchesCronDate(schedule, parts)) {
|
|
118
|
+
const minutesToNextDay = Math.max(1, 24 * 60 - (parts.hour * 60 + parts.minute));
|
|
119
|
+
let next = new Date(candidate.getTime() + minutesToNextDay * 60_000);
|
|
120
|
+
// minutesToNextDay is a LOCAL-clock count added to a UTC instant. On a
|
|
121
|
+
// spring-forward day the local day is < 24h, so the jump overshoots the
|
|
122
|
+
// next local midnight and lands at ~01:00 — skipping an early-of-day match
|
|
123
|
+
// (e.g. "0 0 …"), which can push the next run a full year out. If the jump
|
|
124
|
+
// crossed into the next local day but past midnight, pull it back to that
|
|
125
|
+
// midnight. (A fall-back day is > 24h: the jump undershoots and stays on
|
|
126
|
+
// the same date, which the next iteration advances — left as-is.)
|
|
127
|
+
const landed = zonedDateParts(next, timezone);
|
|
128
|
+
const intoNextLocalDay = landed.hour * 60 + landed.minute;
|
|
129
|
+
if (landed.dayOfMonth !== parts.dayOfMonth && intoNextLocalDay > 0) {
|
|
130
|
+
next = new Date(next.getTime() - intoNextLocalDay * 60_000);
|
|
131
|
+
}
|
|
132
|
+
candidate = next;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
candidate = new Date(candidate.getTime() + 60_000);
|
|
136
|
+
}
|
|
98
137
|
}
|
|
99
|
-
throw new Error(
|
|
138
|
+
throw new Error(`Cron expression has no matching run time in the next ${MAX_CRON_LOOKAHEAD_DAYS} days.`);
|
|
100
139
|
}
|
|
101
140
|
export function transformStep(input) {
|
|
102
141
|
return {
|
|
@@ -116,9 +155,23 @@ export function toolStep(input) {
|
|
|
116
155
|
tool: input.tool,
|
|
117
156
|
...(input.effect ? { effect: input.effect } : {}),
|
|
118
157
|
...(input.mode ? { mode: input.mode } : {}),
|
|
158
|
+
...(input.maxCredits != null ? { maxCredits: input.maxCredits } : {}),
|
|
119
159
|
payload: input.payload,
|
|
120
160
|
};
|
|
121
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Conditional step. `condition` is evaluated; control then flows by mode:
|
|
164
|
+
*
|
|
165
|
+
* - then only (no `else`, no `join`) — GUARD: truthy continues to `then` (steps
|
|
166
|
+
* between the branch and `then` are skipped); falsy skips straight to the end
|
|
167
|
+
* of the workflow (no step after the branch runs) and the run completes.
|
|
168
|
+
* - then + else (no `join`) — skip-until-target jump to `then`/`else`; from the
|
|
169
|
+
* chosen target, the remaining steps run sequentially through the end.
|
|
170
|
+
* - then + else + join — real if/else: the non-chosen arm is skipped and `join`
|
|
171
|
+
* is the convergence point where shared downstream work resumes.
|
|
172
|
+
*
|
|
173
|
+
* All targets must be ids of later steps (branches are forward-only).
|
|
174
|
+
*/
|
|
122
175
|
export function branchStep(input) {
|
|
123
176
|
return {
|
|
124
177
|
__oxygen_workflow_step: true,
|
|
@@ -161,6 +214,7 @@ definition, options = {}) {
|
|
|
161
214
|
...(definition.specification ? { specification: definition.specification } : {}),
|
|
162
215
|
...(definition.trigger ? { trigger: definition.trigger } : {}),
|
|
163
216
|
...(definition.inputSchema ? { input_schema: definition.inputSchema } : {}),
|
|
217
|
+
...(definition.maxCredits != null ? { max_credits: definition.maxCredits } : {}),
|
|
164
218
|
steps: definition.steps.map((step) => {
|
|
165
219
|
if (step.kind === "transform") {
|
|
166
220
|
return {
|
|
@@ -188,6 +242,7 @@ definition, options = {}) {
|
|
|
188
242
|
tool: step.tool,
|
|
189
243
|
effect: step.effect ?? "external_read",
|
|
190
244
|
...(step.mode ? { mode: step.mode } : {}),
|
|
245
|
+
...(step.maxCredits != null ? { max_credits: step.maxCredits } : {}),
|
|
191
246
|
payload_source: serializeWorkflowFunction(step.payload, `steps.${step.id}.payload`),
|
|
192
247
|
};
|
|
193
248
|
}),
|
|
@@ -218,12 +273,23 @@ export function buildRecipeManifest(input) {
|
|
|
218
273
|
bundle_format: "esm",
|
|
219
274
|
tools_used: Array.from(new Set((input.toolsUsed ?? []).filter(Boolean))).sort(),
|
|
220
275
|
...(input.visualPlan ? { visual_plan: input.visualPlan } : {}),
|
|
276
|
+
...(input.maxCredits != null ? { max_credits: input.maxCredits } : {}),
|
|
221
277
|
source_hash: sourceHash,
|
|
222
278
|
compiler_version: DURABLE_RECIPE_COMPILER_VERSION,
|
|
223
279
|
created_at: (input.createdAt ?? new Date()).toISOString(),
|
|
224
280
|
};
|
|
225
281
|
return manifest;
|
|
226
282
|
}
|
|
283
|
+
// Shared positive-finite validator for the optional per-run / per-step
|
|
284
|
+
// max_credits caps (Gap 2). `undefined` is valid (no cap); anything else must be
|
|
285
|
+
// a positive finite number, or lint rejects the manifest.
|
|
286
|
+
function validateOptionalMaxCredits(value, path, add) {
|
|
287
|
+
if (value === undefined)
|
|
288
|
+
return;
|
|
289
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
290
|
+
add(path, "invalid_max_credits", "max_credits must be a positive number.");
|
|
291
|
+
}
|
|
292
|
+
}
|
|
227
293
|
export function lintWorkflowManifest(// skipcq: JS-R1005
|
|
228
294
|
value, options = {}) {
|
|
229
295
|
if (isRecord(value)
|
|
@@ -255,6 +321,7 @@ value, options = {}) {
|
|
|
255
321
|
}
|
|
256
322
|
if (value.trigger !== undefined)
|
|
257
323
|
validateTrigger(value.trigger, "$.trigger", add);
|
|
324
|
+
validateOptionalMaxCredits(value.max_credits, "$.max_credits", add);
|
|
258
325
|
if (!Array.isArray(value.steps) || value.steps.length === 0) {
|
|
259
326
|
add("$.steps", "missing_steps", "At least one workflow step is required.");
|
|
260
327
|
}
|
|
@@ -278,6 +345,15 @@ value, options = {}) {
|
|
|
278
345
|
if (!isNonEmptyString(step.id)) {
|
|
279
346
|
add(`${path}.id`, "invalid_step_id", "Step id is required.");
|
|
280
347
|
}
|
|
348
|
+
else if (RESERVED_STEP_IDS.has(step.id)) {
|
|
349
|
+
// Step ids become keys on the ctx.steps object the runtime assembles.
|
|
350
|
+
// "__proto__" / "constructor" / "prototype" collide with JavaScript
|
|
351
|
+
// object internals: assigning ctx.steps["__proto__"] = output hits the
|
|
352
|
+
// prototype setter, so the output is silently dropped from step outputs
|
|
353
|
+
// and unreadable downstream while the run still reports success. Reject
|
|
354
|
+
// at authoring time instead of letting that data loss happen at runtime.
|
|
355
|
+
add(`${path}.id`, "reserved_step_id", `Step id '${step.id}' is reserved (it collides with a JavaScript object key and would be dropped from step outputs). Choose another id.`);
|
|
356
|
+
}
|
|
281
357
|
else if (ids.has(step.id)) {
|
|
282
358
|
add(`${path}.id`, "duplicate_step_id", `Step id '${step.id}' is duplicated.`);
|
|
283
359
|
}
|
|
@@ -320,6 +396,7 @@ value, options = {}) {
|
|
|
320
396
|
&& step.mode !== "smoke_test") {
|
|
321
397
|
add(`${path}.mode`, "invalid_step_mode", "Step mode is invalid.");
|
|
322
398
|
}
|
|
399
|
+
validateOptionalMaxCredits(step.max_credits, `${path}.max_credits`, add);
|
|
323
400
|
return;
|
|
324
401
|
}
|
|
325
402
|
if (step.kind === "branch") {
|
|
@@ -390,6 +467,7 @@ value, options = {}) {
|
|
|
390
467
|
if (value.bundle_format !== "esm") {
|
|
391
468
|
add("$.bundle_format", "invalid_bundle_format", "Recipe bundle_format must be 'esm'.");
|
|
392
469
|
}
|
|
470
|
+
validateOptionalMaxCredits(value.max_credits, "$.max_credits", add);
|
|
393
471
|
if (value.runtime !== "durable") {
|
|
394
472
|
add("$.runtime", "invalid_recipe_runtime", "Durable recipe manifests must set runtime to durable.");
|
|
395
473
|
}
|
|
@@ -802,25 +880,52 @@ export async function runPureWorkflowFunction(input) {
|
|
|
802
880
|
sandbox.setImmediate = undefined;
|
|
803
881
|
sandbox.queueMicrotask = undefined;
|
|
804
882
|
sandbox.WebAssembly = undefined;
|
|
805
|
-
const script = new vm.Script(
|
|
806
|
-
+
|
|
883
|
+
const script = new vm.Script('"use strict";\n'
|
|
884
|
+
+ 'const __oxygen_context = JSON.parse(__oxygen_context_json);\n'
|
|
807
885
|
+ `const __oxygen_fn = (${input.source});\n`
|
|
808
|
-
+
|
|
886
|
+
+ '__oxygen_fn(__oxygen_context);');
|
|
809
887
|
// Disable dynamic code generation in the sandboxed context. Combined with
|
|
810
888
|
// the JSON re-parse above, this means every prototype-walk path to
|
|
811
889
|
// Function — direct, via context.constructor.constructor, or any other
|
|
812
890
|
// reachable Function instance — throws EvalError when called with source.
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
891
|
+
try {
|
|
892
|
+
const result = script.runInNewContext(sandbox, {
|
|
893
|
+
timeout: timeoutMs,
|
|
894
|
+
contextCodeGeneration: {
|
|
895
|
+
strings: false,
|
|
896
|
+
wasm: false,
|
|
897
|
+
},
|
|
898
|
+
});
|
|
899
|
+
const resolved = isPromiseLike(result)
|
|
900
|
+
? await withTimeout(result, timeoutMs)
|
|
901
|
+
: result;
|
|
902
|
+
return enforceJsonOutput(resolved, maxOutputBytes);
|
|
903
|
+
}
|
|
904
|
+
catch (error) {
|
|
905
|
+
throw normalizeSandboxThrow(error);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// A throw from inside the vm sandbox is constructed with the sandbox realm's
|
|
909
|
+
// Error, so `error instanceof Error` is false in the host worker — and
|
|
910
|
+
// serializeOxygenError then drops the message and reports a generic
|
|
911
|
+
// "Workflow run failed.", leaving a failing transform/branch undebuggable.
|
|
912
|
+
// Reconstruct a host-realm Error preserving the user's message/name. Host-realm
|
|
913
|
+
// throws (vm timeout, enforceJsonOutput's workflow_output_too_large) already
|
|
914
|
+
// pass instanceof and are returned unchanged.
|
|
915
|
+
function normalizeSandboxThrow(error) {
|
|
916
|
+
if (error instanceof Error)
|
|
917
|
+
return error;
|
|
918
|
+
if (error && typeof error === "object") {
|
|
919
|
+
const record = error;
|
|
920
|
+
const message = typeof record.message === "string" && record.message
|
|
921
|
+
? record.message
|
|
922
|
+
: "Workflow function threw.";
|
|
923
|
+
const normalized = new Error(message);
|
|
924
|
+
if (typeof record.name === "string" && record.name)
|
|
925
|
+
normalized.name = record.name;
|
|
926
|
+
return normalized;
|
|
927
|
+
}
|
|
928
|
+
return new Error(typeof error === "string" && error ? error : "Workflow function threw a non-error value.");
|
|
824
929
|
}
|
|
825
930
|
export const workflowApplySchema = {
|
|
826
931
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
@@ -1077,6 +1182,19 @@ value, path, add) {
|
|
|
1077
1182
|
add(`${path}.cron`, "invalid_cron", error instanceof Error ? error.message : "Cron expression is invalid.");
|
|
1078
1183
|
}
|
|
1079
1184
|
}
|
|
1185
|
+
// Validate the timezone here so an unknown zone fails lint with a typed
|
|
1186
|
+
// issue, rather than passing lint and blowing up at apply time (the
|
|
1187
|
+
// scheduler's Intl.DateTimeFormat throws on an unknown zone). null/empty
|
|
1188
|
+
// are allowed — the scheduler falls back to UTC.
|
|
1189
|
+
const { timezone } = value;
|
|
1190
|
+
if (timezone !== undefined && timezone !== null) {
|
|
1191
|
+
if (typeof timezone !== "string") {
|
|
1192
|
+
add(`${path}.timezone`, "invalid_timezone", "Cron trigger timezone must be a string IANA timezone.");
|
|
1193
|
+
}
|
|
1194
|
+
else if (timezone.trim() && !isValidTimeZone(timezone.trim())) {
|
|
1195
|
+
add(`${path}.timezone`, "invalid_timezone", `Unknown timezone '${timezone}'. Use an IANA timezone such as 'America/New_York' or 'UTC'.`);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1080
1198
|
return;
|
|
1081
1199
|
}
|
|
1082
1200
|
if (value.type === "event") {
|
|
@@ -1272,11 +1390,9 @@ function normalizeDaysOfWeek(values) {
|
|
|
1272
1390
|
}
|
|
1273
1391
|
return normalized;
|
|
1274
1392
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
if (!schedule.hours.has(parts.hour))
|
|
1279
|
-
return false;
|
|
1393
|
+
// Date-only match (month + day-of-month/day-of-week), excluding hour/minute.
|
|
1394
|
+
// Lets nextCronRunAfter skip whole non-matching days in one jump.
|
|
1395
|
+
function matchesCronDate(schedule, parts) {
|
|
1280
1396
|
if (!schedule.months.has(parts.month))
|
|
1281
1397
|
return false;
|
|
1282
1398
|
const dayOfMonthMatches = schedule.daysOfMonth.has(parts.dayOfMonth);
|
|
@@ -1286,6 +1402,13 @@ function matchesCronSchedule(schedule, parts) {
|
|
|
1286
1402
|
}
|
|
1287
1403
|
return dayOfMonthMatches && dayOfWeekMatches;
|
|
1288
1404
|
}
|
|
1405
|
+
function matchesCronSchedule(schedule, parts) {
|
|
1406
|
+
if (!schedule.minutes.has(parts.minute))
|
|
1407
|
+
return false;
|
|
1408
|
+
if (!schedule.hours.has(parts.hour))
|
|
1409
|
+
return false;
|
|
1410
|
+
return matchesCronDate(schedule, parts);
|
|
1411
|
+
}
|
|
1289
1412
|
function roundUpToNextMinute(value) {
|
|
1290
1413
|
const result = new Date(value.getTime());
|
|
1291
1414
|
result.setUTCSeconds(0, 0);
|
|
@@ -1309,6 +1432,17 @@ function zonedDateParts(date, timezone) {
|
|
|
1309
1432
|
dayOfWeek: weekdayToNumber(parts.get("weekday") ?? ""),
|
|
1310
1433
|
};
|
|
1311
1434
|
}
|
|
1435
|
+
function isValidTimeZone(timezone) {
|
|
1436
|
+
try {
|
|
1437
|
+
// Throws RangeError for an unknown IANA zone; same mechanism the scheduler
|
|
1438
|
+
// uses, so lint accepts exactly the zones the runtime can resolve.
|
|
1439
|
+
new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(0);
|
|
1440
|
+
return true;
|
|
1441
|
+
}
|
|
1442
|
+
catch {
|
|
1443
|
+
return false;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1312
1446
|
function getZonedFormatter(timezone) {
|
|
1313
1447
|
const cached = zonedFormatters.get(timezone);
|
|
1314
1448
|
if (cached)
|
|
@@ -1349,6 +1483,25 @@ function weekdayToNumber(value) {
|
|
|
1349
1483
|
function hashWorkflowSource(source) {
|
|
1350
1484
|
return createHash("sha256").update(source).digest("hex");
|
|
1351
1485
|
}
|
|
1486
|
+
// Deterministic plan hash (Gap 4): binds an approval decision to the exact
|
|
1487
|
+
// reviewed plan + cost so it cannot be replayed against a changed plan. The
|
|
1488
|
+
// durable mid-run approval gate (recipe-context) hashes its relevant fields
|
|
1489
|
+
// through this; binding the imperative `workflows call` admission path the same
|
|
1490
|
+
// way is a flagged follow-up. Canonical (sorted-key) JSON keeps the hash stable
|
|
1491
|
+
// regardless of key order or undefined fields.
|
|
1492
|
+
export function computeWorkflowPlanHash(input) {
|
|
1493
|
+
return hashWorkflowSource(canonicalJsonStringify(input));
|
|
1494
|
+
}
|
|
1495
|
+
function canonicalJsonStringify(value) {
|
|
1496
|
+
if (value === null || typeof value !== "object")
|
|
1497
|
+
return JSON.stringify(value) ?? "null";
|
|
1498
|
+
if (Array.isArray(value))
|
|
1499
|
+
return `[${value.map(canonicalJsonStringify).join(",")}]`;
|
|
1500
|
+
const entries = Object.entries(value)
|
|
1501
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
1502
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1503
|
+
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalJsonStringify(entryValue)}`).join(",")}}`;
|
|
1504
|
+
}
|
|
1352
1505
|
function workflowJsonReplacer(_key, value) {
|
|
1353
1506
|
return typeof value === "function" ? Function.prototype.toString.call(value) : value;
|
|
1354
1507
|
}
|
|
@@ -1416,6 +1569,28 @@ export function readWorkflowRunMaxCredits(metadata) {
|
|
|
1416
1569
|
return null;
|
|
1417
1570
|
return parsed;
|
|
1418
1571
|
}
|
|
1572
|
+
// Approval labels the run's enqueue route already collected SYNCHRONOUS human
|
|
1573
|
+
// approval for (a CLI/MCP `approved=true` gate before the run was created), and
|
|
1574
|
+
// recorded in run metadata as `preapproved_labels`. A live ctx.approvals.require()
|
|
1575
|
+
// call whose `label` is in this set is honored immediately by both recipe
|
|
1576
|
+
// executors instead of parking on a durable decision row (local) or failing
|
|
1577
|
+
// closed with approval_requires_local_executor (sandbox). Every other label
|
|
1578
|
+
// still goes through the full durable gate, and the max_credits spend cap is
|
|
1579
|
+
// enforced separately per paid tool step, so a pre-approved label never widens
|
|
1580
|
+
// spend. Only trusted server code (the enqueue route) sets this — it is read
|
|
1581
|
+
// from run metadata, never from user-suppliable workflow input.
|
|
1582
|
+
export function readWorkflowRunPreApprovedLabels(metadata) {
|
|
1583
|
+
if (!isRecord(metadata))
|
|
1584
|
+
return [];
|
|
1585
|
+
const raw = metadata.preapproved_labels ?? metadata.preApprovedLabels;
|
|
1586
|
+
if (!Array.isArray(raw))
|
|
1587
|
+
return [];
|
|
1588
|
+
const labels = raw
|
|
1589
|
+
.filter((entry) => typeof entry === "string")
|
|
1590
|
+
.map((entry) => entry.trim())
|
|
1591
|
+
.filter((entry) => entry.length > 0);
|
|
1592
|
+
return Array.from(new Set(labels));
|
|
1593
|
+
}
|
|
1419
1594
|
// Managed tool runs attach `meta.billing` with the credits the run charged
|
|
1420
1595
|
// (`managed_credit_estimate` is reserved and captured in full). BYOK and
|
|
1421
1596
|
// user-connection runs consume no OXYGEN credits, so they never count
|
package/oxygen.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxygen-agent/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.209.6",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"directory": "packages/cli"
|
|
13
13
|
},
|
|
14
14
|
"bin": {
|
|
15
|
-
"oxygen": "./
|
|
15
|
+
"oxygen": "./oxygen.js"
|
|
16
16
|
},
|
|
17
17
|
"exports": {
|
|
18
18
|
".": {
|