@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 +1 -1
- package/dist/index.js +72 -3
- package/node_modules/@oxygen/shared/dist/custom-http-safety.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/custom-http-safety.js +38 -6
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
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.
|
|
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<
|
|
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 (
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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.
|
|
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.
|
|
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.
|