@oxygen-agent/cli 1.242.6 → 1.246.0

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.242.6
37
+ Version: 1.246.0
package/dist/index.js CHANGED
@@ -323,6 +323,60 @@ function readCustomIntegrationManifest(options) {
323
323
  }
324
324
  return parseJsonObject(manifestJson ?? readFileSync(resolve(manifestPath ?? ""), "utf8"));
325
325
  }
326
+ const CUSTOM_INTEGRATION_SECRET_KEYS = ["API_KEY", "TOKEN", "PASSWORD", "SECRET"];
327
+ function readCustomIntegrationSecret(options) {
328
+ const apiKey = readOption(options.apiKey);
329
+ const secretsFile = readOption(options.secretsFile);
330
+ if (apiKey && secretsFile) {
331
+ throw new OxygenError("invalid_request", "Pass either --api-key or --secrets-file, not both.", {
332
+ exitCode: 1,
333
+ });
334
+ }
335
+ if (apiKey)
336
+ return apiKey;
337
+ if (!secretsFile) {
338
+ throw new OxygenError("invalid_request", "Pass --api-key or --secrets-file.", {
339
+ exitCode: 1,
340
+ });
341
+ }
342
+ const entries = parseEnvFile(readFileSync(resolve(secretsFile), "utf8"));
343
+ const keys = Object.keys(entries);
344
+ if (keys.length === 0) {
345
+ throw new OxygenError("invalid_request", "Secrets file has no KEY=VALUE entries.", {
346
+ details: { file: secretsFile },
347
+ exitCode: 1,
348
+ });
349
+ }
350
+ if (keys.length === 1)
351
+ return entries[keys[0]];
352
+ const preferred = CUSTOM_INTEGRATION_SECRET_KEYS.find((key) => entries[key] !== undefined);
353
+ if (preferred)
354
+ return entries[preferred];
355
+ throw new OxygenError("invalid_request", "Secrets file is ambiguous: use a single entry or name one API_KEY, TOKEN, PASSWORD, or SECRET.", {
356
+ details: { file: secretsFile, keys },
357
+ exitCode: 1,
358
+ });
359
+ }
360
+ function parseEnvFile(content) {
361
+ const entries = {};
362
+ for (const rawLine of content.split(/\r?\n/)) {
363
+ const line = rawLine.trim();
364
+ if (!line || line.startsWith("#"))
365
+ continue;
366
+ const separator = line.indexOf("=");
367
+ if (separator <= 0)
368
+ continue;
369
+ const key = line.slice(0, separator).trim().replace(/^export\s+/, "");
370
+ let value = line.slice(separator + 1).trim();
371
+ if ((value.startsWith("\"") && value.endsWith("\"") && value.length >= 2)
372
+ || (value.startsWith("'") && value.endsWith("'") && value.length >= 2)) {
373
+ value = value.slice(1, -1);
374
+ }
375
+ if (key && value)
376
+ entries[key] = value;
377
+ }
378
+ return entries;
379
+ }
326
380
  function readFileIfPresent(value) {
327
381
  const candidate = resolve(value);
328
382
  try {
@@ -1796,7 +1850,7 @@ export function createProgram() {
1796
1850
  }));
1797
1851
  }))
1798
1852
  .addCommand(new Command("configure")
1799
- .description("Configure a standing bidirectional sync for one CRM object pair. Defaults to dry-run, which returns a real preview of the next cycle.")
1853
+ .description("Configure a standing bidirectional sync for one CRM object pair. Defaults to dry-run, which returns a real preview of the next cycle; inbound and bidirectional previews perform bounded CRM provider reads and may use external API quota.")
1800
1854
  .requiredOption("--provider <provider>", "Connected CRM: hubspot or attio.")
1801
1855
  .requiredOption("--object <object>", "Provider object: hubspot contacts|companies, or attio people|companies.")
1802
1856
  .option("--direction <direction>", "Sync direction: inbound, outbound, or bidirectional. Defaults to bidirectional.")
@@ -1805,7 +1859,7 @@ export function createProgram() {
1805
1859
  .option("--max-rows <n>", "Per-cycle row cap. The real volume brake on provider writes (BYOK writes cost 0 Oxygen credits).")
1806
1860
  .option("--max-credits <n>", "Managed-credit + external-quota cap per cycle.")
1807
1861
  .option("--approved", "Confirm a live configuration after inspecting a dry run.")
1808
- .option("--dry-run", "Preview the configuration and next cycle without writing. Default.")
1862
+ .option("--dry-run", "Preview the configuration and next cycle without writing. Inbound/bidirectional previews perform bounded CRM reads and may use external API quota. Default.")
1809
1863
  .option("--live", "Write the configuration. Requires --approved and --max-credits.")
1810
1864
  .option("--json", "Print a JSON envelope.")
1811
1865
  .action(async (options) => {
@@ -4205,7 +4259,7 @@ export function createProgram() {
4205
4259
  .command("custom-integrations")
4206
4260
  .description("Custom HTTP integration commands.")
4207
4261
  .addCommand(new Command("apply")
4208
- .description("Create or update a custom HTTP integration manifest. Store credentials from the web Connections page.")
4262
+ .description("Create or update a custom HTTP integration manifest. Attach credentials with `custom-integrations connect`.")
4209
4263
  .option("--manifest <path>", "Path to a custom HTTP manifest JSON file.")
4210
4264
  .option("--manifest-json <json>", "Custom HTTP manifest as a JSON object.")
4211
4265
  .option("--json", "Print a JSON envelope.")
@@ -4217,6 +4271,21 @@ export function createProgram() {
4217
4271
  body: { manifest },
4218
4272
  });
4219
4273
  });
4274
+ }))
4275
+ .addCommand(new Command("connect")
4276
+ .description("Attach the credential (bearer token, API key, or basic password) for an applied custom HTTP integration.")
4277
+ .argument("<slug>", "Custom integration slug from the applied manifest.")
4278
+ .option("--api-key <value>", "Credential value. Prefer --secrets-file to keep it out of shell history.")
4279
+ .option("--secrets-file <path>", "Path to a .env-style file holding the credential (a single entry, or one named API_KEY/TOKEN/PASSWORD/SECRET).")
4280
+ .option("--json", "Print a JSON envelope.")
4281
+ .action(async (slug, options) => {
4282
+ await handleAsyncAction("custom-integrations connect", options, () => {
4283
+ const apiKey = readCustomIntegrationSecret(options);
4284
+ return requestOxygen("/api/cli/custom-integrations/connect", {
4285
+ method: "POST",
4286
+ body: { slug, api_key: apiKey },
4287
+ });
4288
+ });
4220
4289
  }))
4221
4290
  .addCommand(new Command("list")
4222
4291
  .description("List custom HTTP integrations for the active organization.")
@@ -13,6 +13,6 @@ export declare function assertCustomHttpPublicUrlSyntax(url: string | URL): URL;
13
13
  export declare function assertCustomHttpResolvedHostAllowed(input: {
14
14
  url: string | URL;
15
15
  resolveHostname?: CustomHttpResolveHostname;
16
- }): Promise<void>;
16
+ }): Promise<CustomHttpResolvedAddress[]>;
17
17
  export declare function normalizeCustomHttpUrlHost(hostname: string): string;
18
18
  export declare function isBlockedCustomHttpHost(host: string): boolean;
@@ -29,8 +29,10 @@ export function assertCustomHttpPublicUrlSyntax(url) {
29
29
  export async function assertCustomHttpResolvedHostAllowed(input) {
30
30
  const parsed = assertCustomHttpPublicUrlSyntax(input.url);
31
31
  const host = normalizeCustomHttpUrlHost(parsed.hostname);
32
+ // Literal IP hosts were already validated by assertCustomHttpPublicUrlSyntax and
33
+ // need no DNS resolution (nor connection pinning) — return an empty answer set.
32
34
  if (isIP(host))
33
- return;
35
+ return [];
34
36
  const resolveHostname = input.resolveHostname ?? defaultResolveHostname;
35
37
  let addresses;
36
38
  try {
@@ -53,6 +55,9 @@ export async function assertCustomHttpResolvedHostAllowed(input) {
53
55
  throw new CustomHttpUrlSafetyError("blocked_resolved_address", "Custom HTTP URL host resolved to a non-public address.", { host, address: normalizedAddress, family: address.family ?? null });
54
56
  }
55
57
  }
58
+ // Return the validated addresses so callers can pin the connection to a checked
59
+ // IP and close the DNS-rebinding TOCTOU (fetch would otherwise re-resolve).
60
+ return addresses;
56
61
  }
57
62
  export function normalizeCustomHttpUrlHost(hostname) {
58
63
  const host = hostname.toLowerCase().replace(/\.+$/, "");
@@ -137,20 +142,47 @@ function splitCustomHttpIpv6Part(part) {
137
142
  return [];
138
143
  const groups = part.split(":");
139
144
  const values = [];
140
- for (const group of groups) {
145
+ for (let index = 0; index < groups.length; index += 1) {
146
+ const group = groups[index] ?? "";
147
+ // A trailing dotted-decimal group (e.g. `8.8.8.8` in `::ffff:8.8.8.8`) is a
148
+ // valid IPv4-in-IPv6 literal; fold it into its two 16-bit words so mapped/
149
+ // compatible addresses parse instead of fail-closing as "blocked".
150
+ if (index === groups.length - 1 && group.includes(".")) {
151
+ const octets = customHttpDottedIpv4Octets(group);
152
+ if (!octets)
153
+ return null;
154
+ values.push((octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]);
155
+ continue;
156
+ }
141
157
  if (!/^[0-9a-f]{1,4}$/.test(group))
142
158
  return null;
143
159
  values.push(Number.parseInt(group, 16));
144
160
  }
145
161
  return values;
146
162
  }
163
+ function customHttpDottedIpv4Octets(value) {
164
+ if (isIP(value) !== 4)
165
+ return null;
166
+ const octets = value.split(".").map((octet) => Number(octet));
167
+ return [octets[0] ?? 0, octets[1] ?? 0, octets[2] ?? 0, octets[3] ?? 0];
168
+ }
147
169
  function customHttpIpv4FromIpv6(groups) {
148
170
  const mappedPrefix = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff;
149
171
  const compatiblePrefix = groups.slice(0, 6).every((group) => group === 0);
150
- if (!mappedPrefix && !compatiblePrefix)
151
- return null;
152
- const high = groups[6] ?? 0;
153
- const low = groups[7] ?? 0;
172
+ // NAT64/DNS64 well-known prefix 64:ff9b::/96 embeds the IPv4 in the last 32 bits.
173
+ const nat64WellKnownPrefix = groups[0] === 0x0064
174
+ && groups[1] === 0xff9b
175
+ && groups.slice(2, 6).every((group) => group === 0);
176
+ if (mappedPrefix || compatiblePrefix || nat64WellKnownPrefix) {
177
+ return customHttpIpv4FromIpv6Pair(groups[6], groups[7]);
178
+ }
179
+ // 6to4 prefix 2002::/16 embeds the gateway IPv4 in the two groups after the prefix.
180
+ if (groups[0] === 0x2002) {
181
+ return customHttpIpv4FromIpv6Pair(groups[1], groups[2]);
182
+ }
183
+ return null;
184
+ }
185
+ function customHttpIpv4FromIpv6Pair(high = 0, low = 0) {
154
186
  if (high === 0 && low === 0)
155
187
  return null;
156
188
  return [
@@ -1,2 +1,2 @@
1
- export declare const OXYGEN_VERSION = "1.242.6";
1
+ export declare const OXYGEN_VERSION = "1.246.0";
2
2
  export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
@@ -1,4 +1,4 @@
1
- export const OXYGEN_VERSION = "1.242.6";
1
+ export const OXYGEN_VERSION = "1.246.0";
2
2
  // Bump this only when deployed CLI/API contracts require a newer CLI.
3
3
  // 1.181.0: paid table action runs and background columns run require
4
4
  // approved=true in addition to max_credits; older CLIs cannot send the flag.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.242.6",
3
+ "version": "1.246.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",