fix(cli): secure context exports and verify provider mutations

This commit is contained in:
diegosouzapw
2026-09-21 18:11:36 -03:00
parent 06f1df9d77
commit 00fc961651
8 changed files with 749 additions and 43 deletions

View File

@@ -1,7 +1,9 @@
import { t } from "../i18n.mjs";
import { emit } from "../output.mjs";
import { writePrivateFileAtomic } from "../private-file.mjs";
import {
loadContexts,
loadContextsForExport,
saveContextsSecure,
deleteContextCredential,
migrateContextCredentials,
@@ -245,16 +247,15 @@ export function registerContexts(program) {
.command("export")
.description("Export contexts to JSON")
.option("--out <path>", "Output file path (default: stdout)")
.option("--no-secrets", "Omit API keys from export")
.option("--no-secrets", "Omit credentials from export (safe default)")
.option("--include-secrets", "Explicitly include plaintext credentials in the export")
.action(async (opts, cmd) => {
const cfg = loadContexts();
// Commander stores `--no-secrets` as `secrets === false`, never as `noSecrets`.
const redact = opts.secrets === false || opts.noSecrets === true;
const out = redact ? redactContextSecrets(cfg) : JSON.parse(JSON.stringify(cfg));
const includeSecrets = opts.includeSecrets === true && opts.secrets !== false;
const cfg = await loadContextsForExport({ includeSecrets });
const out = includeSecrets ? cfg : redactContextSecrets(cfg);
const json = JSON.stringify(out, null, 2);
if (opts.out) {
const { writeFileSync } = await import("node:fs");
writeFileSync(opts.out, json);
writePrivateFileAtomic(opts.out, json);
process.stdout.write(`Exported to ${opts.out}\n`);
} else {
process.stdout.write(json + "\n");

View File

@@ -15,8 +15,17 @@ function credentialShape(value) {
return { present: true, length: String(value).length };
}
const SENSITIVE_FIELD_RE =
/^(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|secret|client[_-]?secret|credential|authorization)$/i;
const SENSITIVE_FIELD_SUFFIX_RE =
/(?:^|_)(?:api_?key|access_key|secret_access_key|access_?token|refresh_?token|id_?token|auth_token|token|password|passphrase|secret|secret_key|secret_value|client_?secret|credential|authorization|private_key)$/;
function isSensitiveFieldName(key) {
const normalized = String(key || "")
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.replace(/[^A-Za-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
return SENSITIVE_FIELD_SUFFIX_RE.test(normalized);
}
/**
* Redact provider responses before they reach human or JSON output.
@@ -27,7 +36,7 @@ const SENSITIVE_FIELD_RE =
* for diagnostics; the value itself must never be printed.
*/
export function redactProviderResponse(value, key = "") {
if (SENSITIVE_FIELD_RE.test(key)) {
if (isSensitiveFieldName(key)) {
if (value === null || value === undefined || value === "") return null;
return typeof value === "string" ? credentialShape(value) : "[redacted]";
}
@@ -58,17 +67,31 @@ export function findConnectionFromResponse(body, selector) {
.trim()
.toLowerCase();
if (!needle) return null;
return (
rows.find((row) => String(row?.id || "").toLowerCase() === needle) ||
rows.find((row) =>
const selectUnique = (matches) => {
if (matches.length === 0) return null;
if (matches.length === 1) return matches[0];
const candidates = matches.map((row) => String(row?.id || "<missing-id>")).join(", ");
throw new Error(`Provider connection selector '${selector}' is ambiguous: ${candidates}`);
};
const exactId = selectUnique(
rows.filter((row) => String(row?.id || "").toLowerCase() === needle)
);
if (exactId) return exactId;
const idPrefix = selectUnique(
rows.filter((row) =>
String(row?.id || "")
.toLowerCase()
.startsWith(needle)
) ||
rows.find((row) => String(row?.name || "").toLowerCase() === needle) ||
rows.find((row) => String(row?.provider || "").toLowerCase() === needle) ||
null
)
);
if (idPrefix) return idPrefix;
const exactName = selectUnique(
rows.filter((row) => String(row?.name || "").toLowerCase() === needle)
);
if (exactName) return exactName;
return selectUnique(rows.filter((row) => String(row?.provider || "").toLowerCase() === needle));
}
/** Build the API body without accepting management auth as a provider secret. */
@@ -179,6 +202,48 @@ async function resolveRemoteConnection(selector, opts) {
return connection;
}
async function readRemoteConnectionById(id, opts) {
const response = await apiFetch(`/api/providers/${encodeURIComponent(id)}`, {
...targetOptions(opts),
acceptNotOk: true,
retry: false,
});
if (!response.ok)
throw new Error(`Provider mutation read-back failed: ${await readApiError(response)}`);
const body = await response.json().catch(() => ({}));
const connection = body?.connection;
if (!connection || String(connection.id || "") !== String(id)) {
throw new Error("Provider mutation read-back returned an unexpected connection.");
}
return connection;
}
async function confirmRemoteConnectionRemoved(id, opts) {
const response = await apiFetch(`/api/providers/${encodeURIComponent(id)}`, {
...targetOptions(opts),
acceptNotOk: true,
retry: false,
});
if (response.status === 404) return;
if (!response.ok) {
throw new Error(`Provider removal read-back failed: ${await readApiError(response)}`);
}
const body = await response.json().catch(() => ({}));
if (body?.connection) {
throw new Error("Provider removal read-back found the connection still present.");
}
throw new Error("Provider removal read-back returned an unexpected success response.");
}
function verifyConnectionFields(connection, expected) {
for (const [field, value] of Object.entries(expected)) {
if (value !== undefined && connection?.[field] !== value) {
throw new Error(`Provider mutation read-back did not persist field '${field}'.`);
}
}
return connection;
}
export async function runProviderAddCommand(provider, opts = {}) {
const normalized = String(provider || "").trim();
if (!normalized) {
@@ -241,9 +306,18 @@ export async function runProviderAddCommand(provider, opts = {}) {
return statusToExitCode(response.status);
}
const body = await response.json().catch(() => ({}));
const created = body?.connection;
if (!created?.id) throw new Error("Provider create response did not include a connection id.");
const verified = verifyConnectionFields(await readRemoteConnectionById(created.id, opts), {
provider: payload.provider,
name: payload.name,
defaultModel: payload.defaultModel,
priority: payload.priority,
});
if (!opts.silent) {
if (opts.json) console.log(JSON.stringify(redactProviderResponse(body), null, 2));
else printSuccess(`Added provider connection '${body?.connection?.name || payload.name}'.`);
if (opts.json) {
console.log(JSON.stringify(redactProviderResponse({ connection: verified }), null, 2));
} else printSuccess(`Added provider connection '${verified.name || payload.name}'.`);
}
return 0;
} catch (error) {
@@ -271,6 +345,29 @@ export async function runProviderImportCommand(file, opts = {}) {
printError("Provider import file contains no entries.");
return 2;
}
const existingByProviderAndName = new Map();
if (!opts.dryRun) {
try {
const response = await listRemoteConnections(opts);
if (!response.ok) {
printError(await readApiError(response));
return statusToExitCode(response.status);
}
const body = await response.json().catch(() => ({}));
const connections = Array.isArray(body?.connections) ? body.connections : [];
for (const connection of connections) {
const key = `${String(connection?.provider || "").toLowerCase()}\0${String(
connection?.name || connection?.provider || ""
).toLowerCase()}`;
if (!existingByProviderAndName.has(key)) {
existingByProviderAndName.set(key, connection);
}
}
} catch (error) {
printError(error instanceof Error ? error.message : String(error));
return 1;
}
}
const results = [];
for (const entry of entries) {
if (!entry || typeof entry !== "object" || !entry.provider) {
@@ -278,16 +375,48 @@ export async function runProviderImportCommand(file, opts = {}) {
if (!opts.continueOnError) break;
continue;
}
const code = await runProviderAddCommand(entry.provider, {
...opts,
...entry,
const provider = String(entry.provider).trim();
const name = String(entry.name || provider).trim();
const identityKey = `${provider.toLowerCase()}\0${name.toLowerCase()}`;
const existing = existingByProviderAndName.get(identityKey);
if (existing) {
const result = {
provider,
name,
ok: true,
status: "skipped_existing",
connectionId: existing.id,
};
results.push(result);
if (!opts.json) printInfo(`Skipped existing provider connection '${name}'.`);
continue;
}
// Import files contain provider data, never command/control-plane options.
// Keep the management target, context and authentication exclusively from
// the CLI invocation so an imported document cannot redirect credentials.
const importedProviderOptions = {
name: entry.name,
defaultModel: entry.defaultModel,
priority: entry.priority,
providerSpecificData: entry.providerSpecificData,
credential: entry.apiKey ?? entry.credential,
allowNoCredential: entry.allowNoCredential ?? opts.allowNoCredential,
};
const code = await runProviderAddCommand(provider, {
...opts,
...importedProviderOptions,
dryRun: opts.dryRun,
yes: true,
silent: true,
allowNoCredential: entry.allowNoCredential ?? opts.allowNoCredential,
});
results.push({ provider: entry.provider, ok: code === 0, code });
results.push({
provider,
name,
ok: code === 0,
code,
status: code === 0 ? "created" : "error",
});
if (code === 0) existingByProviderAndName.set(identityKey, { provider, name });
if (code !== 0 && !opts.continueOnError) break;
}
if (opts.json) console.log(JSON.stringify({ file, results }, null, 2));
@@ -340,6 +469,7 @@ export async function runProviderRemoveCommand(selector, opts = {}) {
printError(await readApiError(response));
return statusToExitCode(response.status);
}
await confirmRemoteConnectionRemoved(connection.id, opts);
if (opts.json)
console.log(JSON.stringify(redactProviderResponse({ removed: connection }), null, 2));
else printSuccess(`Removed provider connection '${connection.name || connection.id}'.`);
@@ -351,12 +481,24 @@ export async function runProviderRemoveCommand(selector, opts = {}) {
}
export async function runProviderEditCommand(selector, opts = {}) {
if (opts.active === true && opts.inactive === true) {
printError("--active and --inactive cannot be used together.");
return 2;
}
let parsedPriority;
if (opts.priority !== undefined) {
parsedPriority = Number(opts.priority);
if (!Number.isInteger(parsedPriority) || parsedPriority < 1) {
printError("--priority must be a positive integer.");
return 2;
}
}
try {
const connection = await resolveRemoteConnection(selector, opts);
const body = {};
if (opts.name !== undefined) body.name = opts.name;
if (opts.defaultModel !== undefined) body.defaultModel = opts.defaultModel || null;
if (opts.priority !== undefined) body.priority = Number(opts.priority);
if (parsedPriority !== undefined) body.priority = parsedPriority;
if (opts.active !== undefined) body.isActive = Boolean(opts.active);
if (opts.inactive !== undefined) body.isActive = false;
const credential = await resolveProviderCredential(opts, { prompt: false });
@@ -388,9 +530,16 @@ export async function runProviderEditCommand(selector, opts = {}) {
printError(await readApiError(response));
return statusToExitCode(response.status);
}
const result = await response.json().catch(() => ({}));
if (opts.json) console.log(JSON.stringify(redactProviderResponse(result), null, 2));
else printSuccess(`Updated provider connection '${connection.name || connection.id}'.`);
await response.json().catch(() => ({}));
const expected = { ...body };
delete expected.apiKey;
const verified = verifyConnectionFields(
await readRemoteConnectionById(connection.id, opts),
expected
);
if (opts.json) {
console.log(JSON.stringify(redactProviderResponse({ connection: verified }), null, 2));
} else printSuccess(`Updated provider connection '${connection.name || connection.id}'.`);
return 0;
} catch (error) {
printError(error instanceof Error ? error.message : String(error));

View File

@@ -1,6 +1,7 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
import { join, dirname } from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { resolveDataDir } from "./data-dir.mjs";
import { writePrivateFileAtomic } from "./private-file.mjs";
const CONFIG_VERSION = 1;
const KEYCHAIN_SERVICE = "omniroute-cli";
@@ -124,17 +125,44 @@ export function loadContexts() {
return readConfigFile();
}
/**
* Load contexts for an explicit export operation.
*
* The persisted file intentionally contains only `credentialRef` for
* keychain-backed contexts. Secret-bearing exports therefore have to hydrate
* every referenced credential first. Fail closed when any reference cannot be
* resolved so `--include-secrets` never produces a silently incomplete backup.
*/
export async function loadContextsForExport({ includeSecrets = false } = {}) {
const cfg = readConfigFile();
if (!includeSecrets) return cfg;
await hydrateCredentialCache(cfg);
const out = JSON.parse(JSON.stringify(cfg));
const contexts = out.contexts || out.profiles || {};
const unresolved = [];
for (const [name, context] of Object.entries(contexts)) {
if (!context || typeof context !== "object" || !context.credentialRef) continue;
const credential = credentialForContext(context);
if (!credential) {
unresolved.push(name);
continue;
}
Object.assign(context, credential);
}
if (unresolved.length > 0) {
throw new Error(`Cannot include keychain credentials for context(s): ${unresolved.join(", ")}`);
}
return out;
}
/**
* Synchronous compatibility writer. New credential-bearing code should use
* `saveContextsSecure()` so tokens are moved to the OS keychain when possible.
*/
export function saveContexts(cfg) {
const path = configPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(cfg, null, 2));
try {
chmodSync(path, 0o600);
} catch {}
writePrivateFileAtomic(path, JSON.stringify(cfg, null, 2));
}
/** Stable keychain reference; the reference itself is safe to persist in JSON. */

49
bin/cli/private-file.mjs Normal file
View File

@@ -0,0 +1,49 @@
import {
fchmodSync,
closeSync,
constants,
fsyncSync,
mkdirSync,
openSync,
renameSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { randomBytes } from "node:crypto";
import { basename, dirname, join } from "node:path";
/**
* Atomically replace a credential-bearing file without following a destination
* symlink. The temporary file is private from its first filesystem operation.
*/
export function writePrivateFileAtomic(path, content, { mode = 0o600 } = {}) {
const directory = dirname(path);
mkdirSync(directory, { recursive: true, mode: 0o700 });
const suffix = randomBytes(8).toString("hex");
const temporary = join(directory, `.${basename(path)}.${process.pid}.${suffix}.tmp`);
let descriptor;
try {
descriptor = openSync(
temporary,
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
mode
);
writeFileSync(descriptor, String(content), "utf8");
fchmodSync(descriptor, mode);
fsyncSync(descriptor);
closeSync(descriptor);
descriptor = undefined;
renameSync(temporary, path);
} catch (error) {
if (descriptor !== undefined) {
try {
closeSync(descriptor);
} catch {}
}
try {
unlinkSync(temporary);
} catch {}
throw error;
}
}

View File

@@ -41,6 +41,12 @@ is retained for controlled local use. `providers remove` requires `--yes` on a
non-interactive terminal, and all five commands honor the active context or the
global `--base-url`/`--api-key` options.
Provider selectors reject ambiguous ID prefixes, names or provider names; use a
full connection ID when several connections match. Create and edit commands read
the saved connection back, and removal verifies that it is no longer readable.
An import skips an existing provider/name pair. Imported entries cannot override
the management endpoint, context or management credentials supplied to the CLI.
For the one-time, hand-written base setup of the two richest integrations, see the
per-tool deep dives:

View File

@@ -393,19 +393,23 @@ omniroute contexts remove stg --yes
> revoke the token on the server with `omniroute tokens revoke <id>` to actually
> kill access.
**Export / import** contexts (e.g. to move them between machines). New contexts persist
only a keychain reference; credentials are not copied into the export when the OS
keychain is available:
**Export / import** contexts (e.g. to move them between machines). Exports omit
credentials by default, including credentials stored by the file fallback. Use
`--include-secrets` explicitly when a portable credential-bearing backup is needed:
```bash
omniroute contexts export --out contexts.json # default: stdout
omniroute contexts export --out contexts.json # redacted; default destination: stdout
omniroute contexts export --include-secrets --out private-contexts.json
omniroute contexts import contexts.json # overwrite; --merge to keep existing
omniroute contexts migrate --yes # move legacy plaintext tokens to keychain
```
On headless systems without a usable OS keychain, the CLI falls back to
`config.json` with mode `0600` and prints a one-time warning. Treat exports from
that fallback (and any legacy config before migration) as secret material.
`--include-secrets` resolves keychain references before exporting and fails if any
referenced credential cannot be read. `--no-secrets` always takes precedence.
Export files are written atomically with mode `0600`. Treat an explicit
secret-bearing export as secret material. On headless systems without a usable OS
keychain, the CLI falls back to `config.json` with mode `0600` and prints a
one-time warning; a default export remains redacted in this mode.
---

View File

@@ -1,6 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import {
lstatSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -49,6 +57,25 @@ test("saveContexts persiste e loadContexts relê", async () => {
assert.equal(cfg2.contexts.test?.baseUrl, "http://test:9999");
});
test("saveContexts replaces a hostile symlink atomically with a private regular file", async () => {
const { configPath, saveContexts } = await import("../../bin/cli/contexts.mjs");
const path = configPath();
const victim = join(tmpDir, "victim.json");
rmSync(path, { force: true });
writeFileSync(victim, "do-not-overwrite");
symlinkSync(victim, path);
saveContexts({
version: 1,
currentContext: "default",
contexts: { default: { baseUrl: "http://localhost:20128", apiKey: null } },
});
assert.equal(readFileSync(victim, "utf8"), "do-not-overwrite");
assert.equal(lstatSync(path).isSymbolicLink(), false);
assert.equal(statSync(path).mode & 0o777, 0o600);
});
test("resolveActiveContext retorna contexto ativo", async () => {
const { resolveActiveContext, loadContexts, saveContexts } =
await import("../../bin/cli/contexts.mjs");
@@ -177,3 +204,156 @@ test("registerContexts registers the singular `context` alias", async () => {
registerContexts(fakeProgram);
assert.equal(aliasName, "context");
});
test("context export --no-secrets overrides explicit inclusion through the real command", async () => {
const { saveContexts } = await import("../../bin/cli/contexts.mjs");
const { createProgram } = await import("../../bin/cli/program.mjs");
saveContexts({
version: 1,
currentContext: "remote",
contexts: {
remote: {
baseUrl: "https://remote.example.com",
accessToken: "oma-command-secret",
apiKey: "sk-command-secret",
},
},
});
const chunks: string[] = [];
const originalWrite = process.stdout.write;
process.stdout.write = ((chunk: string | Uint8Array) => {
chunks.push(String(chunk));
return true;
}) as typeof process.stdout.write;
try {
await createProgram().parseAsync([
"node",
"omniroute",
"context",
"export",
"--no-secrets",
"--include-secrets",
]);
} finally {
process.stdout.write = originalWrite;
}
const exported = chunks.join("");
assert.ok(!exported.includes("oma-command-secret"));
assert.ok(!exported.includes("sk-command-secret"));
assert.equal(JSON.parse(exported).contexts.remote.apiKey, null);
});
test("context export --include-secrets resolves keychain-backed credentials", async () => {
const { loadContexts, saveContextsSecure, setContextKeychainBackendForTests } =
await import("../../bin/cli/contexts.mjs");
const { createProgram } = await import("../../bin/cli/program.mjs");
const entries = new Map<string, string>();
const fakeKeychain = {
async getPassword(_service: string, account: string) {
return entries.get(account) || null;
},
async setPassword(_service: string, account: string, value: string) {
entries.set(account, value);
},
async deletePassword(_service: string, account: string) {
entries.delete(account);
return true;
},
};
await setContextKeychainBackendForTests(fakeKeychain);
const cfg = loadContexts();
cfg.contexts.keychainExport = {
baseUrl: "https://keychain.example.com",
accessToken: "oma-keychain-export",
apiKey: "sk-keychain-export",
};
await saveContextsSecure(cfg);
const chunks: string[] = [];
const originalWrite = process.stdout.write;
process.stdout.write = ((chunk: string | Uint8Array) => {
chunks.push(String(chunk));
return true;
}) as typeof process.stdout.write;
try {
await createProgram().parseAsync([
"node",
"omniroute",
"context",
"export",
"--include-secrets",
]);
} finally {
process.stdout.write = originalWrite;
await setContextKeychainBackendForTests(null);
}
const exported = JSON.parse(chunks.join(""));
assert.equal(exported.contexts.keychainExport.accessToken, "oma-keychain-export");
assert.equal(exported.contexts.keychainExport.apiKey, "sk-keychain-export");
});
test("secret-bearing context export fails closed when a keychain reference is unavailable", async () => {
const { loadContextsForExport, saveContexts, setContextKeychainBackendForTests } =
await import("../../bin/cli/contexts.mjs");
await setContextKeychainBackendForTests(null);
saveContexts({
version: 1,
currentContext: "missingKeychain",
contexts: {
missingKeychain: {
baseUrl: "https://keychain.example.com",
credentialRef: "omniroute-cli:context:missingKeychain",
},
},
});
await assert.rejects(
loadContextsForExport({ includeSecrets: true }),
/Cannot include keychain credentials for context\(s\): missingKeychain/
);
});
test("context export defaults to a redacted atomic private output file", async () => {
const { saveContexts } = await import("../../bin/cli/contexts.mjs");
const { createProgram } = await import("../../bin/cli/program.mjs");
saveContexts({
version: 1,
currentContext: "remote",
contexts: {
remote: {
baseUrl: "https://remote.example.com",
accessToken: "oma-file-secret",
apiKey: "sk-file-secret",
},
},
});
const victim = join(tmpDir, "export-victim.json");
const output = join(tmpDir, "contexts-export.json");
rmSync(output, { force: true });
writeFileSync(victim, "do-not-overwrite");
symlinkSync(victim, output);
const stdout: string[] = [];
const originalWrite = process.stdout.write;
process.stdout.write = ((chunk: string | Uint8Array) => {
stdout.push(String(chunk));
return true;
}) as typeof process.stdout.write;
try {
await createProgram().parseAsync(["node", "omniroute", "context", "export", "--out", output]);
} finally {
process.stdout.write = originalWrite;
}
assert.equal(readFileSync(victim, "utf8"), "do-not-overwrite");
assert.equal(lstatSync(output).isSymbolicLink(), false);
assert.equal(statSync(output).mode & 0o777, 0o600);
const exported = readFileSync(output, "utf8");
assert.ok(!exported.includes("oma-file-secret"));
assert.ok(!exported.includes("sk-file-secret"));
assert.match(stdout.join(""), /Exported to/);
});

View File

@@ -1,5 +1,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
buildProviderPayload,
@@ -7,8 +11,18 @@ import {
redactProviderResponse,
resolveProviderCredential,
runProviderAddCommand,
runProviderEditCommand,
runProviderImportCommand,
runProviderRemoveCommand,
} from "../../../bin/cli/commands/provider-crud.mjs";
async function listen(server: ReturnType<typeof createServer>): Promise<string> {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assert.ok(address && typeof address === "object");
return `http://127.0.0.1:${address.port}`;
}
test("provider payload separates management auth from provider credential", () => {
const payload = buildProviderPayload(
"glm",
@@ -47,6 +61,18 @@ test("provider selector resolves id, prefix, name, and provider", () => {
assert.equal(findConnectionFromResponse(body, "missing"), null);
});
test("provider selector rejects ambiguous prefixes instead of mutating the first match", () => {
const body = {
connections: [
{ id: "abc-123", name: "Work GLM", provider: "glm" },
{ id: "abc-456", name: "Backup GLM", provider: "glm" },
],
};
assert.throws(() => findConnectionFromResponse(body, "abc"), /ambiguous.*abc-123.*abc-456/i);
assert.throws(() => findConnectionFromResponse(body, "glm"), /ambiguous.*abc-123.*abc-456/i);
});
test("provider credential can be resolved from a validated environment name", async () => {
const previous = process.env.TEST_PROVIDER_SECRET;
process.env.TEST_PROVIDER_SECRET = "secret-from-env";
@@ -91,6 +117,11 @@ test("provider JSON output redacts raw credentials recursively", () => {
apiKey: "provider-secret",
providerSpecificData: { client_secret: "oauth-secret" },
credentialRef: "omniroute-cli:context:remote",
awsSecretAccessKey: "aws-secret",
privateKey: "private-key-material",
secret_key: "snake-secret",
APIKEY: "compact-key",
clientsecret: "compact-secret",
},
token: "management-secret",
});
@@ -101,6 +132,11 @@ test("provider JSON output redacts raw credentials recursively", () => {
apiKey: { present: true, length: 15 },
providerSpecificData: { client_secret: { present: true, length: 12 } },
credentialRef: "omniroute-cli:context:remote",
awsSecretAccessKey: { present: true, length: 10 },
privateKey: { present: true, length: 20 },
secret_key: { present: true, length: 12 },
APIKEY: { present: true, length: 11 },
clientsecret: { present: true, length: 14 },
},
token: { present: true, length: 17 },
});
@@ -134,3 +170,256 @@ test("provider add dry-run redacts provider-specific secrets", async () => {
assert.ok(!serialized.includes("oauth-secret"));
assert.match(serialized, /client_secret/);
});
test("provider import cannot override the management target or authentication", async () => {
const requests: Array<{
method?: string;
url?: string;
authorization?: string;
body?: unknown;
}> = [];
let attackerRequests = 0;
const managementServer = createServer((request, response) => {
const chunks: Buffer[] = [];
request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
request.on("end", () => {
requests.push({
method: request.method,
url: request.url,
authorization: request.headers.authorization,
body: chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : undefined,
});
response.writeHead(request.method === "POST" ? 201 : 200, {
"content-type": "application/json",
});
response.end(JSON.stringify({ connection: { id: "conn-1", provider: "glm", name: "work" } }));
});
});
const attackerServer = createServer((_request, response) => {
attackerRequests += 1;
response.writeHead(201, { "content-type": "application/json" });
response.end(JSON.stringify({ connection: { id: "attacker" } }));
});
const directory = mkdtempSync(join(tmpdir(), "omniroute-provider-import-"));
try {
const managementUrl = await listen(managementServer);
const attackerUrl = await listen(attackerServer);
const file = join(directory, "providers.json");
writeFileSync(
file,
JSON.stringify({
provider: "glm",
name: "work",
credential: "secondary-provider-secret",
baseUrl: attackerUrl,
apiKey: "imported-provider-secret",
context: "attacker-context",
oauth: false,
})
);
assert.equal(
await runProviderImportCommand(file, {
baseUrl: managementUrl,
apiKey: "management-token",
}),
0
);
assert.equal(attackerRequests, 0);
assert.equal(requests.length, 3);
assert.deepEqual(
requests.map(({ method, url }) => ({ method, url })),
[
{ method: "GET", url: "/api/providers?limit=5000" },
{ method: "POST", url: "/api/providers" },
{ method: "GET", url: "/api/providers/conn-1" },
]
);
assert.equal(requests[0]?.authorization, "Bearer management-token");
assert.equal(requests[1]?.authorization, "Bearer management-token");
assert.equal(requests[2]?.authorization, "Bearer management-token");
assert.deepEqual(requests[1]?.body, {
provider: "glm",
name: "work",
apiKey: "imported-provider-secret",
});
} finally {
managementServer.close();
attackerServer.close();
rmSync(directory, { recursive: true, force: true });
}
});
test("provider edit rejects contradictory state flags and invalid priority before networking", async () => {
const unreachable = "http://127.0.0.1:1";
assert.equal(
await runProviderEditCommand("conn-1", {
baseUrl: unreachable,
active: true,
inactive: true,
}),
2
);
assert.equal(
await runProviderEditCommand("conn-1", {
baseUrl: unreachable,
priority: 0,
}),
2
);
});
test("provider import is idempotent and reports an existing connection without mutating", async () => {
let mutations = 0;
const server = createServer((request, response) => {
if (request.method !== "GET") mutations += 1;
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
connections: [{ id: "conn-1", provider: "glm", name: "work" }],
})
);
});
const directory = mkdtempSync(join(tmpdir(), "omniroute-provider-idempotent-"));
const output: string[] = [];
const originalLog = console.log;
try {
const baseUrl = await listen(server);
const file = join(directory, "providers.json");
writeFileSync(
file,
JSON.stringify({ provider: "glm", name: "work", apiKey: "provider-secret" })
);
console.log = (...args: unknown[]) => output.push(args.join(" "));
assert.equal(
await runProviderImportCommand(file, {
baseUrl,
apiKey: "management-token",
json: true,
}),
0
);
assert.equal(mutations, 0);
const result = JSON.parse(output.join("\n"));
assert.deepEqual(result.results, [
{
provider: "glm",
name: "work",
ok: true,
status: "skipped_existing",
connectionId: "conn-1",
},
]);
} finally {
console.log = originalLog;
server.close();
rmSync(directory, { recursive: true, force: true });
}
});
test("provider edit confirms the persisted state with a read-back", async () => {
const methods: string[] = [];
let connection = {
id: "conn-1",
provider: "glm",
name: "work",
priority: 1,
isActive: false,
};
const server = createServer((request, response) => {
const chunks: Buffer[] = [];
request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
request.on("end", () => {
methods.push(`${request.method} ${request.url}`);
if (request.method === "PUT") {
connection = { ...connection, ...JSON.parse(Buffer.concat(chunks).toString("utf8")) };
}
response.writeHead(200, { "content-type": "application/json" });
response.end(
request.url === "/api/providers?limit=5000"
? JSON.stringify({ connections: [connection] })
: JSON.stringify({ connection })
);
});
});
const output: string[] = [];
const originalLog = console.log;
try {
const baseUrl = await listen(server);
console.log = (...args: unknown[]) => output.push(args.join(" "));
assert.equal(
await runProviderEditCommand("conn-1", {
baseUrl,
apiKey: "management-token",
name: "renamed",
priority: 3,
active: true,
json: true,
}),
0
);
assert.deepEqual(methods, [
"GET /api/providers?limit=5000",
"PUT /api/providers/conn-1",
"GET /api/providers/conn-1",
]);
const result = JSON.parse(output.join("\n"));
assert.equal(result.connection.name, "renamed");
assert.equal(result.connection.priority, 3);
assert.equal(result.connection.isActive, true);
} finally {
console.log = originalLog;
server.close();
}
});
test("provider removal confirms that the connection is no longer readable", async () => {
const methods: string[] = [];
let removed = false;
const connection = { id: "conn-1", provider: "glm", name: "work" };
const server = createServer((request, response) => {
methods.push(`${request.method} ${request.url}`);
if (request.method === "DELETE") removed = true;
if (request.method === "GET" && request.url === "/api/providers/conn-1" && removed) {
response.writeHead(404, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "Connection not found" }));
return;
}
response.writeHead(200, { "content-type": "application/json" });
response.end(
request.url === "/api/providers?limit=5000"
? JSON.stringify({ connections: [connection] })
: JSON.stringify({ removed: true })
);
});
const output: string[] = [];
const originalLog = console.log;
try {
const baseUrl = await listen(server);
console.log = (...args: unknown[]) => output.push(args.join(" "));
assert.equal(
await runProviderRemoveCommand("conn-1", {
baseUrl,
apiKey: "management-token",
yes: true,
json: true,
}),
0
);
assert.deepEqual(methods, [
"GET /api/providers?limit=5000",
"DELETE /api/providers/conn-1",
"GET /api/providers/conn-1",
]);
assert.equal(JSON.parse(output.join("\n")).removed.id, "conn-1");
} finally {
console.log = originalLog;
server.close();
}
});