Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ pnpm mcode provider list --json

`--context-limit` and `--output-limit` each accept a positive safe integer (at most `9007199254740991`). Either flag can be used independently. The same limits apply to every repeated `--model`; only the first model is tested and selected by `--use`. The JSON list shows the configured values as `contextLimit` and `maxOutputTokens`. Without these flags, the existing defaults remain unchanged (unknown custom models currently fall back to 200,000 context tokens and 16,384 output tokens). Model discovery does not infer your local server's context size.

`--api-key-env` reads the current environment variable value and stores that value in the active profile's `config.yaml`; it does not save an environment-variable reference. The file still contains plaintext credentials. On POSIX systems, config writes and temporary copies use `0600`. When loading existing files, MCode removes group/other access while preserving the owner's permissions; already-private files such as `0400` or `0600` do not require a permission change. Loading fails if an unsafe main config cannot be restricted. Older migration backups are also checked, but inspection or repair failures produce a warning identifying the directory or backup that needs manual attention rather than preventing the main config from loading. Windows file modes do not provide equivalent ACL protection; restrict access to the profile directory using Windows permissions.

[Live acceptance](verification.md) separately verified MiniMax Token Plan and one configured BYOK provider. This is not a guarantee for every compatible service.

## 3. Search and image input
Expand Down
41 changes: 36 additions & 5 deletions packages/config/src/byok-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
// (they are part of the Config surface); the back edge here is type-only, so
// there is no runtime import cycle.

import { createHash } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import yaml from 'js-yaml';
import { restrictConfigFileSync, writePrivateConfigFileSync } from './private-config-file.js';

import type {
CustomProvidersConfig,
Expand Down Expand Up @@ -243,10 +244,9 @@ export function migrateLegacyByokProvidersOnDisk(
writeLegacyMigrationMarker(raw, marker);
try {
const backupPath = backupConfigForMigration(configPath);
fs.writeFileSync(
writePrivateConfigFileSync(
configPath,
yaml.dump(raw, { indent: 2, lineWidth: -1, noRefs: true }),
'utf-8',
);
return { migrated: true, migratedProviders, backupPath };
} catch (err) {
Expand Down Expand Up @@ -474,12 +474,43 @@ function rewriteNexusModelProvider(
return true;
}

/** Repair old backups without making archival maintenance a config-load dependency. */
export function restrictLegacyByokBackups(configPath: string): void {
if (process.platform === 'win32') return;
const directory = path.dirname(configPath);
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(directory, { withFileTypes: true });
} catch {
console.warn(
`[config] Could not inspect legacy BYOK backups in ${JSON.stringify(directory)}. ` +
'Backup permissions were not verified; check directory access and restrict backup permissions manually.',
);
return;
}
for (const entry of entries) {
if (entry.isFile() && entry.name.startsWith(LEGACY_BYOK_BACKUP_PREFIX)) {
const backupPath = path.join(directory, entry.name);
try {
restrictConfigFileSync(backupPath);
} catch {
// Do not expose arbitrary error messages that could contain config content.
console.warn(
`[config] Could not restrict legacy BYOK backup ${JSON.stringify(backupPath)}. ` +
'It may still be readable by other users; restrict its permissions manually.',
);
}
}
}
}

function backupConfigForMigration(configPath: string): string {
const backupPath = path.join(
path.dirname(configPath),
`${LEGACY_BYOK_BACKUP_PREFIX}${Date.now()}`,
`${LEGACY_BYOK_BACKUP_PREFIX}${Date.now()}.${randomUUID()}`,
);
fs.copyFileSync(configPath, backupPath);
restrictConfigFileSync(configPath);
writePrivateConfigFileSync(backupPath, fs.readFileSync(configPath), true);
return backupPath;
}

Expand Down
12 changes: 8 additions & 4 deletions packages/config/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type RunawayGuardSettings,
} from "./runaway-guard-config.js";
import fs from "node:fs";
import { restrictConfigFileSync, writePrivateConfigFileSync } from "./private-config-file.js";
import path from "node:path";
import os from "node:os";
import { spawnSync } from "node:child_process";
Expand All @@ -13,6 +14,7 @@ import {
applyManagedMinimaxContextLimits,
applyRequiredProviderOverrides,
migrateLegacyByokProvidersOnDisk,
restrictLegacyByokBackups,
normalizeLegacyThinkingEfforts,
parseCustomProvidersConfig,
parseMinimaxApiConfig,
Expand Down Expand Up @@ -1648,10 +1650,9 @@ function syncManagedPresetBaseUrl(configPath: string): void {
return;

(options as Record<string, unknown>).baseURL = presetBaseURL;
fs.writeFileSync(
writePrivateConfigFileSync(
configPath,
yaml.dump(raw, { indent: 2, lineWidth: -1, noRefs: true }),
"utf-8",
);
}

Expand Down Expand Up @@ -1830,7 +1831,8 @@ function ensureConfigFile(): void {
const defaultDataDir = resolveDataDir({ homeDir: os.homedir() });
const defaultConfigPath = path.join(defaultDataDir, "config.yaml");
if (configPath !== defaultConfigPath && fs.existsSync(defaultConfigPath)) {
fs.copyFileSync(defaultConfigPath, configPath);
restrictConfigFileSync(defaultConfigPath);
writePrivateConfigFileSync(configPath, fs.readFileSync(defaultConfigPath), true);
}
return;
}
Expand All @@ -1841,7 +1843,7 @@ function ensureConfigFile(): void {
provider: managedPresetBaseUrlSyncEnabled ? preset.provider : undefined,
defaultModel: preset.defaultModel,
});
fs.writeFileSync(configPath, content, "utf-8");
writePrivateConfigFileSync(configPath, content, true);
}

function readConfigFile(configPath = getConfigPath()): Record<string, unknown> {
Expand Down Expand Up @@ -1897,6 +1899,8 @@ export function setManagedPresetBaseUrlSyncEnabled(enabled: boolean): void {
}

export function prepareConfigFileForRead(configPath: string): void {
restrictConfigFileSync(configPath);
restrictLegacyByokBackups(configPath);
if (legacyByokProviderMigrationEnabled) {
migrateLegacyByokProvidersOnDisk(
configPath,
Expand Down
3 changes: 2 additions & 1 deletion packages/config/src/cu-backend-io.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import yaml from 'js-yaml';
import { writePrivateConfigFileSync } from './private-config-file.js';
import { getConfig, getConfigPath, resetConfig } from './config.js';
import {
type CuBackend,
Expand Down Expand Up @@ -51,7 +52,7 @@ export function setCuBackend(value: CuBackend): void {

raw.cuBackend = value;
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, yaml.dump(raw), 'utf-8');
writePrivateConfigFileSync(configPath, yaml.dump(raw));
resetConfig();
}

Expand Down
29 changes: 12 additions & 17 deletions packages/config/src/local-model-provider-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ async function withLockedConfig<T>(
try {
await fs.promises.mkdir(dirname(configPath), { recursive: true });
await fs.promises.writeFile(configPath, '', { flag: 'a', mode: LOCAL_CONFIG_FILE_MODE });
await fs.promises.chmod(configPath, LOCAL_CONFIG_FILE_MODE);
release = await lockfile.lock(configPath, {
stale: 10_000,
retries: { retries: 20, factor: 1, minTimeout: 5, maxTimeout: 25 },
Expand Down Expand Up @@ -257,26 +258,24 @@ async function withLockedConfig<T>(

async function atomicWriteFile(filePath: string, content: string): Promise<void> {
const tmpPath = join(dirname(filePath), `.config-tmp-${randomBytes(6).toString('hex')}`);
let created = false;
try {
const mode = await readFilePermissionMode(filePath);
await fs.promises.writeFile(tmpPath, content, { encoding: 'utf-8', mode });
await fs.promises.chmod(tmpPath, mode);
const mode = LOCAL_CONFIG_FILE_MODE;
const temporary = await fs.promises.open(tmpPath, 'wx', mode);
created = true;
try {
await temporary.writeFile(content, 'utf-8');
await temporary.chmod(mode);
} finally {
await temporary.close();
}
await fs.promises.rename(tmpPath, filePath);
} catch {
await fs.promises.unlink(tmpPath).catch(() => undefined);
if (created) await fs.promises.unlink(tmpPath).catch(() => undefined);
throw new LocalModelProviderConfigWriteError();
}
}

async function readFilePermissionMode(filePath: string): Promise<number> {
try {
return (await fs.promises.stat(filePath)).mode & 0o777;
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') return LOCAL_CONFIG_FILE_MODE;
throw error;
}
}

function readLocalRawConfig(configPath: string): Record<string, unknown> {
if (!existsSync(configPath)) return {};
try {
Expand Down Expand Up @@ -337,10 +336,6 @@ function assertSafeConfigRecord(record: Record<string, unknown>): void {
}
}

function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error;
}

function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
33 changes: 33 additions & 0 deletions packages/config/src/private-config-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import fs from "node:fs";

/** Config documents and their copies can contain plaintext credentials. */
export const PRIVATE_CONFIG_FILE_MODE = 0o600;

/** Remove non-owner access without changing an owner's read-only policy. */
export function restrictConfigFileSync(filePath: string): void {
if (process.platform === "win32") return;
const mode = fs.statSync(filePath).mode;
// Already-private files may live on read-only mounts or be immutable.
if ((mode & 0o077) === 0) return;
fs.chmodSync(filePath, mode & 0o700);
}

/** Restrict access before truncating or writing any secret-bearing content. */
export function writePrivateConfigFileSync(
filePath: string,
content: string | Buffer,
exclusive = false,
): void {
const fd = fs.openSync(
filePath,
exclusive ? "wx" : "a",
PRIVATE_CONFIG_FILE_MODE,
);
try {
fs.fchmodSync(fd, PRIVATE_CONFIG_FILE_MODE);
fs.ftruncateSync(fd, 0);
fs.writeFileSync(fd, content);
} finally {
fs.closeSync(fd);
}
}
19 changes: 12 additions & 7 deletions packages/config/src/tui-status-line-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ export async function writeTuiStatusLineSetting(
): Promise<void> {
const configPath = join(dataDir, 'config.yaml');
let temporaryPath: string | undefined;
let temporaryCreated = false;
let release: (() => Promise<void>) | undefined;
try {
await fs.mkdir(dataDir, { recursive: true });
await fs.writeFile(configPath, '', { flag: 'a', mode: 0o600 });
await fs.chmod(configPath, 0o600);
// Replace the real file, preserving any symlink used to manage profile settings.
// Keep the temporary file on the target filesystem so rename remains atomic.
const targetPath = await fs.realpath(configPath);
Expand All @@ -33,20 +35,23 @@ export async function writeTuiStatusLineSetting(
if (items === undefined) delete nextTui.statusLine;
else nextTui.statusLine = [...items];
const next = { ...document, tui: nextTui };
const mode = (await fs.stat(targetPath)).mode & 0o777;
await fs.writeFile(temporaryPath, yaml.dump(next, { lineWidth: -1, noRefs: true }), {
encoding: 'utf8',
mode,
});
await fs.chmod(temporaryPath, mode);
const mode = 0o600;
const temporary = await fs.open(temporaryPath, 'wx', mode);
temporaryCreated = true;
try {
await temporary.writeFile(yaml.dump(next, { lineWidth: -1, noRefs: true }), 'utf8');
await temporary.chmod(mode);
} finally {
await temporary.close();
}
await fs.rename(temporaryPath, targetPath);
} catch {
// Parser errors can contain unrelated credentials from the config source.
throw new Error(
'Unable to save status line settings. Check config.yaml syntax and permissions.',
);
} finally {
if (temporaryPath) await fs.unlink(temporaryPath).catch(() => undefined);
if (temporaryCreated && temporaryPath) await fs.unlink(temporaryPath).catch(() => undefined);
await release?.().catch(() => undefined);
}
}
Expand Down
Loading
Loading