@oxygen-agent/cli 1.177.1 → 1.184.3

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.
@@ -0,0 +1,78 @@
1
+ import { createHmac } from "node:crypto";
2
+ // Shared transport contract for the signup-lead webhook. Both the request-time
3
+ // direct emitter (apps/web/src/lib/signup-lead-webhook.ts) and the durable
4
+ // worker retry queue (apps/worker/src/signup-lead-delivery-queue.ts) build the
5
+ // same HMAC signature, Oxygen table/workflow secret headers, timeout, and
6
+ // target classification. This module is the single source of truth so the two
7
+ // paths cannot drift when the webhook auth/header contract changes (OXY-679).
8
+ /** Request timeout shared by the direct emit and the durable worker retry. */
9
+ export const SIGNUP_LEAD_WEBHOOK_TIMEOUT_MS = 10_000;
10
+ /** True for the canonical Oxygen apex domain and its subdomains only. */
11
+ export function isOxygenHostname(hostname) {
12
+ return (hostname === "oxygen-agent.com" || hostname.endsWith(".oxygen-agent.com"));
13
+ }
14
+ /**
15
+ * Classify a webhook target URL so the caller can decide whether to attach the
16
+ * internal table/workflow secret. Only genuine oxygen-agent.com hosts receive
17
+ * internal secrets; external hosts with Oxygen-looking paths are "external" and
18
+ * must never be trusted with the internal webhook secret.
19
+ */
20
+ export function classifySignupLeadWebhookTarget(value) {
21
+ const target = typeof value === "string" ? value.trim() : "";
22
+ if (!target)
23
+ return "missing";
24
+ try {
25
+ const url = new URL(target);
26
+ if (!isOxygenHostname(url.hostname))
27
+ return "external";
28
+ if (url.pathname.startsWith("/api/webhooks/tables/")) {
29
+ return "oxygen_table_webhook";
30
+ }
31
+ if (url.pathname.startsWith("/api/webhooks/workflows/")) {
32
+ return "oxygen_workflow_webhook";
33
+ }
34
+ return "oxygen_internal";
35
+ }
36
+ catch {
37
+ return "invalid_url";
38
+ }
39
+ }
40
+ /** HMAC-SHA256 hex digest of the request body keyed by the webhook secret. */
41
+ export function signSignupLeadWebhookBody(secret, body) {
42
+ return createHmac("sha256", secret).update(body).digest("hex");
43
+ }
44
+ /**
45
+ * Build the complete header set for a signup-lead webhook request. Produces
46
+ * identical output for the request-time emit and the durable worker retry so
47
+ * the two transports stay in lockstep. Header values fall back to safe defaults
48
+ * for the worker's untyped (DB-sourced) payload; for the web emitter's typed
49
+ * payload the fallbacks are no-ops.
50
+ */
51
+ export function buildSignupLeadWebhookHeaders(input) {
52
+ const { webhookUrl, payload, body, secret } = input;
53
+ const now = input.now ?? new Date();
54
+ const headers = {
55
+ "content-type": "application/json",
56
+ "x-event-id": headerString(payload.event_id) ?? "",
57
+ "x-event-type": headerString(payload.type) ?? "signup_lead.event",
58
+ "x-event-time": headerString(payload.occurred_at) ?? now.toISOString(),
59
+ "x-oxygen-signup-source": headerString(payload.source) ?? "clerk",
60
+ };
61
+ if (secret) {
62
+ headers["x-oxygen-webhook-signature"] =
63
+ `sha256=${signSignupLeadWebhookBody(secret, body)}`;
64
+ headers["x-oxygen-webhook-signature-algorithm"] = "hmac-sha256";
65
+ headers["x-oxygen-webhook-signature-timestamp"] = String(Math.floor(now.getTime() / 1000));
66
+ const target = classifySignupLeadWebhookTarget(webhookUrl);
67
+ if (target === "oxygen_table_webhook") {
68
+ headers["x-oxygen-table-webhook-secret"] = secret;
69
+ }
70
+ else if (target === "oxygen_workflow_webhook") {
71
+ headers["x-oxygen-workflow-secret"] = secret;
72
+ }
73
+ }
74
+ return headers;
75
+ }
76
+ function headerString(value) {
77
+ return typeof value === "string" && value.trim() ? value.trim() : null;
78
+ }
@@ -33,5 +33,17 @@ export declare function sqlErrorFields(error: unknown): Record<string, unknown>;
33
33
  * the generic error serializers would otherwise emit those values verbatim.
34
34
  */
35
35
  export declare function redactSqlParameters(text: string): string;
36
+ /**
37
+ * True when the error is a Postgres serialization failure (40001) or deadlock
38
+ * (40P01) — the two "the transaction was aborted as a concurrency victim, retry
39
+ * the whole transaction" outcomes. Postgres aborts exactly one transaction in
40
+ * such a conflict and lets the other commit, so re-running the victim's
41
+ * transaction almost always succeeds once the winner has committed. Callers use
42
+ * this both to drive a bounded transaction retry and to surface a typed,
43
+ * retryable error (instead of an opaque unexpected_error) when retries are
44
+ * exhausted. Reuses the same SQLSTATE classifier as the telemetry helpers so the
45
+ * 40001/40P01 knowledge lives in one place. Never throws.
46
+ */
47
+ export declare function isRetryableConcurrencyError(error: unknown): boolean;
36
48
  /** Telemetry-attribute shape (dotted keys) for span error attribution. */
37
49
  export declare function sqlErrorTelemetryAttributes(error: unknown): Record<string, unknown>;
@@ -146,6 +146,21 @@ export function redactSqlParameters(text) {
146
146
  return text;
147
147
  return text.replace(/\n[ \t]*params[ \t]*:[\s\S]*?(?=\n[ \t]*at\s|$)/i, "");
148
148
  }
149
+ /**
150
+ * True when the error is a Postgres serialization failure (40001) or deadlock
151
+ * (40P01) — the two "the transaction was aborted as a concurrency victim, retry
152
+ * the whole transaction" outcomes. Postgres aborts exactly one transaction in
153
+ * such a conflict and lets the other commit, so re-running the victim's
154
+ * transaction almost always succeeds once the winner has committed. Callers use
155
+ * this both to drive a bounded transaction retry and to surface a typed,
156
+ * retryable error (instead of an opaque unexpected_error) when retries are
157
+ * exhausted. Reuses the same SQLSTATE classifier as the telemetry helpers so the
158
+ * 40001/40P01 knowledge lives in one place. Never throws.
159
+ */
160
+ export function isRetryableConcurrencyError(error) {
161
+ const cause = describeSqlError(error)?.cause;
162
+ return cause === "serialization" || cause === "deadlock";
163
+ }
149
164
  /** Telemetry-attribute shape (dotted keys) for span error attribution. */
150
165
  export function sqlErrorTelemetryAttributes(error) {
151
166
  const attribution = describeSqlError(error);
@@ -1,2 +1,2 @@
1
- export declare const OXYGEN_VERSION = "1.177.1";
2
- export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.154.0";
1
+ export declare const OXYGEN_VERSION = "1.184.3";
2
+ export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
@@ -1,6 +1,8 @@
1
- export const OXYGEN_VERSION = "1.177.1";
1
+ export const OXYGEN_VERSION = "1.184.3";
2
2
  // Bump this only when deployed CLI/API contracts require a newer CLI.
3
+ // 1.181.0: paid table action runs and background columns run require
4
+ // approved=true in addition to max_credits; older CLIs cannot send the flag.
3
5
  // 1.154.0: LinkedIn → Sequencer rename moved the CLI/API/MCP surface
4
6
  // (oxygen sequences|inbox|senders, /api/cli/{sequences,inbox,senders}) and
5
7
  // removed the old /api/cli/linkedin/* routes — older CLIs would 404.
6
- export const OXYGEN_MINIMUM_CLI_VERSION = "1.154.0";
8
+ export const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
@@ -67,6 +67,7 @@ export type WorkflowToolStepManifest = {
67
67
  effect: WorkflowStepEffect;
68
68
  mode?: WorkflowMode;
69
69
  payload_source: string;
70
+ max_credits?: number;
70
71
  };
71
72
  export type WorkflowBranchStepManifest = {
72
73
  kind: "branch";
@@ -121,6 +122,7 @@ export type WorkflowManifest = {
121
122
  trigger?: WorkflowTriggerManifest;
122
123
  input_schema?: JsonSchema;
123
124
  steps: WorkflowStepManifest[];
125
+ max_credits?: number;
124
126
  source_hash: string;
125
127
  compiler_version: string;
126
128
  created_at: string;
@@ -139,6 +141,7 @@ export type RecipeManifest = {
139
141
  bundle_format: "esm";
140
142
  tools_used: string[];
141
143
  visual_plan?: RecipeVisualPlanManifest;
144
+ max_credits?: number;
142
145
  source_hash: string;
143
146
  compiler_version: typeof DURABLE_RECIPE_COMPILER_VERSION;
144
147
  created_at: string;
@@ -219,6 +222,7 @@ export type WorkflowDefinition = {
219
222
  specification?: string;
220
223
  trigger?: WorkflowTriggerDefinition;
221
224
  inputSchema?: JsonSchema;
225
+ maxCredits?: number;
222
226
  steps: WorkflowStepDefinition[];
223
227
  };
224
228
  export type WorkflowTriggerDefinition = WorkflowTriggerManifest;
@@ -237,6 +241,7 @@ export type WorkflowToolStepDefinition = {
237
241
  tool: string;
238
242
  effect?: WorkflowStepEffect;
239
243
  mode?: WorkflowMode;
244
+ maxCredits?: number;
240
245
  payload: WorkflowFunction;
241
246
  };
242
247
  export type WorkflowBranchStepDefinition = {
@@ -270,6 +275,7 @@ export declare function defineWorkflow(input: {
270
275
  specification?: string;
271
276
  trigger?: WorkflowTriggerDefinition;
272
277
  inputSchema?: JsonSchema;
278
+ maxCredits?: number;
273
279
  steps: WorkflowStepDefinition[];
274
280
  }): WorkflowDefinition;
275
281
  export declare function apiTrigger(input?: {
@@ -310,8 +316,22 @@ export declare function toolStep(input: {
310
316
  tool: string;
311
317
  effect?: WorkflowStepEffect;
312
318
  mode?: WorkflowMode;
319
+ maxCredits?: number;
313
320
  payload: WorkflowFunction;
314
321
  }): WorkflowToolStepDefinition;
322
+ /**
323
+ * Conditional step. `condition` is evaluated; control then flows by mode:
324
+ *
325
+ * - then only (no `else`, no `join`) — GUARD: truthy continues to `then` (steps
326
+ * between the branch and `then` are skipped); falsy skips straight to the end
327
+ * of the workflow (no step after the branch runs) and the run completes.
328
+ * - then + else (no `join`) — skip-until-target jump to `then`/`else`; from the
329
+ * chosen target, the remaining steps run sequentially through the end.
330
+ * - then + else + join — real if/else: the non-chosen arm is skipped and `join`
331
+ * is the convergence point where shared downstream work resumes.
332
+ *
333
+ * All targets must be ids of later steps (branches are forward-only).
334
+ */
315
335
  export declare function branchStep(input: {
316
336
  id: string;
317
337
  description?: string;
@@ -339,6 +359,7 @@ export declare function buildRecipeManifest(input: {
339
359
  bundle: string;
340
360
  toolsUsed: string[];
341
361
  visualPlan?: RecipeVisualPlanManifest;
362
+ maxCredits?: number;
342
363
  sourceHash?: string;
343
364
  createdAt?: Date;
344
365
  }): RecipeManifest;
@@ -804,6 +825,7 @@ export declare function getWorkflowSchema(subject?: "apply" | "call" | "event" |
804
825
  };
805
826
  };
806
827
  };
828
+ export declare function computeWorkflowPlanHash(input: Record<string, unknown>): string;
807
829
  export declare const WORKFLOW_MAX_CREDITS_EXCEEDED_ERROR_CODE = "max_credits_exceeded";
808
830
  export declare function readWorkflowRunMaxCredits(metadata: Record<string, unknown> | null | undefined): number | null;
809
831
  export declare function readManagedToolRunCredits(output: unknown): number;
@@ -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
- { token: "require", pattern: /\brequire\b/ },
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,37 @@ 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() + 370 * 24 * 60 * 60 * 1000;
106
+ const deadline = candidate.getTime() + MAX_CRON_LOOKAHEAD_DAYS * 24 * 60 * 60 * 1000;
93
107
  while (candidate.getTime() <= deadline) {
94
- if (matchesCronSchedule(schedule, zonedDateParts(candidate, timezone))) {
108
+ const parts = zonedDateParts(candidate, timezone);
109
+ if (matchesCronSchedule(schedule, parts)) {
95
110
  return candidate;
96
111
  }
97
- candidate = new Date(candidate.getTime() + 60_000);
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 big jump always lands at ~00:00 of the next day,
116
+ // so a subsequently matching date is scanned from its start (no missed
117
+ // earlier minutes).
118
+ if (!matchesCronDate(schedule, parts)) {
119
+ const minutesToNextDay = Math.max(1, 24 * 60 - (parts.hour * 60 + parts.minute));
120
+ candidate = new Date(candidate.getTime() + minutesToNextDay * 60_000);
121
+ }
122
+ else {
123
+ candidate = new Date(candidate.getTime() + 60_000);
124
+ }
98
125
  }
99
- throw new Error("Cron expression has no matching run time in the next 370 days.");
126
+ throw new Error(`Cron expression has no matching run time in the next ${MAX_CRON_LOOKAHEAD_DAYS} days.`);
100
127
  }
101
128
  export function transformStep(input) {
102
129
  return {
@@ -116,9 +143,23 @@ export function toolStep(input) {
116
143
  tool: input.tool,
117
144
  ...(input.effect ? { effect: input.effect } : {}),
118
145
  ...(input.mode ? { mode: input.mode } : {}),
146
+ ...(input.maxCredits != null ? { maxCredits: input.maxCredits } : {}),
119
147
  payload: input.payload,
120
148
  };
121
149
  }
150
+ /**
151
+ * Conditional step. `condition` is evaluated; control then flows by mode:
152
+ *
153
+ * - then only (no `else`, no `join`) — GUARD: truthy continues to `then` (steps
154
+ * between the branch and `then` are skipped); falsy skips straight to the end
155
+ * of the workflow (no step after the branch runs) and the run completes.
156
+ * - then + else (no `join`) — skip-until-target jump to `then`/`else`; from the
157
+ * chosen target, the remaining steps run sequentially through the end.
158
+ * - then + else + join — real if/else: the non-chosen arm is skipped and `join`
159
+ * is the convergence point where shared downstream work resumes.
160
+ *
161
+ * All targets must be ids of later steps (branches are forward-only).
162
+ */
122
163
  export function branchStep(input) {
123
164
  return {
124
165
  __oxygen_workflow_step: true,
@@ -161,6 +202,7 @@ definition, options = {}) {
161
202
  ...(definition.specification ? { specification: definition.specification } : {}),
162
203
  ...(definition.trigger ? { trigger: definition.trigger } : {}),
163
204
  ...(definition.inputSchema ? { input_schema: definition.inputSchema } : {}),
205
+ ...(definition.maxCredits != null ? { max_credits: definition.maxCredits } : {}),
164
206
  steps: definition.steps.map((step) => {
165
207
  if (step.kind === "transform") {
166
208
  return {
@@ -188,6 +230,7 @@ definition, options = {}) {
188
230
  tool: step.tool,
189
231
  effect: step.effect ?? "external_read",
190
232
  ...(step.mode ? { mode: step.mode } : {}),
233
+ ...(step.maxCredits != null ? { max_credits: step.maxCredits } : {}),
191
234
  payload_source: serializeWorkflowFunction(step.payload, `steps.${step.id}.payload`),
192
235
  };
193
236
  }),
@@ -218,12 +261,23 @@ export function buildRecipeManifest(input) {
218
261
  bundle_format: "esm",
219
262
  tools_used: Array.from(new Set((input.toolsUsed ?? []).filter(Boolean))).sort(),
220
263
  ...(input.visualPlan ? { visual_plan: input.visualPlan } : {}),
264
+ ...(input.maxCredits != null ? { max_credits: input.maxCredits } : {}),
221
265
  source_hash: sourceHash,
222
266
  compiler_version: DURABLE_RECIPE_COMPILER_VERSION,
223
267
  created_at: (input.createdAt ?? new Date()).toISOString(),
224
268
  };
225
269
  return manifest;
226
270
  }
271
+ // Shared positive-finite validator for the optional per-run / per-step
272
+ // max_credits caps (Gap 2). `undefined` is valid (no cap); anything else must be
273
+ // a positive finite number, or lint rejects the manifest.
274
+ function validateOptionalMaxCredits(value, path, add) {
275
+ if (value === undefined)
276
+ return;
277
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
278
+ add(path, "invalid_max_credits", "max_credits must be a positive number.");
279
+ }
280
+ }
227
281
  export function lintWorkflowManifest(// skipcq: JS-R1005
228
282
  value, options = {}) {
229
283
  if (isRecord(value)
@@ -255,6 +309,7 @@ value, options = {}) {
255
309
  }
256
310
  if (value.trigger !== undefined)
257
311
  validateTrigger(value.trigger, "$.trigger", add);
312
+ validateOptionalMaxCredits(value.max_credits, "$.max_credits", add);
258
313
  if (!Array.isArray(value.steps) || value.steps.length === 0) {
259
314
  add("$.steps", "missing_steps", "At least one workflow step is required.");
260
315
  }
@@ -278,6 +333,15 @@ value, options = {}) {
278
333
  if (!isNonEmptyString(step.id)) {
279
334
  add(`${path}.id`, "invalid_step_id", "Step id is required.");
280
335
  }
336
+ else if (RESERVED_STEP_IDS.has(step.id)) {
337
+ // Step ids become keys on the ctx.steps object the runtime assembles.
338
+ // "__proto__" / "constructor" / "prototype" collide with JavaScript
339
+ // object internals: assigning ctx.steps["__proto__"] = output hits the
340
+ // prototype setter, so the output is silently dropped from step outputs
341
+ // and unreadable downstream while the run still reports success. Reject
342
+ // at authoring time instead of letting that data loss happen at runtime.
343
+ 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.`);
344
+ }
281
345
  else if (ids.has(step.id)) {
282
346
  add(`${path}.id`, "duplicate_step_id", `Step id '${step.id}' is duplicated.`);
283
347
  }
@@ -320,6 +384,7 @@ value, options = {}) {
320
384
  && step.mode !== "smoke_test") {
321
385
  add(`${path}.mode`, "invalid_step_mode", "Step mode is invalid.");
322
386
  }
387
+ validateOptionalMaxCredits(step.max_credits, `${path}.max_credits`, add);
323
388
  return;
324
389
  }
325
390
  if (step.kind === "branch") {
@@ -390,6 +455,7 @@ value, options = {}) {
390
455
  if (value.bundle_format !== "esm") {
391
456
  add("$.bundle_format", "invalid_bundle_format", "Recipe bundle_format must be 'esm'.");
392
457
  }
458
+ validateOptionalMaxCredits(value.max_credits, "$.max_credits", add);
393
459
  if (value.runtime !== "durable") {
394
460
  add("$.runtime", "invalid_recipe_runtime", "Durable recipe manifests must set runtime to durable.");
395
461
  }
@@ -802,25 +868,52 @@ export async function runPureWorkflowFunction(input) {
802
868
  sandbox.setImmediate = undefined;
803
869
  sandbox.queueMicrotask = undefined;
804
870
  sandbox.WebAssembly = undefined;
805
- const script = new vm.Script(`"use strict";\n`
806
- + `const __oxygen_context = JSON.parse(__oxygen_context_json);\n`
871
+ const script = new vm.Script('"use strict";\n'
872
+ + 'const __oxygen_context = JSON.parse(__oxygen_context_json);\n'
807
873
  + `const __oxygen_fn = (${input.source});\n`
808
- + `__oxygen_fn(__oxygen_context);`);
874
+ + '__oxygen_fn(__oxygen_context);');
809
875
  // Disable dynamic code generation in the sandboxed context. Combined with
810
876
  // the JSON re-parse above, this means every prototype-walk path to
811
877
  // Function — direct, via context.constructor.constructor, or any other
812
878
  // reachable Function instance — throws EvalError when called with source.
813
- const result = script.runInNewContext(sandbox, {
814
- timeout: timeoutMs,
815
- contextCodeGeneration: {
816
- strings: false,
817
- wasm: false,
818
- },
819
- });
820
- const resolved = isPromiseLike(result)
821
- ? await withTimeout(result, timeoutMs)
822
- : result;
823
- return enforceJsonOutput(resolved, maxOutputBytes);
879
+ try {
880
+ const result = script.runInNewContext(sandbox, {
881
+ timeout: timeoutMs,
882
+ contextCodeGeneration: {
883
+ strings: false,
884
+ wasm: false,
885
+ },
886
+ });
887
+ const resolved = isPromiseLike(result)
888
+ ? await withTimeout(result, timeoutMs)
889
+ : result;
890
+ return enforceJsonOutput(resolved, maxOutputBytes);
891
+ }
892
+ catch (error) {
893
+ throw normalizeSandboxThrow(error);
894
+ }
895
+ }
896
+ // A throw from inside the vm sandbox is constructed with the sandbox realm's
897
+ // Error, so `error instanceof Error` is false in the host worker — and
898
+ // serializeOxygenError then drops the message and reports a generic
899
+ // "Workflow run failed.", leaving a failing transform/branch undebuggable.
900
+ // Reconstruct a host-realm Error preserving the user's message/name. Host-realm
901
+ // throws (vm timeout, enforceJsonOutput's workflow_output_too_large) already
902
+ // pass instanceof and are returned unchanged.
903
+ function normalizeSandboxThrow(error) {
904
+ if (error instanceof Error)
905
+ return error;
906
+ if (error && typeof error === "object") {
907
+ const record = error;
908
+ const message = typeof record.message === "string" && record.message
909
+ ? record.message
910
+ : "Workflow function threw.";
911
+ const normalized = new Error(message);
912
+ if (typeof record.name === "string" && record.name)
913
+ normalized.name = record.name;
914
+ return normalized;
915
+ }
916
+ return new Error(typeof error === "string" && error ? error : "Workflow function threw a non-error value.");
824
917
  }
825
918
  export const workflowApplySchema = {
826
919
  $schema: "https://json-schema.org/draft/2020-12/schema",
@@ -1077,6 +1170,19 @@ value, path, add) {
1077
1170
  add(`${path}.cron`, "invalid_cron", error instanceof Error ? error.message : "Cron expression is invalid.");
1078
1171
  }
1079
1172
  }
1173
+ // Validate the timezone here so an unknown zone fails lint with a typed
1174
+ // issue, rather than passing lint and blowing up at apply time (the
1175
+ // scheduler's Intl.DateTimeFormat throws on an unknown zone). null/empty
1176
+ // are allowed — the scheduler falls back to UTC.
1177
+ const { timezone } = value;
1178
+ if (timezone !== undefined && timezone !== null) {
1179
+ if (typeof timezone !== "string") {
1180
+ add(`${path}.timezone`, "invalid_timezone", "Cron trigger timezone must be a string IANA timezone.");
1181
+ }
1182
+ else if (timezone.trim() && !isValidTimeZone(timezone.trim())) {
1183
+ add(`${path}.timezone`, "invalid_timezone", `Unknown timezone '${timezone}'. Use an IANA timezone such as 'America/New_York' or 'UTC'.`);
1184
+ }
1185
+ }
1080
1186
  return;
1081
1187
  }
1082
1188
  if (value.type === "event") {
@@ -1272,11 +1378,9 @@ function normalizeDaysOfWeek(values) {
1272
1378
  }
1273
1379
  return normalized;
1274
1380
  }
1275
- function matchesCronSchedule(schedule, parts) {
1276
- if (!schedule.minutes.has(parts.minute))
1277
- return false;
1278
- if (!schedule.hours.has(parts.hour))
1279
- return false;
1381
+ // Date-only match (month + day-of-month/day-of-week), excluding hour/minute.
1382
+ // Lets nextCronRunAfter skip whole non-matching days in one jump.
1383
+ function matchesCronDate(schedule, parts) {
1280
1384
  if (!schedule.months.has(parts.month))
1281
1385
  return false;
1282
1386
  const dayOfMonthMatches = schedule.daysOfMonth.has(parts.dayOfMonth);
@@ -1286,6 +1390,13 @@ function matchesCronSchedule(schedule, parts) {
1286
1390
  }
1287
1391
  return dayOfMonthMatches && dayOfWeekMatches;
1288
1392
  }
1393
+ function matchesCronSchedule(schedule, parts) {
1394
+ if (!schedule.minutes.has(parts.minute))
1395
+ return false;
1396
+ if (!schedule.hours.has(parts.hour))
1397
+ return false;
1398
+ return matchesCronDate(schedule, parts);
1399
+ }
1289
1400
  function roundUpToNextMinute(value) {
1290
1401
  const result = new Date(value.getTime());
1291
1402
  result.setUTCSeconds(0, 0);
@@ -1309,6 +1420,17 @@ function zonedDateParts(date, timezone) {
1309
1420
  dayOfWeek: weekdayToNumber(parts.get("weekday") ?? ""),
1310
1421
  };
1311
1422
  }
1423
+ function isValidTimeZone(timezone) {
1424
+ try {
1425
+ // Throws RangeError for an unknown IANA zone; same mechanism the scheduler
1426
+ // uses, so lint accepts exactly the zones the runtime can resolve.
1427
+ new Intl.DateTimeFormat("en-US", { timeZone: timezone });
1428
+ return true;
1429
+ }
1430
+ catch {
1431
+ return false;
1432
+ }
1433
+ }
1312
1434
  function getZonedFormatter(timezone) {
1313
1435
  const cached = zonedFormatters.get(timezone);
1314
1436
  if (cached)
@@ -1349,6 +1471,25 @@ function weekdayToNumber(value) {
1349
1471
  function hashWorkflowSource(source) {
1350
1472
  return createHash("sha256").update(source).digest("hex");
1351
1473
  }
1474
+ // Deterministic plan hash (Gap 4): binds an approval decision to the exact
1475
+ // reviewed plan + cost so it cannot be replayed against a changed plan. The
1476
+ // durable mid-run approval gate (recipe-context) hashes its relevant fields
1477
+ // through this; binding the imperative `workflows call` admission path the same
1478
+ // way is a flagged follow-up. Canonical (sorted-key) JSON keeps the hash stable
1479
+ // regardless of key order or undefined fields.
1480
+ export function computeWorkflowPlanHash(input) {
1481
+ return hashWorkflowSource(canonicalJsonStringify(input));
1482
+ }
1483
+ function canonicalJsonStringify(value) {
1484
+ if (value === null || typeof value !== "object")
1485
+ return JSON.stringify(value) ?? "null";
1486
+ if (Array.isArray(value))
1487
+ return `[${value.map(canonicalJsonStringify).join(",")}]`;
1488
+ const entries = Object.entries(value)
1489
+ .filter(([, entryValue]) => entryValue !== undefined)
1490
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
1491
+ return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalJsonStringify(entryValue)}`).join(",")}}`;
1492
+ }
1352
1493
  function workflowJsonReplacer(_key, value) {
1353
1494
  return typeof value === "function" ? Function.prototype.toString.call(value) : value;
1354
1495
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.177.1",
3
+ "version": "1.184.3",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",