@oxygen-agent/cli 1.1014.1600 → 1.1014.1696

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.
Files changed (41) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +4 -1
  3. package/dist/commands/custom-integrations.js +13 -0
  4. package/dist/commands/inbox.js +1 -1
  5. package/dist/commands/integrations.js +5 -1
  6. package/dist/commands/mailboxes.js +26 -6
  7. package/dist/commands/managed-inboxes.js +2 -2
  8. package/dist/commands/senders.js +4 -4
  9. package/dist/credential-storage/store.d.ts +10 -0
  10. package/dist/credential-storage/store.js +282 -0
  11. package/dist/credentials.d.ts +1 -3
  12. package/dist/credentials.js +3 -275
  13. package/dist/mailbox-fleet-output.js +11 -2
  14. package/dist/workspace-placement-commands.js +3 -2
  15. package/node_modules/@oxygen/shared/dist/capability-discovery.js +63 -9
  16. package/node_modules/@oxygen/shared/dist/clay-migration-auth-state.d.ts +7 -1
  17. package/node_modules/@oxygen/shared/dist/clay-migration-auth-state.js +6 -5
  18. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +1 -0
  19. package/node_modules/@oxygen/shared/dist/contracts/clay-migration.d.ts +39 -1
  20. package/node_modules/@oxygen/shared/dist/contracts/clay-migration.js +32 -4
  21. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +2 -1
  22. package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.js +1 -1
  23. package/node_modules/@oxygen/shared/dist/copilot-playbooks.generated.d.ts +1 -1
  24. package/node_modules/@oxygen/shared/dist/copilot-playbooks.generated.js +2 -2
  25. package/node_modules/@oxygen/shared/dist/email-classification-scope.d.ts +35 -0
  26. package/node_modules/@oxygen/shared/dist/email-classification-scope.js +40 -0
  27. package/node_modules/@oxygen/shared/dist/native-chat.d.ts +9 -0
  28. package/node_modules/@oxygen/shared/dist/native-chat.js +12 -1
  29. package/node_modules/@oxygen/shared/dist/onboarding-research.d.ts +9 -2
  30. package/node_modules/@oxygen/shared/dist/onboarding-research.js +9 -2
  31. package/node_modules/@oxygen/shared/dist/recipes.d.ts +2 -0
  32. package/node_modules/@oxygen/shared/dist/recipes.js +13 -0
  33. package/node_modules/@oxygen/shared/dist/sending-domain-rollout.d.ts +7 -0
  34. package/node_modules/@oxygen/shared/dist/sending-domain-rollout.js +28 -0
  35. package/node_modules/@oxygen/shared/dist/tool-call-arguments.d.ts +24 -0
  36. package/node_modules/@oxygen/shared/dist/tool-call-arguments.js +68 -0
  37. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  38. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  39. package/node_modules/@oxygen/shared/dist/workspace-placement.d.ts +3 -0
  40. package/node_modules/@oxygen/shared/dist/workspace-placement.js +11 -0
  41. package/package.json +1 -1
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.1014.1600
37
+ Version: 1.1014.1696
@@ -180,6 +180,8 @@ const MUTATING_COMMANDS = new Set([
180
180
  // mode, but a small number of approved operations are explicitly
181
181
  // zero-credit. Keep those exceptions exact so discovery never invents spend.
182
182
  const ZERO_CREDIT_APPROVAL_COMMANDS = new Set([
183
+ "custom-integrations remove",
184
+ "integrations disconnect",
183
185
  // Native deliverability tests send approved real email without buying provider credits.
184
186
  "deliverability workspace-test run",
185
187
  "deliverability workspace-test bulk run",
@@ -270,6 +272,7 @@ const READ_ONLY_COMMANDS = new Set([
270
272
  // invocation still previews, so machine-readable discovery must not imply that
271
273
  // calling the command without its confirmation flag writes anything.
272
274
  const PREVIEW_BY_DEFAULT_COMMANDS = new Set([
275
+ "custom-integrations remove",
273
276
  // A bare call validates and names what would be sent; --approved submits.
274
277
  "voice verification submit",
275
278
  "domains dns apply",
@@ -491,7 +494,7 @@ function toManifestEntry(command, path) {
491
494
  && (MUTATING_COMMANDS.has(commandName)
492
495
  || MUTATING_VERBS.has(leafVerb)
493
496
  || MUTATING_VERBS.has(verbPrefix))),
494
- destructive: operations.some((spec) => spec.mcp.annotations?.destructiveHint === true),
497
+ destructive: commandName === "custom-integrations remove" || commandName === "integrations disconnect" || operations.some((spec) => spec.mcp.annotations?.destructiveHint === true),
495
498
  preview_by_default: flagStrings.some((flags) => flags.includes("--live")) ||
496
499
  PREVIEW_BY_DEFAULT_COMMANDS.has(commandName),
497
500
  json_supported: flagStrings.some((flags) => flags.includes("--json")),
@@ -123,6 +123,19 @@ export function registerCustomIntegrationsFamily(program, handleAsyncAction) {
123
123
  body: { slug, api_key: apiKey },
124
124
  });
125
125
  });
126
+ }))
127
+ .addCommand(new Command("remove")
128
+ .description("Preview removal of a custom HTTP integration and its tools. Confirm with --approved --plan <plan_hash>; saved columns, workflows and run history remain.")
129
+ .argument("[slug]", "Custom HTTP integration slug.")
130
+ .option("--connection-id <id>", "Select an exact connection, including when a slug is ambiguous.")
131
+ .option("--approved", "Approve the exact removal preview.")
132
+ .option("--plan <plan_hash>", "Hash returned by the removal preview.")
133
+ .option("--json", "Print a JSON envelope.")
134
+ .action(async (slug, options) => {
135
+ await handleAsyncAction("custom-integrations remove", options, () => requestOxygen("/api/cli/custom-integrations/remove", {
136
+ method: "POST",
137
+ body: { slug, connection_id: readOption(options.connectionId), approved: options.approved ?? false, plan_hash: readOption(options.plan) },
138
+ }));
126
139
  }))
127
140
  .addCommand(new Command("list")
128
141
  .description("List custom HTTP integrations for the active organization.")
@@ -336,7 +336,7 @@ export function registerInboxFamily(program, handleAsyncAction) {
336
336
  });
337
337
  }))
338
338
  .addCommand(new Command("analyze")
339
- .description("Run analysis on a conversation now: status + a drafted reply (Oxygen's default model). The worker also does this automatically on inbound conversations.")
339
+ .description("Run analysis on a conversation now: status + a drafted reply (Oxygen's default model). The worker also does this automatically on inbound conversations inside each mailbox's classification scope (`mailboxes update --classify`).")
340
340
  .argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
341
341
  .option("--channel <channel>", "Inbox channel: email (default), linkedin, or whatsapp.")
342
342
  .option("--force", "Re-analyze even if the conversation was already classified (an explicit paid re-classification; spends one managed credit). Without it, an already-analyzed email is a free no-op. DMs always re-analyze.")
@@ -206,9 +206,11 @@ export function registerIntegrationsFamily(program, handleAsyncAction) {
206
206
  }));
207
207
  }))
208
208
  .addCommand(new Command("disconnect")
209
- .description("Disconnect an integration. LinkedIn and WhatsApp remove the connected account; pass --connection-id when more than one is connected.")
209
+ .description("Disconnect an integration. Custom HTTP previews removal first; confirm with --approved --plan <plan_hash>. LinkedIn and WhatsApp remove the connected account.")
210
210
  .argument("<integration_id>", "Integration id, such as 'slack'.")
211
211
  .option("--connection-id <id>", "Disconnect a specific connected account when multiple exist.")
212
+ .option("--approved", "Approve the custom HTTP removal preview.")
213
+ .option("--plan <plan_hash>", "Custom HTTP removal preview hash.")
212
214
  .option("--json", "Print a JSON envelope.")
213
215
  .action(async (integrationId, options) => {
214
216
  const connectionId = readOption(options.connectionId)?.trim();
@@ -217,6 +219,8 @@ export function registerIntegrationsFamily(program, handleAsyncAction) {
217
219
  body: {
218
220
  integration_id: integrationId,
219
221
  ...(connectionId ? { connection_id: connectionId } : {}),
222
+ ...(options.approved !== undefined ? { approved: options.approved } : {}),
223
+ ...(readOption(options.plan) ? { plan_hash: readOption(options.plan) } : {}),
220
224
  },
221
225
  }));
222
226
  }))
@@ -553,15 +553,23 @@ export function registerMailboxesFamily(program, handleAsyncAction) {
553
553
  }))
554
554
  .addCommand(new Command("health")
555
555
  .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 — plus OXYGEN's OWN evidence for the last 7 days, which needs no provider connected: the bounce notifications each mailbox received classified as provider rejection / dead address / mailbox full / delay (a Google receiving-server policy rejection shows up as provider_rejected), distinct recipients with a hard bounce in the window (legacy facts retain their first observation time), sequence sends OXYGEN logged (source=sequence only), warm-up day, health score and stop cause, each mailbox's configured daily cap, and the same rollup per sending domain ranked worst-first. Notifications whose body was never stored count as signals.dsn7d.unclassified, which means OXYGEN could not look — not that nothing was wrong. Read health_coverage to see how many mailboxes an external provider has actually scored. Start from the fleet read instead of scanning the mailbox array: warmup.byCause (why warm-up stopped, counted once for the whole pool). Per-mailbox warmup.state and fleet warmup.byState are stored rail telemetry; use `oxygen mailboxes list --json` warmupTruth for enrollment state and next actions. The rolling window is signals.window (7d). Delivery notifications include all send sources, including warm-up and manual mail; signals.sendSource=sequence restricts only coldSends7d. Identified warm-up rejections are reported separately, and other notifications have no verified send-source attribution. Rejections alone do not establish that prospect mail was rejected or that a whole domain is blocked. A zero means no such evidence was recorded in the stated window, not that the fleet is un-blocklisted everywhere. Pure read — 0 credits; never pauses a mailbox or changes a cap.")
556
- .addOption(new Option("--view <view>", "Summary: complete compact roster (default); fleet: domain, DSN and warm-up totals without rows; full: all inbox evidence.").choices(["summary", "fleet", "full"]))
556
+ .addOption(new Option("--view <view>", "Start with --view fleet for totals; summary: complete compact roster (default); full: per-inbox evidence.").choices(["summary", "fleet", "full"]))
557
+ .option("--mailboxes <list>", "Inspect 1–100 mailbox ids or addresses, comma-separated (defaults to full evidence). Fleet totals remain unfiltered.")
557
558
  .option("--json", "Print a JSON envelope.")
558
559
  .action(async (options) => {
559
- await handleReadActionWithLens("mailboxes health", options, () => requestOxygen(`/api/cli/mailboxes/health?view=${encodeURIComponent(options.view ?? "summary")}`), formatMailboxFleetHealth);
560
+ const query = new URLSearchParams({ view: options.view ?? (options.mailboxes !== undefined ? "full" : "summary") });
561
+ if (options.mailboxes !== undefined)
562
+ query.set("mailboxes", options.mailboxes);
563
+ await handleReadActionWithLens("mailboxes health", options, () => requestOxygen(`/api/cli/mailboxes/health?${query}`), formatMailboxFleetHealth);
560
564
  }))
561
565
  .addCommand(new Command("compatibility")
562
- .description("Read-only compatibility report per mailbox — select with --mailboxes <address,…> (omit for the whole pool; filter here, not in a script): generic origin class, real infrastructure tier (including InboxKit Azure), OXYGEN native-send connection quality, OXYGEN Warm-up path, OXYGEN deliverability monitoring path, and exact next actions. Read automatic_repair for native send and warmup_automatic_repair for an existing warm-up seat: mode=automatic means OXYGEN owns the next bounded retry. Native send distinguishes destination-bound OAuth, provider-managed API transport, admin delegation, and authorization_required. Use --catalog-only for the compact workspace-independent import contract. JSON data.compatibility contains checked rows; data.import_methods and data.import_fields describe accepted transfer inputs; data.provider_matrix is the current public product matrix; data.non_transferable_auth names credentials that must be reconnected; data.summary rolls up states; data.web_url opens the pool. Downstream states distinguish credential_required, consent_required, and vendor_blocked. Never decrypts a credential or calls a downstream provider.")
566
+ .description("Read-only compatibility report per mailbox — select with --mailboxes <address,…> (omit for the whole pool; filter here, not in a script): generic origin class, real infrastructure tier (including InboxKit Azure), OXYGEN native-send connection quality, OXYGEN Warm-up path, OXYGEN deliverability monitoring path, and exact next actions. Read automatic_repair for native send and warmup_automatic_repair for an existing warm-up seat: mode=automatic means OXYGEN owns the next bounded retry. Native send distinguishes destination-bound OAuth, provider-managed API transport, admin delegation, and authorization_required. Use --catalog-only for the compact workspace-independent import contract. JSON data.compatibility contains checked rows; data.import_methods and data.import_fields describe accepted transfer inputs; data.provider_matrix is the current public product matrix; data.non_transferable_auth names credentials that must be reconnected; data.summary rolls up states; data.web_url opens the pool. Downstream states distinguish credential_required, consent_required, and vendor_blocked. Never decrypts credentials or writes provider state. The full report may read InboxKit source-warmup status; --remedy oauth uses stored authorization only with no provider calls and leaves warm-up and deliverability unchecked.")
563
567
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
564
568
  .option("--catalog-only", "Return only the bounded import-method, field, auth-boundary, and public provider catalogs; do not read or return workspace mailbox rows.")
569
+ .option("--vendor <origin>", "Filter the canonical mailbox origin, for example inboxkit, zapmail or native.")
570
+ .option("--provider <provider>", "Filter google or microsoft.")
571
+ .option("--remedy <remedy>", "Filter a native-send remedy code; oauth reports stored authorization only without provider calls.")
572
+ .option("--summary-only", "Return counts without individual mailbox rows.")
565
573
  .option("--json", "Print a JSON envelope.")
566
574
  .action(async (options) => {
567
575
  await handleAsyncAction("mailboxes compatibility", options, () => {
@@ -576,6 +584,13 @@ export function registerMailboxesFamily(program, handleAsyncAction) {
576
584
  if (mailboxes.length > 0) {
577
585
  params.set("mailboxes", mailboxes.join(","));
578
586
  }
587
+ for (const key of ["vendor", "provider", "remedy"]) {
588
+ const value = readOption(options[key]);
589
+ if (value)
590
+ params.set(key, value);
591
+ }
592
+ if (options.summaryOnly)
593
+ params.set("summary_only", "true");
579
594
  const suffix = params.toString();
580
595
  return requestOxygen(`/api/cli/mailboxes/compatibility${suffix ? `?${suffix}` : ""}`);
581
596
  });
@@ -781,12 +796,13 @@ export function registerMailboxesFamily(program, handleAsyncAction) {
781
796
  });
782
797
  })))
783
798
  .addCommand(new Command("update")
784
- .description("Update a sending mailbox's rich-content profile: the From display name and/or the HTML signature appended to its sends. An email that already contains the signature gets no second copy. Pass an empty string ('') to clear a field. Consumes no Oxygen credits. <mailbox> accepts a mailbox id or email address.")
799
+ .description("Update a sending mailbox's rich-content profile: the From display name and/or the HTML signature appended to its sends. An email that already contains the signature gets no second copy. Pass an empty string ('') to clear a field. Also sets its bounce protection and which inbound mail AI reply classification triages (--classify). Consumes no Oxygen credits. <mailbox> accepts a mailbox id or email address.")
785
800
  .argument("<mailbox>", "Mailbox id or email address.")
786
801
  .option("--display-name <name>", "From display name (e.g. \"Ada from Acme\"). Pass '' to clear it.")
787
802
  .option("--signature-html <html>", "HTML signature appended to every send from this mailbox. Pass '' to clear it.")
788
803
  .option("--signature-file <path>", "Read the HTML signature from a file instead of --signature-html.")
789
804
  .option("--bounce-protection <json>", "Mailbox bounce policy (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this mailbox. Rates are fractions; sequence protection is separate.")
805
+ .option("--classify <scope>", "Which inbound conversations AI reply classification triages (each one uses credits): replies (Sequence threads and threads you wrote in; other mail, such as newsletters and notifications, stays in Unibox unclassified at no cost) or all (every inbound conversation). Default: replies for a Google mailbox you added yourself, all for every other mailbox. Applies to new mail; `mailboxes get` shows the current scope.")
790
806
  .option("--json", "Print a JSON envelope.")
791
807
  .action(async (mailbox, options) => {
792
808
  await handleAsyncAction("mailboxes update", options, () => {
@@ -795,6 +811,8 @@ export function registerMailboxesFamily(program, handleAsyncAction) {
795
811
  const patch = {};
796
812
  if (options.bounceProtection !== undefined)
797
813
  patch.bounce_protection = JSON.parse(options.bounceProtection);
814
+ if (options.classify !== undefined)
815
+ patch.classification_scope = options.classify;
798
816
  if (options.displayName !== undefined)
799
817
  patch.display_name = options.displayName;
800
818
  if (options.signatureHtml !== undefined) {
@@ -804,7 +822,7 @@ export function registerMailboxesFamily(program, handleAsyncAction) {
804
822
  patch.signature_html = readFileSync(resolve(options.signatureFile), "utf8");
805
823
  }
806
824
  if (Object.keys(patch).length === 0) {
807
- throw new Error("Provide --display-name and/or --signature-html (or --signature-file).");
825
+ throw new Error("Provide --display-name, --signature-html (or --signature-file), --bounce-protection or --classify.");
808
826
  }
809
827
  return requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}`, {
810
828
  method: "PATCH",
@@ -836,8 +854,9 @@ export function registerMailboxesFamily(program, handleAsyncAction) {
836
854
  .option("--no-canary", "inboxkit only: fan out to every eligible mailbox on a domain that has never connected one, instead of firing a single canary first.")
837
855
  .option("--connection <id>", "Zapmail connection id. Defaults to the org's active Zapmail connection.")
838
856
  .option("--status <id>", "Poll a previously started Zapmail, InboxKit, or OXYGEN direct-consent run.")
839
- .option("--dry-run", "Preview the requested mailbox/domain scope and external writes without changing vendor or ledger state. This is the default for starts; a successful preview can still have zero eligible mailboxes, so check InboxKit candidates with oauth-health before approval.")
857
+ .option("--dry-run", "Preview the requested mailbox/domain scope and external writes without changing vendor or ledger state. InboxKit enumerates consent candidates, skipped reasons, domain approvals and exact external writes; approve its fresh plan hash. This is the default for starts.")
840
858
  .option("--approved", "Start the reviewed authorization run. Composio creates an individual consent link for each owner, who must open it and approve access with Google or Microsoft. Native vendor=oxygen records expiring browser steps for the selected owner or tenant administrator. Starting a run does not grant mail access.")
859
+ .option("--plan-hash <hash>", "InboxKit: exact fresh eligibility/approval preview hash; required with --approved.")
841
860
  .option("--json", "Print a JSON envelope.")
842
861
  .action(async (options) => {
843
862
  await handleAsyncAction("mailboxes connect-oauth", options, () => {
@@ -867,6 +886,7 @@ export function registerMailboxesFamily(program, handleAsyncAction) {
867
886
  method: "POST",
868
887
  body: {
869
888
  provider,
889
+ ...(readOption(options.planHash) ? { plan_hash: readOption(options.planHash) } : {}),
870
890
  ...(options.googleSmtp === true ? { google_smtp: true } : {}),
871
891
  // Every key stays absent unless it was asked for, so a run
872
892
  // without the new flags is the request this command has always
@@ -126,8 +126,8 @@ export function registerManagedInboxesFamily(program, handleAsyncAction) {
126
126
  .addOption(new Option("--preview", "Explicit read-only quote: connects nothing, buys nothing and charges nothing. Cannot be combined with purchase or DNS cleanup approval.").conflicts(["approved", "dnsCleanupApproved"]))
127
127
  .option("--existing-domain", "Use an existing Cloudflare domain from oxygen domains list; never register or transfer it.")
128
128
  .option("--max-per-domain <n>", "Save a stricter mailbox limit with the approved order, including existing addresses (e.g. 2). Preview does not save it.")
129
- .option("--dns-cleanup-records <ids>", "Preview deletion of these comma-separated exact email DNS record IDs on an existing domain; connects and buys nothing.")
130
- .addOption(new Option("--dns-cleanup-approved", "Delete only the reviewed email records; requires --dns-cleanup-records and --dns-cleanup-hash. Request a fresh purchase quote afterward.").conflicts("approved"))
129
+ .option("--dns-cleanup-records <ids>", "Optional standalone cleanup: preview deletion of these comma-separated email DNS record IDs; buys nothing. An approved existing-domain order already removes the records its preview lists.")
130
+ .addOption(new Option("--dns-cleanup-approved", "Delete only the reviewed email records without buying; requires --dns-cleanup-records and --dns-cleanup-hash. Request a fresh purchase quote afterward.").conflicts("approved"))
131
131
  .option("--dns-cleanup-hash <hash>", "The plan_hash from the exact DNS cleanup preview, required to delete records.")
132
132
  .option("--years <n>", "Years to register a new domain for (1-10). Omit for existing domains.")
133
133
  .option("--redirect-url <url>", "Where the domain's web root redirects. Defaults to your workspace's company website; editable later with `oxygen domains forwarding set`.")
@@ -203,15 +203,15 @@ export function registerSendersFamily(program, handleAsyncAction) {
203
203
  .description("Show current limits, overrides, working hours (timezone, days, start, end), defaults, hard maximums, and the caution levels above which LinkedIn restriction risk rises, for an account. <id> accepts a sender account id, connection id, or Unipile account id.")
204
204
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
205
205
  .option("--json", "Print a JSON envelope.")
206
- .action(async (id, options) => {
207
- await handleAsyncAction("senders limits get", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/limits`));
206
+ .action(async (id, options, command) => {
207
+ await handleAsyncAction("senders limits get", { ...options, json: command.optsWithGlobals().json === true }, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/limits`));
208
208
  }))
209
209
  .addCommand(addContractFlags(new Command(SENDER_LIMITS_SET_OPERATION.cli.path.at(-1) ?? "set")
210
210
  .description(SENDER_LIMITS_SET_OPERATION.cli.summary)
211
211
  .argument("<id>", SENDER_LIMITS_SET_OPERATION.cli.argument?.description), SENDER_LIMITS_SET_OPERATION.contract, SENDER_LIMITS_FLAG_OVERLAY)
212
212
  .option("--json", "Print a JSON envelope.")
213
- .action(async (id, options) => {
214
- await handleAsyncAction("senders limits set", options, () => {
213
+ .action(async (id, options, command) => {
214
+ await handleAsyncAction("senders limits set", { ...options, json: command.optsWithGlobals().json === true }, () => {
215
215
  const body = buildSenderLimitsBody(options);
216
216
  return requestOxygen(operationPath(SENDER_LIMITS_SET_OPERATION, { id }), {
217
217
  method: SENDER_LIMITS_SET_OPERATION.method,
@@ -0,0 +1,10 @@
1
+ /** The CLI's per-user config directory; `OXYGEN_CONFIG_DIR` overrides it. */
2
+ export declare function resolveConfigDir(env?: NodeJS.ProcessEnv): string;
3
+ /** Own raw credential IO. The same live environment is read at every original
4
+ * operation boundary; the application still owns documents and profiles.
5
+ * Construct once at the application's original promisify initialization point. */
6
+ export declare function createCredentialStorage(): {
7
+ readSystemCredentialPayload: (env: NodeJS.ProcessEnv) => Promise<string | null>;
8
+ writeSystemCredentialPayload: (payload: string, env: NodeJS.ProcessEnv) => Promise<void>;
9
+ deleteSystemCredentialPayload: (env: NodeJS.ProcessEnv) => Promise<void>;
10
+ };
@@ -0,0 +1,282 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, chmodSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { OxygenError } from "@oxygen/shared/cli-result";
7
+ const SERVICE_NAME = "oxygen-cli";
8
+ const ACCOUNT_NAME = "default";
9
+ const WINDOWS_CREDENTIAL_PAYLOAD_ENV = "OXYGEN_CLI_WINDOWS_CREDENTIAL_PAYLOAD";
10
+ /** The CLI's per-user config directory; `OXYGEN_CONFIG_DIR` overrides it. */
11
+ export function resolveConfigDir(env = process.env) {
12
+ return env.OXYGEN_CONFIG_DIR?.trim()
13
+ || (process.platform === "win32"
14
+ ? join(env.APPDATA || join(homedir(), "AppData", "Roaming"), "oxygen")
15
+ : join(homedir(), ".config", "oxygen"));
16
+ }
17
+ /** Own raw credential IO. The same live environment is read at every original
18
+ * operation boundary; the application still owns documents and profiles.
19
+ * Construct once at the application's original promisify initialization point. */
20
+ export function createCredentialStorage() {
21
+ const execFileAsync = promisify(execFile);
22
+ async function readSystemCredentialPayload(env) {
23
+ const store = credentialStore(env);
24
+ if (store === "keychain")
25
+ return readMacOSKeychainPayload(env);
26
+ if (store === "credential-manager")
27
+ return readWindowsCredentialManagerPayload(env);
28
+ return readFileCredentialPayload(env);
29
+ }
30
+ async function writeSystemCredentialPayload(payload, env) {
31
+ const store = credentialStore(env);
32
+ if (store === "keychain") {
33
+ await writeMacOSKeychainPayload(payload, env);
34
+ return;
35
+ }
36
+ if (store === "credential-manager") {
37
+ await writeWindowsCredentialManagerPayload(payload, env);
38
+ return;
39
+ }
40
+ writeFileCredentialPayload(payload, env);
41
+ }
42
+ async function deleteSystemCredentialPayload(env) {
43
+ const store = credentialStore(env);
44
+ if (store === "keychain") {
45
+ await deleteMacOSKeychain(env);
46
+ return;
47
+ }
48
+ if (store === "credential-manager") {
49
+ await deleteWindowsCredentialManager(env);
50
+ return;
51
+ }
52
+ deleteFileCredentials(env);
53
+ }
54
+ function credentialStore(env) {
55
+ const forced = env.OXYGEN_CREDENTIAL_STORE?.trim().toLowerCase();
56
+ if (forced === "file")
57
+ return "file";
58
+ if (forced === "keychain")
59
+ return "keychain";
60
+ if (forced === "credential-manager")
61
+ return "credential-manager";
62
+ if (process.platform === "darwin")
63
+ return "keychain";
64
+ if (process.platform === "win32")
65
+ return "credential-manager";
66
+ return "file";
67
+ }
68
+ function credentialsPath(env) {
69
+ return join(resolveConfigDir(env), "credentials.json");
70
+ }
71
+ function readFileCredentialPayload(env) {
72
+ const path = credentialsPath(env);
73
+ if (!existsSync(path))
74
+ return null;
75
+ return readFileSync(path, "utf8");
76
+ }
77
+ function writeFileCredentialPayload(payload, env) {
78
+ const path = credentialsPath(env);
79
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
80
+ const tempPath = `${path}.${process.pid}.tmp`;
81
+ writeFileSync(tempPath, payload, { mode: 0o600 });
82
+ chmodSync(tempPath, 0o600);
83
+ renameSync(tempPath, path);
84
+ chmodSync(path, 0o600);
85
+ }
86
+ function deleteFileCredentials(env) {
87
+ const path = credentialsPath(env);
88
+ rmSync(path, { force: true });
89
+ }
90
+ async function readMacOSKeychainPayload(env) {
91
+ try {
92
+ const { stdout } = await execFileAsync(macOSSecurityCommand(env), [
93
+ "find-generic-password",
94
+ "-a",
95
+ ACCOUNT_NAME,
96
+ "-s",
97
+ SERVICE_NAME,
98
+ "-w",
99
+ ], { env });
100
+ return stdout;
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
106
+ // `security -i` reads commands with a line buffer that traditionally caps at
107
+ // 4096 bytes. Beyond that, the input is silently truncated, which would
108
+ // corrupt the stored credential and break re-auth. Reject early with a clear
109
+ // error so a user with too many profiles gets actionable feedback instead of
110
+ // a partial-write that only surfaces on the next CLI invocation.
111
+ const MACOS_SECURITY_LINE_LIMIT_BYTES = 4096;
112
+ async function writeMacOSKeychainPayload(payload, env) {
113
+ // `security add-generic-password -w PAYLOAD` would put the token in argv
114
+ // (visible to any same-UID `ps`). Use `security -i` instead: the subcommand
115
+ // is written on stdin, so the only argv `ps` ever sees is `security -i`.
116
+ const command = macOSSecurityCommand(env);
117
+ const escaped = payload.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
118
+ const line = `add-generic-password -a "${ACCOUNT_NAME}" -s "${SERVICE_NAME}" -w "${escaped}" -U\n`;
119
+ const lineBytes = Buffer.byteLength(line, "utf8");
120
+ if (lineBytes >= MACOS_SECURITY_LINE_LIMIT_BYTES) {
121
+ throw credentialStoreError("write", "macOS Keychain", {
122
+ code: "payload_too_large",
123
+ stderr: `Credential document is ${lineBytes} bytes; \`security -i\` truncates lines at ${MACOS_SECURITY_LINE_LIMIT_BYTES} bytes. Reduce stored profiles (oxygen logout <profile>) and retry.`,
124
+ });
125
+ }
126
+ await new Promise((resolve, reject) => {
127
+ const child = spawn(command, ["-i"], { env, stdio: ["pipe", "pipe", "pipe"] });
128
+ let stderr = "";
129
+ child.stderr.on("data", (chunk) => {
130
+ stderr += chunk.toString("utf8");
131
+ });
132
+ child.once("error", (error) => {
133
+ reject(credentialStoreError("write", "macOS Keychain", error));
134
+ });
135
+ child.once("close", (code, signal) => {
136
+ const trimmedStderr = stderr.trim();
137
+ if (code === 0 && !trimmedStderr) {
138
+ resolve();
139
+ return;
140
+ }
141
+ reject(credentialStoreError("write", "macOS Keychain", { code, signal, stderr: trimmedStderr }));
142
+ });
143
+ child.stdin.end(line);
144
+ });
145
+ }
146
+ async function deleteMacOSKeychain(env) {
147
+ await execFileAsync(macOSSecurityCommand(env), [
148
+ "delete-generic-password",
149
+ "-a",
150
+ ACCOUNT_NAME,
151
+ "-s",
152
+ SERVICE_NAME,
153
+ ], { env }).catch(() => undefined);
154
+ }
155
+ async function readWindowsCredentialManagerPayload(env) {
156
+ try {
157
+ const { stdout } = await runPowerShell(`${windowsCredentialManagerScript()}
158
+ $ptr = [IntPtr]::Zero
159
+ try {
160
+ if (-not [OxygenCredMan]::CredRead("${SERVICE_NAME}", 1, 0, [ref]$ptr)) { exit 2 }
161
+ $cred = [Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [type][OxygenCredMan+CREDENTIAL])
162
+ $secret = [Runtime.InteropServices.Marshal]::PtrToStringUni($cred.CredentialBlob, [int]($cred.CredentialBlobSize / 2))
163
+ [Console]::Out.Write($secret)
164
+ } finally {
165
+ if ($ptr -ne [IntPtr]::Zero) { [OxygenCredMan]::CredFree($ptr) }
166
+ }
167
+ `, env);
168
+ return stdout;
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ }
174
+ async function writeWindowsCredentialManagerPayload(value, env) {
175
+ const payload = Buffer.from(value, "utf16le").toString("base64");
176
+ try {
177
+ await runPowerShell(`${windowsCredentialManagerScript()}
178
+ $payload = $env:${WINDOWS_CREDENTIAL_PAYLOAD_ENV}
179
+ if ([string]::IsNullOrWhiteSpace($payload)) { exit 1 }
180
+ $secret = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($payload))
181
+ $bytes = [Text.Encoding]::Unicode.GetBytes($secret)
182
+ $blob = [Runtime.InteropServices.Marshal]::StringToCoTaskMemUni($secret)
183
+ try {
184
+ $cred = New-Object OxygenCredMan+CREDENTIAL
185
+ $cred.Type = 1
186
+ $cred.TargetName = "${SERVICE_NAME}"
187
+ $cred.UserName = "${ACCOUNT_NAME}"
188
+ $cred.CredentialBlob = $blob
189
+ $cred.CredentialBlobSize = $bytes.Length
190
+ $cred.Persist = 2
191
+ if (-not [OxygenCredMan]::CredWrite([ref]$cred, 0)) { exit 1 }
192
+ } finally {
193
+ [Runtime.InteropServices.Marshal]::FreeCoTaskMem($blob)
194
+ }
195
+ `, {
196
+ ...env,
197
+ [WINDOWS_CREDENTIAL_PAYLOAD_ENV]: payload,
198
+ });
199
+ }
200
+ catch (error) {
201
+ throw credentialStoreError("write", "Windows Credential Manager", error);
202
+ }
203
+ }
204
+ async function deleteWindowsCredentialManager(env) {
205
+ await runPowerShell(`${windowsCredentialManagerScript()}
206
+ [void][OxygenCredMan]::CredDelete("${SERVICE_NAME}", 1, 0)
207
+ `, env).catch(() => undefined);
208
+ }
209
+ function runPowerShell(script, env) {
210
+ const encodedCommand = Buffer.from(script, "utf16le").toString("base64");
211
+ return execFileAsync(windowsPowerShellCommand(env), [
212
+ "-NoProfile",
213
+ "-NonInteractive",
214
+ "-ExecutionPolicy",
215
+ "Bypass",
216
+ "-EncodedCommand",
217
+ encodedCommand,
218
+ ], { env });
219
+ }
220
+ function credentialStoreError(operation, store, error) {
221
+ const child = error;
222
+ const details = {
223
+ operation,
224
+ store,
225
+ };
226
+ if (typeof child.code === "number") {
227
+ details.exit_code = child.code;
228
+ }
229
+ else if (typeof child.code === "string") {
230
+ details.reason = child.code;
231
+ }
232
+ if (typeof child.signal === "string")
233
+ details.signal = child.signal;
234
+ if (typeof child.stderr === "string" && child.stderr.trim()) {
235
+ details.stderr = sanitizeCredentialErrorText(child.stderr.trim()).slice(0, 4000);
236
+ }
237
+ return new OxygenError("credential_store_failed", `Unable to ${operation} Oxygen CLI credentials in ${store}.`, { details, exitCode: 1 });
238
+ }
239
+ function sanitizeCredentialErrorText(value) {
240
+ return value.replace(/\boxy_(?:live|user)_[A-Za-z0-9_-]+\b/g, "[redacted_token]");
241
+ }
242
+ function macOSSecurityCommand(env) {
243
+ return env.OXYGEN_SECURITY_COMMAND?.trim() || "security";
244
+ }
245
+ function windowsPowerShellCommand(env) {
246
+ return env.OXYGEN_POWERSHELL_COMMAND?.trim() || "powershell.exe";
247
+ }
248
+ function windowsCredentialManagerScript() {
249
+ return `
250
+ Add-Type @"
251
+ using System;
252
+ using System.Runtime.InteropServices;
253
+ public static class OxygenCredMan {
254
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
255
+ public struct CREDENTIAL {
256
+ public UInt32 Flags;
257
+ public UInt32 Type;
258
+ public string TargetName;
259
+ public string Comment;
260
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
261
+ public UInt32 CredentialBlobSize;
262
+ public IntPtr CredentialBlob;
263
+ public UInt32 Persist;
264
+ public UInt32 AttributeCount;
265
+ public IntPtr Attributes;
266
+ public string TargetAlias;
267
+ public string UserName;
268
+ }
269
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
270
+ public static extern bool CredWrite([In] ref CREDENTIAL userCredential, UInt32 flags);
271
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
272
+ public static extern bool CredRead(string target, UInt32 type, UInt32 reservedFlag, out IntPtr credentialPtr);
273
+ [DllImport("advapi32.dll", SetLastError = true)]
274
+ public static extern bool CredDelete(string target, UInt32 type, UInt32 flags);
275
+ [DllImport("advapi32.dll", SetLastError = true)]
276
+ public static extern void CredFree([In] IntPtr cred);
277
+ }
278
+ "@
279
+ `;
280
+ }
281
+ return { readSystemCredentialPayload, writeSystemCredentialPayload, deleteSystemCredentialPayload };
282
+ }
@@ -1,3 +1,4 @@
1
+ export { resolveConfigDir } from "./credential-storage/store.js";
1
2
  export type StoredProfileIdentity = {
2
3
  organization: StoredOrganization | null;
3
4
  user: {
@@ -77,7 +78,4 @@ export declare function pickProfileNameForUserSession(userId: string, candidateS
77
78
  export declare function switchCredentialProfile(profile: string, env?: NodeJS.ProcessEnv): Promise<CredentialProfile>;
78
79
  export declare function updateActiveOrganizationForProfile(profile: string, organization: StoredOrganization, env?: NodeJS.ProcessEnv): Promise<CredentialProfile>;
79
80
  export declare function normalizeApiUrl(value: string): string;
80
- /** The CLI's per-user config directory; `OXYGEN_CONFIG_DIR` overrides it. */
81
- export declare function resolveConfigDir(env?: NodeJS.ProcessEnv): string;
82
81
  export declare function readEnvToken(env?: NodeJS.ProcessEnv): string | null;
83
- export {};