@oxygen-agent/cli 1.686.3 → 1.692.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,4 +34,4 @@ oxygen update
34
34
 
35
35
  For product documentation, visit https://oxygen-agent.com/docs. For support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.686.3
37
+ Version: 1.692.5
@@ -46,6 +46,13 @@ const MUTATING_VERBS = new Set([
46
46
  "unbind", "unsubscribe", "update", "upload", "upsert", "use", "warm-send", "warmup",
47
47
  "withdraw", "write",
48
48
  ]);
49
+ // `--approved` usually identifies a paid or externally mutating execution
50
+ // mode, but a small number of destructive local operations are explicitly
51
+ // zero-credit. Keep those exceptions exact so discovery never invents spend.
52
+ const ZERO_CREDIT_APPROVAL_COMMANDS = new Set(["mailboxes delete"]);
53
+ // These commands preview when the execution approval is omitted even though
54
+ // their gate is named `--approved` rather than `--live`.
55
+ const APPROVAL_PREVIEW_COMMANDS = new Set(["mailboxes delete"]);
49
56
  export function buildCommandManifest(program, binaryName) {
50
57
  const commands = [];
51
58
  for (const child of program.commands) {
@@ -172,8 +179,9 @@ function toManifestEntry(command, path) {
172
179
  const flagStrings = options.map((option) => option.flags);
173
180
  const leafVerb = path[path.length - 1] ?? "";
174
181
  const verbPrefix = leafVerb.split("-")[0] ?? leafVerb;
182
+ const commandName = path.join(" ");
175
183
  return {
176
- name: path.join(" "),
184
+ name: commandName,
177
185
  group: path[0] ?? "",
178
186
  description: command.description(),
179
187
  arguments: command.registeredArguments.map((argument) => ({
@@ -186,20 +194,22 @@ function toManifestEntry(command, path) {
186
194
  description: option.description,
187
195
  required: option.mandatory,
188
196
  })),
189
- spends_credits: flagStrings.some((flags) => flags.includes("--approved") ||
190
- flags.includes("--max-credits") ||
191
- // Copilot's explicit session inference-budget approval flag.
192
- flags.includes("--budget-credits")) ||
193
- // `copilot send` bills managed inference (5x actual model cost) inside the
194
- // session budget approved at `copilot start`, and `copilot approve`
195
- // dispatches the gated paid/external capability and requeues the paused
196
- // turn (waking the worker for more 5x-billed inference) — no per-command
197
- // cap flag exists for either, so the flag heuristic alone would misreport
198
- // them as free.
199
- (path[0] === "copilot" &&
200
- (leafVerb === "send" || leafVerb === "approve")),
197
+ spends_credits: !ZERO_CREDIT_APPROVAL_COMMANDS.has(commandName) &&
198
+ (flagStrings.some((flags) => flags.includes("--approved") ||
199
+ flags.includes("--max-credits") ||
200
+ // Copilot's explicit session inference-budget approval flag.
201
+ flags.includes("--budget-credits")) ||
202
+ // `copilot send` bills managed inference (5x actual model cost) inside the
203
+ // session budget approved at `copilot start`, and `copilot approve`
204
+ // dispatches the gated paid/external capability and requeues the paused
205
+ // turn (waking the worker for more 5x-billed inference) — no per-command
206
+ // cap flag exists for either, so the flag heuristic alone would misreport
207
+ // them as free.
208
+ (path[0] === "copilot" &&
209
+ (leafVerb === "send" || leafVerb === "approve"))),
201
210
  mutates: MUTATING_VERBS.has(leafVerb) || MUTATING_VERBS.has(verbPrefix),
202
- preview_by_default: flagStrings.some((flags) => flags.includes("--live")),
211
+ preview_by_default: flagStrings.some((flags) => flags.includes("--live")) ||
212
+ APPROVAL_PREVIEW_COMMANDS.has(commandName),
203
213
  json_supported: flagStrings.some((flags) => flags.includes("--json")),
204
214
  hidden: isHiddenCommand(command),
205
215
  };
package/dist/index.js CHANGED
@@ -1169,6 +1169,15 @@ function buildCrmPhotosBody(options) {
1169
1169
  ...(limit ? { limit: Number.parseInt(limit, 10) } : {}),
1170
1170
  };
1171
1171
  }
1172
+ function buildCrmFieldsBody(options) {
1173
+ const object = readOption(options.object);
1174
+ const limit = readOption(options.limit);
1175
+ return {
1176
+ mode: resolveLiveDryRunMode(options),
1177
+ ...(object ? { objects: object } : {}),
1178
+ ...(limit ? { limit: Number.parseInt(limit, 10) } : {}),
1179
+ };
1180
+ }
1172
1181
  function buildCrmRollupBody(options) {
1173
1182
  const object = readOption(options.object);
1174
1183
  const row = readOption(options.row);
@@ -3758,6 +3767,19 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3758
3767
  method: "POST",
3759
3768
  body: buildCrmPhotosBody(options),
3760
3769
  }));
3770
+ }))
3771
+ .addCommand(new Command("fields")
3772
+ .description("Fill the stored CRM fields (Industry, Employees, HQ, and their siblings) from enrichment data this workspace already holds. These are ordinary text/number fields you can edit \u2014 the fill only ever writes an EMPTY cell, so a value you typed is never overwritten. Free: no provider calls, no credits. Run this once on a workspace whose records were enriched before those fields became editable. Defaults to dry-run.")
3773
+ .option("--object <object>", "Limit the fill to one CRM object (companies, people, deals). Defaults to all.")
3774
+ .option("--limit <n>", "Maximum rows to scan per object. Defaults to 2000.")
3775
+ .option("--dry-run", "Preview how many cells would be filled, without writing.")
3776
+ .option("--live", "Write the extracted values onto the records.")
3777
+ .option("--json", "Print a JSON envelope.")
3778
+ .action(async (options) => {
3779
+ await handleAsyncAction("crm fields", options, () => requestOxygen("/api/cli/crm/fields", {
3780
+ method: "POST",
3781
+ body: buildCrmFieldsBody(options),
3782
+ }));
3761
3783
  }))
3762
3784
  .addCommand(new Command("rollup")
3763
3785
  .description("Recompute the Open Deal column on companies (and the copy stamped on their people) from this workspace's deals. It normally converges on every deal write; run this to repair after a bulk import, or on a workspace whose deals predate the column. Free: no provider calls, no credits. Defaults to dry-run.")
@@ -11789,9 +11811,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11789
11811
  });
11790
11812
  }))
11791
11813
  .addCommand(new Command("start")
11792
- .description("Preview or start a sequence. Preview reports exact per-kind scope, sender/mailbox capacity, CRM connection readiness, and safety caps. Live start requires --approved; --max-credits is optional. Email/WhatsApp/CRM-task journeys additionally require --max-live-sends because their external actions cost 0 Oxygen credits. Use --dry-run to simulate without any send, provider campaign, CRM task, or credits. The first live start also switches on the standing reply → CRM automation for the workspace.")
11814
+ .description("Preview or start a sequence. Without --approved, preview scans pending LinkedIn copy and returns exact rendered recipient samples, character counts/limits, copy blockers, per-kind scope, sender/mailbox capacity, CRM readiness, and safety caps. Live start requires --approved; --max-credits is optional. Email/WhatsApp/CRM-task journeys additionally require --max-live-sends because their external actions cost 0 Oxygen credits. Use --dry-run to simulate without any send, provider campaign, CRM task, or credits. The first live start also switches on the standing reply → CRM automation for the workspace.")
11793
11815
  .argument("<sequence>", "Sequence id or slug.")
11794
- .option("--approved", "Approve and activate live. Without this flag, returns a preview only.")
11816
+ .option("--approved", "Approve and activate live. Without this flag, returns a preview only, including copy_preview rendered samples and blockers.")
11795
11817
  .option("--max-credits <n>", "Optional credit ceiling for the LinkedIn track (omit for an unbounded run).")
11796
11818
  .option("--max-live-sends <n>", "Live external-action ceiling (positive integer). Required for email, WhatsApp, or crm_task steps.")
11797
11819
  .option("--dry-run", "Activate in dry-run mode: advance every step with simulated actions, no sends, CRM writes, provider calls, or credits.")
@@ -12682,6 +12704,35 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12682
12704
  .option("--json", "Print a JSON envelope.")
12683
12705
  .action(async (mailbox, options) => {
12684
12706
  await handleAsyncAction("mailboxes get", options, () => requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}`));
12707
+ }))
12708
+ .addCommand(new Command("delete")
12709
+ .description("Preview or approve removal of exact mailboxes from Oxygen at 0 credits. Deletion immediately removes them from sending but never deletes the underlying Google Workspace or Microsoft 365 accounts. It preserves conversation/message/delivery history and stops the 1,000-credit mailbox commitment for future renewals; the current period is not refunded. Listed active/paused sequences keep their status but lose these senders and are not automatically paused. Managed mailboxes and live warmup/monitoring add-ons fail closed with their exact address-scoped teardown steps.")
12710
+ .requiredOption("--mailboxes <list>", "Comma-separated mailbox ids or addresses (maximum 500).")
12711
+ .option("--approved", "Execute the fresh preview. Requires --plan-hash and --confirmation.")
12712
+ .option("--plan-hash <hash>", "Fresh preview plan_hash.")
12713
+ .option("--confirmation <phrase>", "Exact confirmation_phrase returned by the fresh preview.")
12714
+ .option("--json", "Print a JSON envelope.")
12715
+ .action(async (options) => {
12716
+ await handleAsyncAction("mailboxes delete", options, () => {
12717
+ const mailboxes = readCsvOption(options.mailboxes);
12718
+ if (mailboxes.length === 0) {
12719
+ throw new Error("--mailboxes must contain at least one mailbox id or address.");
12720
+ }
12721
+ const planHash = readOption(options.planHash);
12722
+ const confirmation = readOption(options.confirmation);
12723
+ if (options.approved === true && (!planHash || !confirmation)) {
12724
+ throw new Error("--approved requires --plan-hash and --confirmation from a fresh deletion preview.");
12725
+ }
12726
+ return requestOxygen("/api/cli/mailboxes/delete", {
12727
+ method: "POST",
12728
+ body: {
12729
+ mailboxes,
12730
+ ...(options.approved === true ? { approved: true } : {}),
12731
+ ...(planHash ? { plan_hash: planHash } : {}),
12732
+ ...(confirmation ? { confirmation } : {}),
12733
+ },
12734
+ });
12735
+ });
12685
12736
  }))
12686
12737
  .addCommand(new Command("health")
12687
12738
  .description("Fleet email-health: the sending pool rolled up by external deliverability reputation (healthy/degraded/critical/unknown), per-mailbox scores, connected health providers, and DIRECTIONAL recommendations. Pure read — 0 credits; never pauses a mailbox.")
@@ -12724,7 +12775,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12724
12775
  "Credential file contract:",
12725
12776
  ' JSON: {"mailboxes":[{"email_address":"ada@send-acme.com","provider":"google","app_password":"<Google mailbox app password>"}]}',
12726
12777
  " Only Google app passwords enter the encrypted seven-day transfer vault. Microsoft rows remain identity-only. Generic SMTP passwords and OAuth/MFA/delegation secrets are rejected.",
12727
- " Validation: add --validate-only to parse the complete real file and return safe aggregate counts without authentication, a network request, or a workspace write. Any parse, shape, provider, platform, tenant, secret-policy, or duplicate-conflict error rejects the entire file before the first mailbox write and names mailboxes[index]; validation-only never writes. A later import infrastructure failure may interrupt the upsert; re-run the same file because import is idempotent by address.",
12778
+ " Validation: add --validate-only to parse the complete real file and return safe aggregate counts plus the provider-specific exact-account OAuth review plan without authentication, a network request, or a workspace write. The plan groups only supplied addresses into reviews of at most 10; it never discovers a domain, and the later online preview may skip existing grants. Any parse, shape, provider, platform, tenant, secret-policy, or duplicate-conflict error rejects the entire file before the first mailbox write and names mailboxes[index]; validation-only never writes. A later import infrastructure failure may interrupt the upsert; re-run the same file because import is idempotent by address.",
12728
12779
  " Docs: https://oxygen-agent.com/docs/providers/mailbox-compatibility",
12729
12780
  " Skill: oxygen-email-infra (`oxygen skills install --skill oxygen-email-infra`).",
12730
12781
  "",
@@ -512,6 +512,12 @@ function normalizeIntent(query) {
512
512
  return query.toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
513
513
  }
514
514
  function explicitCapabilityIntent(query) {
515
+ if (isMailboxDeleteIntent(query)) {
516
+ return ROUTE_BY_ID.get("sending-infrastructure") ?? null;
517
+ }
518
+ if (isMailboxOnboardingIntent(query)) {
519
+ return ROUTE_BY_ID.get("sending-infrastructure") ?? null;
520
+ }
515
521
  if (isHostedWorkflowIntent(query))
516
522
  return ROUTE_BY_PRIMITIVE.get("workflows") ?? null;
517
523
  if (isOwnedPostCommentIntent(query))
@@ -603,6 +609,36 @@ function highestScoringRoute(query) {
603
609
  return best?.card ?? null;
604
610
  }
605
611
  function recommendationsFor(card, query) {
612
+ if (card.id === "sending-infrastructure" && isMailboxDeleteIntent(query)) {
613
+ return {
614
+ tools: [
615
+ "oxygen_mailboxes_delete",
616
+ "oxygen_mailboxes_compatibility",
617
+ "oxygen_mailboxes_list",
618
+ ],
619
+ commands: [
620
+ "mailboxes delete",
621
+ "mailboxes compatibility",
622
+ "mailboxes list",
623
+ ],
624
+ };
625
+ }
626
+ if (card.id === "sending-infrastructure" && isMailboxOnboardingIntent(query)) {
627
+ return {
628
+ tools: [
629
+ "oxygen_mailboxes_compatibility",
630
+ "oxygen_mailboxes_import",
631
+ "oxygen_mailboxes_connect_oauth",
632
+ "oxygen_mailboxes_oauth_health",
633
+ ],
634
+ commands: [
635
+ "mailboxes compatibility",
636
+ "mailboxes import",
637
+ "mailboxes connect-oauth",
638
+ "mailboxes oauth-health",
639
+ ],
640
+ };
641
+ }
606
642
  if (card.primitive === "posts" && isOwnedPostCommentIntent(query)) {
607
643
  return {
608
644
  tools: ["oxygen_publishing_comments", "oxygen_publishing_analytics"],
@@ -782,6 +818,18 @@ function recommendationsFor(card, query) {
782
818
  }
783
819
  return { tools: [...card.gatewayTools], commands: [...card.gatewayCommands] };
784
820
  }
821
+ function isMailboxOnboardingIntent(query) {
822
+ const operation = /\b(import|migrat\w*|onboard|register|upload|bring|connect existing|add existing)\b/.test(query);
823
+ const mailboxScope = /\b(mailbox(?:es)?|inbox(?:es)?|sender accounts?|email accounts?|sending pool)\b/.test(query);
824
+ return operation && mailboxScope;
825
+ }
826
+ function isMailboxDeleteIntent(query) {
827
+ const mailboxScope = /\b(mailbox(?:es)?|inbox(?:es)?|sender accounts?|email accounts?)\b/.test(query);
828
+ const removal = /\b(delete|remove)\b/.test(query)
829
+ || /\bdisconnect\b.{0,24}\b(mailbox(?:es)?|inbox(?:es)?|sender accounts?|email accounts?)\b/.test(query);
830
+ const scopedDetach = /\b(remove|disconnect)\b.{0,48}\b(mailbox(?:es)?|inbox(?:es)?|sender accounts?|email accounts?)\b.{0,24}\b(?:from|in|on)\b.{0,24}\b(sequence|campaign|cadence|sender profile)\b/.test(query);
831
+ return mailboxScope && removal && !scopedDetach;
832
+ }
785
833
  function isNetNewLinkedInInitiation(query) {
786
834
  const mentionsLinkedIn = /\blinkedin\b|\bdm\b/.test(query);
787
835
  const startsConversation = /\b(send|message|dm|contact|reach out|initiate|start)\b/.test(query)
@@ -0,0 +1,108 @@
1
+ type FeatureGateEnv = Record<string, string | undefined>;
2
+ /** Where the effective value came from. Precedence is env → remote → default. */
3
+ export type FeatureGateSource = "env_override" | "remote" | "safe_default";
4
+ export type FeatureGateDefinition = {
5
+ /** PostHog feature-flag key. Lowercase kebab-case by convention. */
6
+ key: string;
7
+ /**
8
+ * The value used whenever the provider has no answer. Pick the value that is
9
+ * safe to serve indefinitely, not the value you expect: this is what ships
10
+ * during a vendor outage, and on Vercel it is also what every cold
11
+ * invocation serves until flag definitions finish loading.
12
+ */
13
+ safeDefault: boolean;
14
+ /**
15
+ * Optional break-glass env var. Set it to force the gate on/off without
16
+ * touching the flag service — the only lever that still works when the flag
17
+ * service is the thing that is broken. Must not name a NEVER_FLAGGABLE key.
18
+ */
19
+ envOverride?: string;
20
+ };
21
+ export type FeatureGateResolution = {
22
+ enabled: boolean;
23
+ source: FeatureGateSource;
24
+ };
25
+ /**
26
+ * Env gates that must NEVER become remotely-flippable feature flags, and stay
27
+ * read directly from `process.env` (Doppler) at their call sites.
28
+ *
29
+ * Two disqualifying properties, each fatal on its own:
30
+ *
31
+ * (a) CIRCULARITY — a kill switch for a telemetry vendor cannot be resolved
32
+ * through a telemetry vendor. You reach for it precisely when that path is
33
+ * misbehaving, so a switch that needs the vendor reachable to be read is
34
+ * not a kill switch at all.
35
+ * (b) AUTHORIZATION — spend enforcement and external-write enablement. A
36
+ * remotely-flippable value means a vendor outage, a mis-click in someone
37
+ * else's UI, or a compromised vendor account can uncap credit spend or
38
+ * enable public side effects. Hard rules keep those in Doppler, where the
39
+ * change is an auditable secret change.
40
+ *
41
+ * Per-key rationale (call sites read 2026-08, verified against this base):
42
+ *
43
+ * - OXYGEN_TELEMETRY_ENABLED — (a). The master telemetry kill switch, read by
44
+ * `apps/web/src/lib/axiom-log-shipper.ts` and `apps/worker/src/telemetry.ts` /
45
+ * `log-shipper.ts`. Resolving it remotely makes turning telemetry OFF depend
46
+ * on a telemetry vendor being up.
47
+ * - OXYGEN_LOG_SHIPPING_ENABLED — (a). Same circularity, narrower blast radius
48
+ * (log shipping only; `axiom-log-shipper.ts`, `apps/worker/src/log-shipper.ts`).
49
+ * - OXYGEN_LLM_TRACING_ENABLED — (a) + (b). `packages/shared/src/langfuse.ts`
50
+ * fails CLOSED on it because it authorizes egress of full prompt/completion
51
+ * payloads to Langfuse (ADR 0014). Where prompt payloads may travel is a data
52
+ * boundary, not a rollout. It also lives in this transport-free package, so
53
+ * remote resolution would drag a flag client into the published CLI.
54
+ * - OXYGEN_TRIGGER_DEFAULT_CAPS — (b). Selects log-only vs enforce for the
55
+ * plan-tier default per-delivery ceiling on autonomous triggers
56
+ * (`apps/worker/src/workflow-spend-cap.ts`). Remote control over spend
57
+ * ENFORCEMENT means an outage can uncap autonomous credit spend everywhere.
58
+ * - OXYGEN_BYOK_DAILY_CAPS — (b). Same, for BYOK per-provider daily caps
59
+ * (`packages/providers/src/provider-fetch.ts`) — external money on the
60
+ * customer's own keys, and likewise in a package the CLI can reach.
61
+ * - OXYGEN_PUBLISHING_ENABLED — (b). Fail-closed production switch for posting
62
+ * to real provider accounts (`apps/web/src/app/(app)/(dashboard)/publishing/
63
+ * data.ts`). Wrong "on" = public posts from customer accounts.
64
+ * - OXYGEN_AGENTS_ENABLED — (b). Authorizes autonomous Agent run execution —
65
+ * tool authority AND credit spend (`apps/web/src/lib/agent-runtime.ts`,
66
+ * `apps/worker/src/agent-cycle.ts`). Same class as publishing.
67
+ *
68
+ * The list is a guard, not a to-do: none of these is migrated, and
69
+ * `findFeatureGateRegistryViolations` fails the build's tests if one ever is.
70
+ */
71
+ export declare const NEVER_FLAGGABLE: readonly string[];
72
+ /**
73
+ * Every server-side gate resolved through PostHog.
74
+ *
75
+ * Intentionally empty: introducing the mechanism is not a reason to move
76
+ * existing switches onto it. The seven env gates above stay in Doppler, and a
77
+ * new entry belongs here only when its worst wrong value is a cosmetic or
78
+ * reversible product difference — never spend, external writes, or telemetry
79
+ * egress.
80
+ */
81
+ export declare const FEATURE_GATES: readonly FeatureGateDefinition[];
82
+ /**
83
+ * Tri-state env parse: `true` / `false` / `null` for "unset or unparseable".
84
+ *
85
+ * The null case is load-bearing — an override that is merely absent has to fall
86
+ * through to the remote value, while an override explicitly set to `false` has
87
+ * to win over it. Collapsing the two into a boolean would make every gate
88
+ * without an override permanently forced off.
89
+ */
90
+ export declare function parseFeatureGateBoolean(value: string | undefined): boolean | null;
91
+ /**
92
+ * Resolve one gate. Pure: the caller supplies the provider's answer (or
93
+ * `undefined` when it had none) and the env to read.
94
+ *
95
+ * Env override wins over the remote value on purpose. It is the break-glass for
96
+ * "the flag service is serving the wrong answer", which is worthless if the
97
+ * flag service outranks it.
98
+ */
99
+ export declare function resolveFeatureGate(definition: FeatureGateDefinition, remote?: boolean | undefined, env?: FeatureGateEnv): FeatureGateResolution;
100
+ export declare function isNeverFlaggable(name: string): boolean;
101
+ /**
102
+ * Registry lint, asserted by tests rather than thrown at import time (a gate
103
+ * registry must never be able to crash a boot). Returns one human-readable
104
+ * violation per problem; an empty array means the registry is clean.
105
+ */
106
+ export declare function findFeatureGateRegistryViolations(gates?: readonly FeatureGateDefinition[]): string[];
107
+ export declare function findFeatureGate(key: string): FeatureGateDefinition | undefined;
108
+ export {};
@@ -0,0 +1,153 @@
1
+ // Server-side feature gates (ADR 0021: PostHog owns server-side feature flags).
2
+ //
3
+ // This module is the CONTRACT ONLY — gate definitions, the unflaggable list, and
4
+ // the pure resolver. It is deliberately TRANSPORT-FREE: no `posthog-node`, no
5
+ // fetch, no client, no I/O of any kind. `packages/cli` is published to npm as
6
+ // `@oxygen-agent/cli` and depends on `@oxygen/shared` unbundled, so every import
7
+ // added here becomes a runtime dependency of every customer's CLI install. The
8
+ // evaluation transport lives in `apps/web/src/lib/feature-gate-provider.ts`,
9
+ // which nothing outside the web app imports.
10
+ //
11
+ // The resolver's one invariant: an absent provider answer is not an answer. A
12
+ // flag-service outage, a quota-limited project, a cold lambda whose definitions
13
+ // have not loaded yet, and a flag that simply does not exist all arrive here as
14
+ // `undefined`, and all of them resolve to the gate's `safeDefault`. A gate never
15
+ // flips because a vendor is unreachable.
16
+ /**
17
+ * Env gates that must NEVER become remotely-flippable feature flags, and stay
18
+ * read directly from `process.env` (Doppler) at their call sites.
19
+ *
20
+ * Two disqualifying properties, each fatal on its own:
21
+ *
22
+ * (a) CIRCULARITY — a kill switch for a telemetry vendor cannot be resolved
23
+ * through a telemetry vendor. You reach for it precisely when that path is
24
+ * misbehaving, so a switch that needs the vendor reachable to be read is
25
+ * not a kill switch at all.
26
+ * (b) AUTHORIZATION — spend enforcement and external-write enablement. A
27
+ * remotely-flippable value means a vendor outage, a mis-click in someone
28
+ * else's UI, or a compromised vendor account can uncap credit spend or
29
+ * enable public side effects. Hard rules keep those in Doppler, where the
30
+ * change is an auditable secret change.
31
+ *
32
+ * Per-key rationale (call sites read 2026-08, verified against this base):
33
+ *
34
+ * - OXYGEN_TELEMETRY_ENABLED — (a). The master telemetry kill switch, read by
35
+ * `apps/web/src/lib/axiom-log-shipper.ts` and `apps/worker/src/telemetry.ts` /
36
+ * `log-shipper.ts`. Resolving it remotely makes turning telemetry OFF depend
37
+ * on a telemetry vendor being up.
38
+ * - OXYGEN_LOG_SHIPPING_ENABLED — (a). Same circularity, narrower blast radius
39
+ * (log shipping only; `axiom-log-shipper.ts`, `apps/worker/src/log-shipper.ts`).
40
+ * - OXYGEN_LLM_TRACING_ENABLED — (a) + (b). `packages/shared/src/langfuse.ts`
41
+ * fails CLOSED on it because it authorizes egress of full prompt/completion
42
+ * payloads to Langfuse (ADR 0014). Where prompt payloads may travel is a data
43
+ * boundary, not a rollout. It also lives in this transport-free package, so
44
+ * remote resolution would drag a flag client into the published CLI.
45
+ * - OXYGEN_TRIGGER_DEFAULT_CAPS — (b). Selects log-only vs enforce for the
46
+ * plan-tier default per-delivery ceiling on autonomous triggers
47
+ * (`apps/worker/src/workflow-spend-cap.ts`). Remote control over spend
48
+ * ENFORCEMENT means an outage can uncap autonomous credit spend everywhere.
49
+ * - OXYGEN_BYOK_DAILY_CAPS — (b). Same, for BYOK per-provider daily caps
50
+ * (`packages/providers/src/provider-fetch.ts`) — external money on the
51
+ * customer's own keys, and likewise in a package the CLI can reach.
52
+ * - OXYGEN_PUBLISHING_ENABLED — (b). Fail-closed production switch for posting
53
+ * to real provider accounts (`apps/web/src/app/(app)/(dashboard)/publishing/
54
+ * data.ts`). Wrong "on" = public posts from customer accounts.
55
+ * - OXYGEN_AGENTS_ENABLED — (b). Authorizes autonomous Agent run execution —
56
+ * tool authority AND credit spend (`apps/web/src/lib/agent-runtime.ts`,
57
+ * `apps/worker/src/agent-cycle.ts`). Same class as publishing.
58
+ *
59
+ * The list is a guard, not a to-do: none of these is migrated, and
60
+ * `findFeatureGateRegistryViolations` fails the build's tests if one ever is.
61
+ */
62
+ export const NEVER_FLAGGABLE = [
63
+ "OXYGEN_PUBLISHING_ENABLED",
64
+ "OXYGEN_AGENTS_ENABLED",
65
+ "OXYGEN_TELEMETRY_ENABLED",
66
+ "OXYGEN_LOG_SHIPPING_ENABLED",
67
+ "OXYGEN_LLM_TRACING_ENABLED",
68
+ "OXYGEN_TRIGGER_DEFAULT_CAPS",
69
+ "OXYGEN_BYOK_DAILY_CAPS",
70
+ ];
71
+ /**
72
+ * Every server-side gate resolved through PostHog.
73
+ *
74
+ * Intentionally empty: introducing the mechanism is not a reason to move
75
+ * existing switches onto it. The seven env gates above stay in Doppler, and a
76
+ * new entry belongs here only when its worst wrong value is a cosmetic or
77
+ * reversible product difference — never spend, external writes, or telemetry
78
+ * egress.
79
+ */
80
+ export const FEATURE_GATES = [];
81
+ /**
82
+ * Tri-state env parse: `true` / `false` / `null` for "unset or unparseable".
83
+ *
84
+ * The null case is load-bearing — an override that is merely absent has to fall
85
+ * through to the remote value, while an override explicitly set to `false` has
86
+ * to win over it. Collapsing the two into a boolean would make every gate
87
+ * without an override permanently forced off.
88
+ */
89
+ export function parseFeatureGateBoolean(value) {
90
+ const normalized = value?.trim().toLowerCase();
91
+ if (!normalized)
92
+ return null;
93
+ if (normalized === "true" || normalized === "1" || normalized === "yes")
94
+ return true;
95
+ if (normalized === "false" || normalized === "0" || normalized === "no")
96
+ return false;
97
+ return null;
98
+ }
99
+ /**
100
+ * Resolve one gate. Pure: the caller supplies the provider's answer (or
101
+ * `undefined` when it had none) and the env to read.
102
+ *
103
+ * Env override wins over the remote value on purpose. It is the break-glass for
104
+ * "the flag service is serving the wrong answer", which is worthless if the
105
+ * flag service outranks it.
106
+ */
107
+ export function resolveFeatureGate(definition, remote, env = process.env) {
108
+ const override = definition.envOverride
109
+ ? parseFeatureGateBoolean(env[definition.envOverride])
110
+ : null;
111
+ if (override !== null)
112
+ return { enabled: override, source: "env_override" };
113
+ if (typeof remote === "boolean")
114
+ return { enabled: remote, source: "remote" };
115
+ return { enabled: definition.safeDefault, source: "safe_default" };
116
+ }
117
+ // Compare env-var names and flag keys in one space, so `oxygen-agents-enabled`
118
+ // as a flag key is caught by a list written in SCREAMING_SNAKE env style.
119
+ function normalizeGateName(name) {
120
+ return name.trim().toUpperCase().replace(/-/g, "_");
121
+ }
122
+ export function isNeverFlaggable(name) {
123
+ const normalized = normalizeGateName(name);
124
+ return NEVER_FLAGGABLE.some((entry) => normalizeGateName(entry) === normalized);
125
+ }
126
+ /**
127
+ * Registry lint, asserted by tests rather than thrown at import time (a gate
128
+ * registry must never be able to crash a boot). Returns one human-readable
129
+ * violation per problem; an empty array means the registry is clean.
130
+ */
131
+ export function findFeatureGateRegistryViolations(gates = FEATURE_GATES) {
132
+ const violations = [];
133
+ const seen = new Set();
134
+ for (const gate of gates) {
135
+ if (!gate.key.trim()) {
136
+ violations.push("A feature gate has an empty key.");
137
+ continue;
138
+ }
139
+ if (seen.has(gate.key))
140
+ violations.push(`Duplicate feature gate key: ${gate.key}`);
141
+ seen.add(gate.key);
142
+ if (isNeverFlaggable(gate.key)) {
143
+ violations.push(`Feature gate "${gate.key}" names a NEVER_FLAGGABLE switch — it must stay an env gate.`);
144
+ }
145
+ if (gate.envOverride && isNeverFlaggable(gate.envOverride)) {
146
+ violations.push(`Feature gate "${gate.key}" uses NEVER_FLAGGABLE env var "${gate.envOverride}" as its override.`);
147
+ }
148
+ }
149
+ return violations;
150
+ }
151
+ export function findFeatureGate(key) {
152
+ return FEATURE_GATES.find((gate) => gate.key === key);
153
+ }
@@ -22,6 +22,7 @@ export * from "./directory.js";
22
22
  export * from "./email-tracking-token.js";
23
23
  export * from "./email-unsubscribe-token.js";
24
24
  export * from "./error-redaction.js";
25
+ export * from "./feature-gates.js";
25
26
  export * from "./hosted-ai.js";
26
27
  export * from "./identifiers.js";
27
28
  export * from "./knowledge-constants.js";
@@ -22,6 +22,7 @@ export * from "./directory.js";
22
22
  export * from "./email-tracking-token.js";
23
23
  export * from "./email-unsubscribe-token.js";
24
24
  export * from "./error-redaction.js";
25
+ export * from "./feature-gates.js";
25
26
  export * from "./hosted-ai.js";
26
27
  export * from "./identifiers.js";
27
28
  export * from "./knowledge-constants.js";
@@ -21,6 +21,7 @@ export type MailboxImportValidationSummary = {
21
21
  infrastructure_platforms: Record<string, number>;
22
22
  credential_rows: number;
23
23
  identity_only_rows: number;
24
+ oauth_review_plan: MailboxOAuthReviewPlan;
24
25
  limits: {
25
26
  max_rows: number;
26
27
  max_file_bytes: number;
@@ -31,8 +32,26 @@ export type MailboxImportValidationSummary = {
31
32
  credits_used: 0;
32
33
  next_action: string;
33
34
  };
35
+ export type MailboxOAuthProviderReviewPlan = {
36
+ mailboxes: number;
37
+ batches: number;
38
+ batch_sizes: number[];
39
+ };
40
+ export type MailboxOAuthReviewPlan = {
41
+ authorization: "exact_account_oauth";
42
+ scope: "validated_addresses";
43
+ directory_discovery: false;
44
+ existing_grants_checked: false;
45
+ batch_size: number;
46
+ total_batches: number;
47
+ providers: {
48
+ google: MailboxOAuthProviderReviewPlan;
49
+ microsoft: MailboxOAuthProviderReviewPlan;
50
+ };
51
+ };
34
52
  export declare const MAILBOX_IMPORT_FILE_MAX_BYTES: number;
35
53
  export declare const MAILBOX_IMPORT_ROW_LIMIT = 500;
54
+ export declare const MAILBOX_OAUTH_REVIEW_BATCH_SIZE = 10;
36
55
  /**
37
56
  * Infer the bounded mailbox-import format without node:path so browser and CLI
38
57
  * preflight use the same extension contract.
@@ -55,4 +74,11 @@ export declare function summarizeMailboxImportValidation(mailboxes: readonly Nor
55
74
  source: MailboxImportValidationSource;
56
75
  sourceProvider: string | null;
57
76
  }): MailboxImportValidationSummary;
77
+ /**
78
+ * Build the deterministic, provider-specific review queue for exact-account
79
+ * OAuth. This is safe during local file validation: it uses only normalized
80
+ * provider counts, performs no directory discovery, and does not claim to know
81
+ * whether a workspace already holds a usable grant for an address.
82
+ */
83
+ export declare function planMailboxOAuthReviews(mailboxes: readonly Pick<NormalizedMailboxImportRow, "provider">[]): MailboxOAuthReviewPlan;
58
84
  export declare function normalizeMailboxImportVendor(raw: string | null | undefined, from: string | null | undefined): string | null;
@@ -1,6 +1,7 @@
1
1
  import { OxygenError } from "./cli-result.js";
2
2
  export const MAILBOX_IMPORT_FILE_MAX_BYTES = 5 * 1024 * 1024;
3
3
  export const MAILBOX_IMPORT_ROW_LIMIT = 500;
4
+ export const MAILBOX_OAUTH_REVIEW_BATCH_SIZE = 10;
4
5
  const MAILBOX_EMAIL_HEADERS = new Set([
5
6
  "address",
6
7
  "email",
@@ -194,6 +195,7 @@ export function summarizeMailboxImportValidation(mailboxes, input) {
194
195
  infrastructure_platforms: infrastructurePlatforms,
195
196
  credential_rows: credentialRows,
196
197
  identity_only_rows: mailboxes.length - credentialRows,
198
+ oauth_review_plan: planMailboxOAuthReviews(mailboxes),
197
199
  limits: {
198
200
  max_rows: MAILBOX_IMPORT_ROW_LIMIT,
199
201
  max_file_bytes: MAILBOX_IMPORT_FILE_MAX_BYTES,
@@ -205,6 +207,40 @@ export function summarizeMailboxImportValidation(mailboxes, input) {
205
207
  next_action: "Submit this reviewed file to register the mailboxes.",
206
208
  };
207
209
  }
210
+ /**
211
+ * Build the deterministic, provider-specific review queue for exact-account
212
+ * OAuth. This is safe during local file validation: it uses only normalized
213
+ * provider counts, performs no directory discovery, and does not claim to know
214
+ * whether a workspace already holds a usable grant for an address.
215
+ */
216
+ export function planMailboxOAuthReviews(mailboxes) {
217
+ const counts = { google: 0, microsoft: 0 };
218
+ for (const mailbox of mailboxes)
219
+ counts[mailbox.provider] += 1;
220
+ const google = planProviderOAuthReviews(counts.google);
221
+ const microsoft = planProviderOAuthReviews(counts.microsoft);
222
+ return {
223
+ authorization: "exact_account_oauth",
224
+ scope: "validated_addresses",
225
+ directory_discovery: false,
226
+ existing_grants_checked: false,
227
+ batch_size: MAILBOX_OAUTH_REVIEW_BATCH_SIZE,
228
+ total_batches: google.batches + microsoft.batches,
229
+ providers: { google, microsoft },
230
+ };
231
+ }
232
+ function planProviderOAuthReviews(mailboxes) {
233
+ const fullBatches = Math.floor(mailboxes / MAILBOX_OAUTH_REVIEW_BATCH_SIZE);
234
+ const remainder = mailboxes % MAILBOX_OAUTH_REVIEW_BATCH_SIZE;
235
+ const batchSizes = Array.from({ length: fullBatches }, () => MAILBOX_OAUTH_REVIEW_BATCH_SIZE);
236
+ if (remainder > 0)
237
+ batchSizes.push(remainder);
238
+ return {
239
+ mailboxes,
240
+ batches: batchSizes.length,
241
+ batch_sizes: batchSizes,
242
+ };
243
+ }
208
244
  export function normalizeMailboxImportVendor(raw, from) {
209
245
  if (from === "hypertide") {
210
246
  if (raw && raw.trim().toLowerCase() !== "hypertide") {
@@ -66,6 +66,12 @@ export declare const MAX_SPINTAX_DEPTH = 10;
66
66
  * `{{column}}` placeholders render empty; a malformed placeholder is left literal.
67
67
  */
68
68
  export declare function renderTemplate(template: string, values: Record<string, unknown>, options?: RenderTemplateOptions): string;
69
+ /**
70
+ * Bare variables that are blank in the branch this exact render selected.
71
+ * Untaken conditional/spintax branches are deliberately ignored; fallback-bearing
72
+ * tokens are optional, while bare tokens selected inside a fallback are checked.
73
+ */
74
+ export declare function renderedTemplateMissingVariables(template: string, values: Record<string, unknown>, options?: RenderTemplateOptions): string[];
69
75
  /**
70
76
  * Every column key a template references — across bare `{{column}}` substitutions,
71
77
  * `{{column|fallback}}` fallbacks, `{{RANDOM|…}}` spintax options, and both the
@@ -1,5 +1,5 @@
1
1
  /** Normalized failure category, derived from SQLSTATE first, then errno/message. */
2
- export type SqlErrorCause = "connect_timeout" | "connection" | "statement_timeout" | "admin_shutdown" | "too_many_connections" | "insufficient_resources" | "auth" | "schema_drift" | "data_exception" | "integrity_constraint" | "serialization" | "deadlock" | "transaction_rollback" | "syntax_or_access" | "internal" | "unknown";
2
+ export type SqlErrorCause = "connect_timeout" | "connection" | "statement_timeout" | "admin_shutdown" | "too_many_connections" | "insufficient_resources" | "auth" | "schema_drift" | "data_exception" | "integrity_constraint" | "serialization" | "deadlock" | "transaction_rollback" | "read_only" | "syntax_or_access" | "internal" | "unknown";
3
3
  export type SqlErrorAttribution = {
4
4
  /** Postgres SQLSTATE (5 chars) when present; null for connection-level errnos. */
5
5
  pgCode: string | null;
@@ -62,6 +62,12 @@ const TRANSIENT_CAUSES = new Set([
62
62
  "serialization",
63
63
  "deadlock",
64
64
  "transaction_rollback",
65
+ // A Neon compute waking from autostop (or briefly in hot-standby/recovery)
66
+ // rejects writes with 25006 read_only_sql_transaction until the primary is
67
+ // writable again; the next poll tick succeeds. The dev worker's control-queue
68
+ // drain produced ~hourly 25006 bursts that self-healed within one wake cycle
69
+ // (OXY-5984).
70
+ "read_only",
65
71
  ]);
66
72
  /**
67
73
  * Classifies a pg/drizzle error into structured, non-secret attribution.
@@ -215,6 +221,13 @@ const EXACT_SQLSTATE_CAUSES = new Map([
215
221
  ["53300", "too_many_connections"],
216
222
  ["40001", "serialization"],
217
223
  ["40P01", "deadlock"],
224
+ // 25006 read_only_sql_transaction: a write hit a compute that is (still)
225
+ // read-only — a Neon dev branch mid-autostart or in hot-standby/recovery.
226
+ // Class 25 has no blanket mapping on purpose: its other members (25P02
227
+ // in_failed_sql_transaction, 25001 active_sql_transaction, ...) are
228
+ // transaction-protocol misuse, not a transient infra state. Only 25006 is
229
+ // the "retry on the next tick" case (OXY-5984).
230
+ ["25006", "read_only"],
218
231
  ]);
219
232
  // SQLSTATE class (first two chars) → cause, applied only after the exact and
220
233
  // schema-drift lookups above so the precise codes keep their dedicated cause.
@@ -1,3 +1,3 @@
1
- export declare const OXYGEN_VERSION = "1.686.3";
1
+ export declare const OXYGEN_VERSION = "1.692.5";
2
2
  export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
3
3
  export declare const MANAGED_INBOX_MINIMUM_CLI_VERSION = "1.326.2";
@@ -1,4 +1,4 @@
1
- export const OXYGEN_VERSION = "1.686.3";
1
+ export const OXYGEN_VERSION = "1.692.5";
2
2
  // The GLOBAL CLI compatibility floor: the oldest CLI allowed to call any
3
3
  // operational route. Raising it hard-rejects every older CLI from the entire
4
4
  // product, so it obeys one law, enforced by scripts/ci/cli-min-version-gate.mjs:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.686.3",
3
+ "version": "1.692.5",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",