@oxygen-agent/cli 1.310.2 → 1.334.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.
- package/README.md +1 -1
- package/dist/command-manifest.js +26 -10
- package/dist/help.js +7 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2077 -134
- package/dist/runtime.js +13 -3
- package/dist/skills.js +192 -32
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/billing.d.ts +52 -29
- package/node_modules/@oxygen/shared/dist/billing.js +77 -55
- package/node_modules/@oxygen/shared/dist/index.d.ts +4 -0
- package/node_modules/@oxygen/shared/dist/index.js +4 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +84 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +82 -0
- package/node_modules/@oxygen/shared/dist/recipes.d.ts +19 -0
- package/node_modules/@oxygen/shared/dist/recipes.js +90 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +61 -15
- package/node_modules/@oxygen/shared/dist/sequences.js +134 -23
- package/node_modules/@oxygen/shared/dist/sql-error.d.ts +25 -0
- package/node_modules/@oxygen/shared/dist/sql-error.js +46 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/version.js +13 -11
- package/node_modules/@oxygen/shared/dist/workflow-mcp-tools.d.ts +4 -0
- package/node_modules/@oxygen/shared/dist/workflow-mcp-tools.js +18 -0
- package/node_modules/@oxygen/shared/dist/workflow-status-change.d.ts +61 -0
- package/node_modules/@oxygen/shared/dist/workflow-status-change.js +124 -0
- package/node_modules/@oxygen/shared/dist/workspace-agents.d.ts +65 -0
- package/node_modules/@oxygen/shared/dist/workspace-agents.js +67 -0
- package/node_modules/@oxygen/workflows/dist/index.js +86 -2
- package/package.json +1 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The audit record behind every workflow enable/disable (OXY-4124).
|
|
3
|
+
*
|
|
4
|
+
* Disabling a workflow is the highest-consequence write in the Action layer — it
|
|
5
|
+
* takes a customer's revenue automation to zero — and it used to record nothing
|
|
6
|
+
* but a bumped `updated_at`: no actor, no source surface, no reason. Answering
|
|
7
|
+
* "who turned this off?" meant a forensic pass across the control DB, the tenant
|
|
8
|
+
* shard, and the request log; the customer saw nothing at all (a design partner's
|
|
9
|
+
* only automation was silently off for 64h).
|
|
10
|
+
*
|
|
11
|
+
* The record lives in `workflow_definitions.metadata.last_status_change` — the
|
|
12
|
+
* column is untyped JSONB, so this needs no migration. It is the *definition*-level
|
|
13
|
+
* peer of the trigger-level auto-pause metadata (`auto_paused_at` /
|
|
14
|
+
* `auto_pause_reason`, written by `workflow-trigger-health.ts`), which already got
|
|
15
|
+
* this right; this module closes the asymmetry for the human disable path.
|
|
16
|
+
*
|
|
17
|
+
* Every surface (web, CLI, MCP) renders the same sentence from
|
|
18
|
+
* `describeWorkflowStatusChange` so "Disabled by X on Y" reads identically
|
|
19
|
+
* wherever a customer meets it.
|
|
20
|
+
*/
|
|
21
|
+
export declare const WORKFLOW_STATUS_CHANGE_METADATA_KEY = "last_status_change";
|
|
22
|
+
/** The surface the transition came through. `auto_pause` is the worker health sweep. */
|
|
23
|
+
export type WorkflowStatusChangeSource = "web" | "cli" | "mcp" | "api" | "auto_pause" | "system";
|
|
24
|
+
export type WorkflowStatusChangeActor = {
|
|
25
|
+
type: "user" | "api_key" | "system";
|
|
26
|
+
/** Control-DB user id. Absent for system/auto-pause actors. */
|
|
27
|
+
user_id?: string;
|
|
28
|
+
/** Set when the call authenticated with an org API key rather than a user session. */
|
|
29
|
+
api_key_id?: string;
|
|
30
|
+
/** What the product shows the customer, e.g. "dana@acme.com" or "Oxygen (auto-pause)". */
|
|
31
|
+
label: string;
|
|
32
|
+
};
|
|
33
|
+
export type WorkflowStatusChange = {
|
|
34
|
+
status: "active" | "disabled";
|
|
35
|
+
/** ISO-8601 UTC. */
|
|
36
|
+
at: string;
|
|
37
|
+
source: WorkflowStatusChangeSource;
|
|
38
|
+
actor: WorkflowStatusChangeActor;
|
|
39
|
+
/** Customer-visible. Auto-pause supplies its own; humans may pass one. */
|
|
40
|
+
reason?: string;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Defensive read: the column is untyped JSONB and every row written before this
|
|
44
|
+
* shipped has no record at all, so a missing/garbage value must read as "unknown",
|
|
45
|
+
* never throw. An unparseable record is indistinguishable from no record.
|
|
46
|
+
*/
|
|
47
|
+
export declare function readWorkflowStatusChange(metadata: Record<string, unknown> | null | undefined): WorkflowStatusChange | null;
|
|
48
|
+
/**
|
|
49
|
+
* Same defensive parse, one level in: for callers holding the already-projected
|
|
50
|
+
* `statusChange` object rather than the raw metadata bag (the CLI reads it off
|
|
51
|
+
* the API's JSON envelope, where it arrives as `unknown`).
|
|
52
|
+
*/
|
|
53
|
+
export declare function parseWorkflowStatusChange(raw: unknown): WorkflowStatusChange | null;
|
|
54
|
+
/** Deterministic UTC stamp ("10 Jul 2026 15:40 UTC") — no locale dependence. */
|
|
55
|
+
export declare function formatWorkflowStatusChangeTimestamp(at: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* The single sentence every surface shows. Web banner, `oxygen workflows
|
|
58
|
+
* list/get`, and the MCP payload all render this string, so the answer to
|
|
59
|
+
* "who turned this off?" is identical wherever the customer looks.
|
|
60
|
+
*/
|
|
61
|
+
export declare function describeWorkflowStatusChange(change: WorkflowStatusChange): string;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The audit record behind every workflow enable/disable (OXY-4124).
|
|
3
|
+
*
|
|
4
|
+
* Disabling a workflow is the highest-consequence write in the Action layer — it
|
|
5
|
+
* takes a customer's revenue automation to zero — and it used to record nothing
|
|
6
|
+
* but a bumped `updated_at`: no actor, no source surface, no reason. Answering
|
|
7
|
+
* "who turned this off?" meant a forensic pass across the control DB, the tenant
|
|
8
|
+
* shard, and the request log; the customer saw nothing at all (a design partner's
|
|
9
|
+
* only automation was silently off for 64h).
|
|
10
|
+
*
|
|
11
|
+
* The record lives in `workflow_definitions.metadata.last_status_change` — the
|
|
12
|
+
* column is untyped JSONB, so this needs no migration. It is the *definition*-level
|
|
13
|
+
* peer of the trigger-level auto-pause metadata (`auto_paused_at` /
|
|
14
|
+
* `auto_pause_reason`, written by `workflow-trigger-health.ts`), which already got
|
|
15
|
+
* this right; this module closes the asymmetry for the human disable path.
|
|
16
|
+
*
|
|
17
|
+
* Every surface (web, CLI, MCP) renders the same sentence from
|
|
18
|
+
* `describeWorkflowStatusChange` so "Disabled by X on Y" reads identically
|
|
19
|
+
* wherever a customer meets it.
|
|
20
|
+
*/
|
|
21
|
+
export const WORKFLOW_STATUS_CHANGE_METADATA_KEY = "last_status_change";
|
|
22
|
+
const SOURCES = new Set([
|
|
23
|
+
"web",
|
|
24
|
+
"cli",
|
|
25
|
+
"mcp",
|
|
26
|
+
"api",
|
|
27
|
+
"auto_pause",
|
|
28
|
+
"system",
|
|
29
|
+
]);
|
|
30
|
+
const ACTOR_TYPES = new Set([
|
|
31
|
+
"user",
|
|
32
|
+
"api_key",
|
|
33
|
+
"system",
|
|
34
|
+
]);
|
|
35
|
+
const SOURCE_LABELS = {
|
|
36
|
+
web: "the web app",
|
|
37
|
+
cli: "the CLI",
|
|
38
|
+
mcp: "MCP",
|
|
39
|
+
api: "the API",
|
|
40
|
+
auto_pause: "auto-pause",
|
|
41
|
+
system: "Oxygen",
|
|
42
|
+
};
|
|
43
|
+
const MONTHS = [
|
|
44
|
+
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
45
|
+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
46
|
+
];
|
|
47
|
+
/**
|
|
48
|
+
* Defensive read: the column is untyped JSONB and every row written before this
|
|
49
|
+
* shipped has no record at all, so a missing/garbage value must read as "unknown",
|
|
50
|
+
* never throw. An unparseable record is indistinguishable from no record.
|
|
51
|
+
*/
|
|
52
|
+
export function readWorkflowStatusChange(metadata) {
|
|
53
|
+
return parseWorkflowStatusChange(metadata?.[WORKFLOW_STATUS_CHANGE_METADATA_KEY]);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Same defensive parse, one level in: for callers holding the already-projected
|
|
57
|
+
* `statusChange` object rather than the raw metadata bag (the CLI reads it off
|
|
58
|
+
* the API's JSON envelope, where it arrives as `unknown`).
|
|
59
|
+
*/
|
|
60
|
+
export function parseWorkflowStatusChange(raw) {
|
|
61
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
62
|
+
return null;
|
|
63
|
+
const record = raw;
|
|
64
|
+
const status = record.status;
|
|
65
|
+
if (status !== "active" && status !== "disabled")
|
|
66
|
+
return null;
|
|
67
|
+
const at = record.at;
|
|
68
|
+
if (typeof at !== "string" || Number.isNaN(Date.parse(at)))
|
|
69
|
+
return null;
|
|
70
|
+
const source = typeof record.source === "string" && SOURCES.has(record.source)
|
|
71
|
+
? record.source
|
|
72
|
+
: "system";
|
|
73
|
+
const actorRaw = record.actor;
|
|
74
|
+
const actorRecord = actorRaw && typeof actorRaw === "object" && !Array.isArray(actorRaw)
|
|
75
|
+
? actorRaw
|
|
76
|
+
: {};
|
|
77
|
+
const actorType = typeof actorRecord.type === "string" && ACTOR_TYPES.has(actorRecord.type)
|
|
78
|
+
? actorRecord.type
|
|
79
|
+
: "system";
|
|
80
|
+
const label = typeof actorRecord.label === "string" && actorRecord.label.trim() !== ""
|
|
81
|
+
? actorRecord.label.trim()
|
|
82
|
+
: "an unknown actor";
|
|
83
|
+
const actor = { type: actorType, label };
|
|
84
|
+
if (typeof actorRecord.user_id === "string" && actorRecord.user_id !== "") {
|
|
85
|
+
actor.user_id = actorRecord.user_id;
|
|
86
|
+
}
|
|
87
|
+
if (typeof actorRecord.api_key_id === "string" && actorRecord.api_key_id !== "") {
|
|
88
|
+
actor.api_key_id = actorRecord.api_key_id;
|
|
89
|
+
}
|
|
90
|
+
const change = { status, at, source, actor };
|
|
91
|
+
if (typeof record.reason === "string" && record.reason.trim() !== "") {
|
|
92
|
+
change.reason = record.reason.trim();
|
|
93
|
+
}
|
|
94
|
+
return change;
|
|
95
|
+
}
|
|
96
|
+
/** Deterministic UTC stamp ("10 Jul 2026 15:40 UTC") — no locale dependence. */
|
|
97
|
+
export function formatWorkflowStatusChangeTimestamp(at) {
|
|
98
|
+
const date = new Date(at);
|
|
99
|
+
if (Number.isNaN(date.getTime()))
|
|
100
|
+
return at;
|
|
101
|
+
const day = String(date.getUTCDate()).padStart(2, "0");
|
|
102
|
+
const month = MONTHS[date.getUTCMonth()];
|
|
103
|
+
const hours = String(date.getUTCHours()).padStart(2, "0");
|
|
104
|
+
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
|
|
105
|
+
return `${day} ${month} ${date.getUTCFullYear()} ${hours}:${minutes} UTC`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The single sentence every surface shows. Web banner, `oxygen workflows
|
|
109
|
+
* list/get`, and the MCP payload all render this string, so the answer to
|
|
110
|
+
* "who turned this off?" is identical wherever the customer looks.
|
|
111
|
+
*/
|
|
112
|
+
export function describeWorkflowStatusChange(change) {
|
|
113
|
+
const verb = change.status === "disabled" ? "Disabled" : "Enabled";
|
|
114
|
+
const when = formatWorkflowStatusChangeTimestamp(change.at);
|
|
115
|
+
// Auto-pause is not a "who" — Oxygen paused it, and the reason carries the why.
|
|
116
|
+
const by = change.source === "auto_pause"
|
|
117
|
+
? "Auto-paused by Oxygen"
|
|
118
|
+
: `${verb} by ${change.actor.label}`;
|
|
119
|
+
const via = change.source === "auto_pause" || change.source === "system"
|
|
120
|
+
? ""
|
|
121
|
+
: ` via ${SOURCE_LABELS[change.source]}`;
|
|
122
|
+
const base = `${by}${via} on ${when}`;
|
|
123
|
+
return change.reason ? `${base} — ${change.reason}` : base;
|
|
124
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** The three implemented Workspace Agent specialists, by stable slug. */
|
|
2
|
+
export type WorkspaceAgentSlug = "inbox-reply-drafts" | "meeting-notetaker" | "knowledge-synthesis";
|
|
3
|
+
/**
|
|
4
|
+
* How the web layer builds a specialist's deep-link. A pure hint on the
|
|
5
|
+
* descriptor (shared can't import the web URL helpers) that agent-registry
|
|
6
|
+
* switches on to produce the actual `https://oxygen-agent.com/...` URL.
|
|
7
|
+
*/
|
|
8
|
+
export type WorkspaceAgentDeepLinkKind = "inbox_reply_agent" | "notetaker" | "knowledge_agent";
|
|
9
|
+
/** A pure, serializable descriptor for one Workspace Agent specialist. */
|
|
10
|
+
export type WorkspaceAgentDescriptor = {
|
|
11
|
+
/** Stable identifier used across CLI/MCP/API. */
|
|
12
|
+
slug: WorkspaceAgentSlug;
|
|
13
|
+
/** Human title for roster rows and widgets. */
|
|
14
|
+
title: string;
|
|
15
|
+
/** One outcome-framed sentence: what the specialist produces for the user. */
|
|
16
|
+
description: string;
|
|
17
|
+
/** The OXYGEN OS primitive this specialist writes into. */
|
|
18
|
+
owningPrimitive: string;
|
|
19
|
+
/** The CLI command that configures this specialist. */
|
|
20
|
+
configCommand: string;
|
|
21
|
+
/** The `/api/cli/*` route that configures this specialist. */
|
|
22
|
+
configApiPath: string;
|
|
23
|
+
/** The MCP tools that read/write this specialist's config. */
|
|
24
|
+
mcpConfigTools: readonly string[];
|
|
25
|
+
/** One plain sentence stating the specialist's write/approval boundary. */
|
|
26
|
+
approvalPosture: string;
|
|
27
|
+
/** How the web layer resolves this specialist's deep-link. */
|
|
28
|
+
deepLinkKind: WorkspaceAgentDeepLinkKind;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* A single run this specialist produced. Field names are aligned with the
|
|
32
|
+
* observability run vocabulary so the cross-primitive runs console can consume
|
|
33
|
+
* these without a parallel shape. Nulls are explicit (never omitted) so every
|
|
34
|
+
* consumer reads the same keys regardless of specialist.
|
|
35
|
+
*/
|
|
36
|
+
export type WorkspaceAgentRunItem = {
|
|
37
|
+
/** The specialist that produced this run. */
|
|
38
|
+
slug: WorkspaceAgentSlug;
|
|
39
|
+
/** The run's stable id (draft id / session id / knowledge-log id). */
|
|
40
|
+
id: string;
|
|
41
|
+
/** The kind of artifact this run produced. */
|
|
42
|
+
kind: string;
|
|
43
|
+
/** The run's current status, in the producing surface's own vocabulary. */
|
|
44
|
+
status: string;
|
|
45
|
+
/** A short human summary of the run, or null when none is available. */
|
|
46
|
+
summary: string | null;
|
|
47
|
+
/** When the run was created (ISO 8601), or null when unknown. */
|
|
48
|
+
createdAt: string | null;
|
|
49
|
+
/** Managed credits this run consumed, or null when it spent none / is unknown. */
|
|
50
|
+
creditsUsed: number | null;
|
|
51
|
+
/** Deep-link to inspect this run, or null when the specialist has no per-run page. */
|
|
52
|
+
webUrl: string | null;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* The roster. Exactly three entries — one per implemented specialist. Order is
|
|
56
|
+
* stable (it drives the default CLI/widget ordering). Adding a fourth entry is a
|
|
57
|
+
* deliberate product decision that requires the specialist to actually ship first.
|
|
58
|
+
*/
|
|
59
|
+
export declare const WORKSPACE_AGENT_ROSTER: readonly WorkspaceAgentDescriptor[];
|
|
60
|
+
/** True when `value` is one of the three implemented specialist slugs. */
|
|
61
|
+
export declare function isWorkspaceAgentSlug(value: unknown): value is WorkspaceAgentSlug;
|
|
62
|
+
/** The descriptor for `slug`, or `undefined` when it is not a known specialist. */
|
|
63
|
+
export declare function getWorkspaceAgentDescriptor(slug: string): WorkspaceAgentDescriptor | undefined;
|
|
64
|
+
/** Every specialist slug, in roster order. */
|
|
65
|
+
export declare function workspaceAgentSlugs(): WorkspaceAgentSlug[];
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The Workspace Agent roster: the single governed lens over OXYGEN's built-in
|
|
2
|
+
// specialist agents. This is deliberately a *computed* registry, not a table and
|
|
3
|
+
// not a runtime — the "Workspace Agent" is ONE Control-layer surface, and each
|
|
4
|
+
// entry here is a product-defined specialist that already ships as an
|
|
5
|
+
// approval-gated, workflow-owned behavior elsewhere in the product. It is NOT a
|
|
6
|
+
// generic build-your-own-agent runtime (ADR 0006), so there is no create/define
|
|
7
|
+
// verb and there are no placeholder rows: the roster lists only specialists that
|
|
8
|
+
// are actually implemented today. CRM-hygiene, deliverability, and any other
|
|
9
|
+
// roadmap specialist stay OUT until they ship — the no-placeholder-surface rule.
|
|
10
|
+
//
|
|
11
|
+
// This module is a dependency-free leaf (pure descriptors + types) so it can live
|
|
12
|
+
// in @oxygen/shared without importing tenant-db: the actual per-specialist wiring
|
|
13
|
+
// (config read/write, run history, deep-links) lives in the web layer's
|
|
14
|
+
// agent-registry, which composes these descriptors with the existing helpers.
|
|
15
|
+
/**
|
|
16
|
+
* The roster. Exactly three entries — one per implemented specialist. Order is
|
|
17
|
+
* stable (it drives the default CLI/widget ordering). Adding a fourth entry is a
|
|
18
|
+
* deliberate product decision that requires the specialist to actually ship first.
|
|
19
|
+
*/
|
|
20
|
+
export const WORKSPACE_AGENT_ROSTER = [
|
|
21
|
+
{
|
|
22
|
+
slug: "inbox-reply-drafts",
|
|
23
|
+
title: "Inbox reply drafts",
|
|
24
|
+
description: "Drafts replies to inbound prospect and customer messages so you can review, edit, and approve them before anything sends.",
|
|
25
|
+
owningPrimitive: "Messages",
|
|
26
|
+
configCommand: "oxygen inbox reply-agent",
|
|
27
|
+
configApiPath: "/api/cli/inbox/reply-agent",
|
|
28
|
+
mcpConfigTools: ["oxygen_inbox_reply_agent_get", "oxygen_inbox_reply_agent_set"],
|
|
29
|
+
approvalPosture: "Drafts replies only — nothing sends without your approval in the reply queue.",
|
|
30
|
+
deepLinkKind: "inbox_reply_agent",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
slug: "meeting-notetaker",
|
|
34
|
+
title: "Meeting notetaker",
|
|
35
|
+
description: "Auto-joins meetings in its configured scope to record, transcribe, and file meeting notes onto the matching CRM records.",
|
|
36
|
+
owningPrimitive: "Records",
|
|
37
|
+
configCommand: "oxygen notetaker setup",
|
|
38
|
+
configApiPath: "/api/cli/notetaker/setup",
|
|
39
|
+
mcpConfigTools: ["oxygen_notetaker_setup", "oxygen_notetaker_status"],
|
|
40
|
+
approvalPosture: "Auto-records only meetings within its configured scope; each session is credit-capped and carries your consent disclosure.",
|
|
41
|
+
deepLinkKind: "notetaker",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
slug: "knowledge-synthesis",
|
|
45
|
+
title: "Knowledge synthesis",
|
|
46
|
+
description: "Distills captured outreach and meeting learnings into workspace knowledge pages on the schedule and daily credit cap you set.",
|
|
47
|
+
owningPrimitive: "Knowledge Graph",
|
|
48
|
+
configCommand: "oxygen knowledge agent",
|
|
49
|
+
configApiPath: "/api/cli/knowledge/agent",
|
|
50
|
+
mcpConfigTools: ["oxygen_knowledge_agent_get", "oxygen_knowledge_agent_set"],
|
|
51
|
+
approvalPosture: "Writes working knowledge pages as logged revisions; canonical and pinned pages still require proposal approval.",
|
|
52
|
+
deepLinkKind: "knowledge_agent",
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
/** True when `value` is one of the three implemented specialist slugs. */
|
|
56
|
+
export function isWorkspaceAgentSlug(value) {
|
|
57
|
+
return (typeof value === "string" &&
|
|
58
|
+
WORKSPACE_AGENT_ROSTER.some((descriptor) => descriptor.slug === value));
|
|
59
|
+
}
|
|
60
|
+
/** The descriptor for `slug`, or `undefined` when it is not a known specialist. */
|
|
61
|
+
export function getWorkspaceAgentDescriptor(slug) {
|
|
62
|
+
return WORKSPACE_AGENT_ROSTER.find((descriptor) => descriptor.slug === slug);
|
|
63
|
+
}
|
|
64
|
+
/** Every specialist slug, in roster order. */
|
|
65
|
+
export function workspaceAgentSlugs() {
|
|
66
|
+
return WORKSPACE_AGENT_ROSTER.map((descriptor) => descriptor.slug);
|
|
67
|
+
}
|
|
@@ -1293,13 +1293,94 @@ function validatePureFunctionSource(source, path, add) {
|
|
|
1293
1293
|
}
|
|
1294
1294
|
}
|
|
1295
1295
|
}
|
|
1296
|
+
// Blank out comment bodies and string/template-literal *text* so the unsafe-token
|
|
1297
|
+
// scan matches only real code identifiers — not a bundle comment ("closed-loop
|
|
1298
|
+
// eval") or a string ("retrieval"), which the raw \b-boundary scan flagged as
|
|
1299
|
+
// false positives. A real eval(...) or new Function(...) — including inside a
|
|
1300
|
+
// `${ ... }` template interpolation, which stays code — is still caught.
|
|
1301
|
+
// @oxygen/workflows ships no AST parser, so this is a small hand-rolled lexer;
|
|
1302
|
+
// the vm sandbox + runtime global guards remain the actual security boundary.
|
|
1303
|
+
function stripCommentsAndStrings(source) {
|
|
1304
|
+
const out = [];
|
|
1305
|
+
const stack = [{ mode: "code", braces: 0 }];
|
|
1306
|
+
let i = 0;
|
|
1307
|
+
const n = source.length;
|
|
1308
|
+
while (i < n) {
|
|
1309
|
+
const top = stack[stack.length - 1];
|
|
1310
|
+
const ch = source[i];
|
|
1311
|
+
const next = i + 1 < n ? source[i + 1] : "";
|
|
1312
|
+
if (top.mode === "template") {
|
|
1313
|
+
if (ch === "\\") {
|
|
1314
|
+
i += 2;
|
|
1315
|
+
continue;
|
|
1316
|
+
}
|
|
1317
|
+
if (ch === "`") {
|
|
1318
|
+
stack.pop();
|
|
1319
|
+
i += 1;
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
if (ch === "$" && next === "{") {
|
|
1323
|
+
stack.push({ mode: "code", braces: 0 });
|
|
1324
|
+
out.push(" ");
|
|
1325
|
+
i += 2;
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
i += 1; // drop template text
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1331
|
+
if (ch === "/" && next === "/") {
|
|
1332
|
+
i += 2;
|
|
1333
|
+
while (i < n && source[i] !== "\n")
|
|
1334
|
+
i += 1;
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
if (ch === "/" && next === "*") {
|
|
1338
|
+
i += 2;
|
|
1339
|
+
while (i < n && !(source[i] === "*" && source[i + 1] === "/"))
|
|
1340
|
+
i += 1;
|
|
1341
|
+
i = Math.min(i + 2, n);
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
if (ch === '"' || ch === "'") {
|
|
1345
|
+
const quote = ch;
|
|
1346
|
+
i += 1;
|
|
1347
|
+
while (i < n && source[i] !== quote)
|
|
1348
|
+
i += source[i] === "\\" ? 2 : 1;
|
|
1349
|
+
i += 1;
|
|
1350
|
+
out.push('""');
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1353
|
+
if (ch === "`") {
|
|
1354
|
+
stack.push({ mode: "template" });
|
|
1355
|
+
i += 1;
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
if (ch === "}" && top.braces === 0 && stack.length > 1) {
|
|
1359
|
+
stack.pop();
|
|
1360
|
+
out.push(" ");
|
|
1361
|
+
i += 1;
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
if (ch === "{")
|
|
1365
|
+
top.braces += 1;
|
|
1366
|
+
else if (ch === "}")
|
|
1367
|
+
top.braces = Math.max(0, top.braces - 1);
|
|
1368
|
+
out.push(ch);
|
|
1369
|
+
i += 1;
|
|
1370
|
+
}
|
|
1371
|
+
return out.join("");
|
|
1372
|
+
}
|
|
1296
1373
|
function validateRecipeBundleSafety(source, path, add) {
|
|
1297
1374
|
if (!source.trim()) {
|
|
1298
1375
|
add(path, "empty_recipe_bundle", "Recipe bundle cannot be empty.");
|
|
1299
1376
|
return;
|
|
1300
1377
|
}
|
|
1378
|
+
// Scan only real code: comments and string literals are blanked first so a
|
|
1379
|
+
// token that merely appears in prose or data (e.g. "eval" in a comment) does
|
|
1380
|
+
// not trip the lint, while genuine calls survive.
|
|
1381
|
+
const scannable = stripCommentsAndStrings(source);
|
|
1301
1382
|
for (const entry of UNSAFE_RECIPE_BUNDLE_PATTERNS) {
|
|
1302
|
-
if (entry.pattern.test(
|
|
1383
|
+
if (entry.pattern.test(scannable)) {
|
|
1303
1384
|
add(path, "unsafe_recipe_bundle", `Durable recipe bundle cannot reference '${entry.token}'. Use ctx.tools.run(), ctx.step(), ctx.now(), or ctx.uuid() instead.`);
|
|
1304
1385
|
return;
|
|
1305
1386
|
}
|
|
@@ -1547,7 +1628,10 @@ function isRecord(value) {
|
|
|
1547
1628
|
function isNonEmptyString(value) {
|
|
1548
1629
|
return typeof value === "string" && value.trim().length > 0;
|
|
1549
1630
|
}
|
|
1550
|
-
//
|
|
1631
|
+
// The canonical list of internal workflow tool ids lives in @oxygen/tools
|
|
1632
|
+
// (OXYGEN_INTERNAL_WORKFLOW_TOOL_IDS). Both the worker's runtime registry and the
|
|
1633
|
+
// web's lint allowlist derive from it, so re-declaring it here would recreate
|
|
1634
|
+
// exactly the drift that broke crm-sync.
|
|
1551
1635
|
// Live `workflows call` requests carry an approved max_credits spend cap in
|
|
1552
1636
|
// the run's metadata (R-E.22(c)). These pure helpers are the single source of
|
|
1553
1637
|
// truth for reading that cap and deciding whether the next paid tool step may
|