Skip to content
Open
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
47 changes: 47 additions & 0 deletions src/config/config-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,3 +554,50 @@ describe("user config file IO", () => {
warn.mockRestore();
});
});

describe("a config written by a newer build", () => {
// Regression: two builds share one `~/.atomic-agent/config.json`. The
// 0.3.0 build bumped it to v38 and the installed v0.2.2 release then
// died on every single command with "unsupported config version 38".
// An additive schema has no reason to make version skew fatal.
it("is read, not refused", () => {
const dir = mkdtempSync(join(tmpdir(), "atomic-newer-config-"));
const path = join(dir, "config.json");
writeFileSync(
path,
JSON.stringify({
version: USER_CONFIG_VERSION + 5,
localModels: { url: "http://127.0.0.1:9999" },
somethingFromTheFuture: { enabled: true },
}),
"utf8",
);
const parsed = ensureUserConfigFileSync(path);
expect(parsed.localModels.url).toBe("http://127.0.0.1:9999");
});

it("is never rewritten back down to this build's version", () => {
const dir = mkdtempSync(join(tmpdir(), "atomic-newer-config-"));
const path = join(dir, "config.json");
const future = {
version: USER_CONFIG_VERSION + 5,
localModels: { url: "http://127.0.0.1:9999" },
somethingFromTheFuture: { enabled: true },
};
writeFileSync(path, JSON.stringify(future), "utf8");
ensureUserConfigFileSync(path);
const onDisk = JSON.parse(readFileSync(path, "utf8")) as Record<
string,
unknown
>;
expect(onDisk.version).toBe(USER_CONFIG_VERSION + 5);
expect(onDisk.somethingFromTheFuture).toEqual({ enabled: true });
});

it("still refuses a version older than the oldest supported one", () => {
const dir = mkdtempSync(join(tmpdir(), "atomic-old-config-"));
const path = join(dir, "config.json");
writeFileSync(path, JSON.stringify({ version: 2 }), "utf8");
expect(() => ensureUserConfigFileSync(path)).toThrow(/unsupported config version/);
});
});
11 changes: 11 additions & 0 deletions src/config/config-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,17 @@ export function ensureUserConfigFileSync(path: string): UserConfigFile {
return USER_CONFIG_DEFAULTS;
}
const parsed = parseUserConfigFile(raw.parsed);
// A file written by a NEWER build is read and left exactly as it is.
// Rewriting it would silently delete whatever that build added — and
// since both builds share one `config.json`, the two would then take
// turns destroying each other's keys on every launch. Reading is safe
// (the schema is additive); writing is not ours to do.
if (
raw.originalVersion !== null &&
raw.originalVersion > USER_CONFIG_VERSION
) {
return parsed;
}
if (raw.originalVersion !== USER_CONFIG_VERSION) {
writeUserConfigFileSync(path, parsed);
process.stderr.write(
Expand Down
28 changes: 26 additions & 2 deletions src/config/config-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,24 @@ describe("parseUserConfigFile", () => {
).toBe(1);
});

it("rejects unsupported version", () => {
expect(() => parseUserConfigFile({ version: 99 })).toThrow(
it("reads a version newer than this build instead of refusing it", () => {
// Knowingly replaces "rejects unsupported version", which pinned
// `{version: 99}` as fatal. That is what made two builds sharing one
// `config.json` mutually exclusive: the newer one wrote v38 and the
// installed v0.2.2 then failed every command. The schema is additive,
// so a newer file parses fine — unknown keys are simply not read.
const parsed = parseUserConfigFile({
version: 99,
localModels: { url: "http://127.0.0.1:9999" },
});
expect(parsed.localModels.url).toBe("http://127.0.0.1:9999");
});

it("still rejects a non-integer version", () => {
expect(() => parseUserConfigFile({ version: "38" })).toThrow(
ConfigValidationError,
);
expect(() => parseUserConfigFile({ version: 38.5 })).toThrow(
ConfigValidationError,
);
});
Expand Down Expand Up @@ -992,4 +1008,12 @@ describe("parseUserConfigFile", () => {
}),
).toThrow(/timeoutMs/);
});
it("upgrades a v37 file to the current version untouched", () => {
// v38 only ADDED the optional `llm.runMode` sub-key, so a v37 file
// needs no migration code — absence already is the v37 behaviour.
const parsed = parseUserConfigFile({ version: 37 });
expect(parsed.version).toBe(USER_CONFIG_VERSION);
expect(USER_CONFIG_VERSION).toBe(38);
expect(parsed.llm?.runMode).toBeUndefined();
});
});
46 changes: 41 additions & 5 deletions src/config/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
parseUserLlmFileConfig,
type UserLlmFileConfig,
} from "./llm-config.js";
import type { UserLlmRunModeConfig } from "./llm-run-mode-config.js";

export type { ApprovalLevel } from "../approval/approval-level.js";
import type { DotenvLoadResult } from "./load-dotenv.js";
Expand Down Expand Up @@ -768,6 +769,14 @@ export interface AtomicAgentConfig {
probeThrottleMs?: number;
failureWindowMs?: number;
};
/**
* Operator run mode: `local` (llama-server only), `cloud` (cloud
* provider only) or `fusion` (cloud orchestrates, local executes).
* `activeTextProvider` stays authoritative — this block is additive
* and is reconciled by `resolveRunMode`. See AGENTS.md §"Run modes
* (Local / Cloud / Fusion)".
*/
runMode?: UserLlmRunModeConfig;
};
}

Expand Down Expand Up @@ -1412,7 +1421,12 @@ export interface UserConfigFile {
// is absent, a legacy `approvalRequired: false` maps to level 5 and
// `true`/absent maps to level 1 — both preserve the old behaviour
// exactly. The legacy key is never written back.
export const USER_CONFIG_VERSION = 37 as const;
// v38: new optional `llm.runMode` block — the operator run mode
// (`local` | `cloud` | `fusion`) plus the fusion cloud-share dial and
// the sub-runner target. Absence IS the v37 behaviour: it is an
// optional sub-key of an already-optional block, so no migration code
// exists; the bump only records the schema change.
export const USER_CONFIG_VERSION = 38 as const;

/**
* Config v21+ flips the full memory-v2 fabric on by default. Upgrades
Expand Down Expand Up @@ -1529,6 +1543,7 @@ const SUPPORTED_INPUT_VERSIONS: readonly number[] = [
34,
35,
36,
37,
USER_CONFIG_VERSION,
];

Expand Down Expand Up @@ -2646,10 +2661,31 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile {
}
const obj = raw as Record<string, unknown>;
const version = obj.version ?? USER_CONFIG_VERSION;
if (
typeof version !== "number" ||
!SUPPORTED_INPUT_VERSIONS.includes(version)
) {
if (typeof version !== "number" || !Number.isInteger(version)) {
throw new ConfigValidationError(
"version",
`expected an integer version; got ${JSON.stringify(version)}`,
);
}
// A version *newer* than this build is read, not refused.
//
// Every bump this schema has ever taken is additive: new keys arrive
// with defaults and the parser reads field by field, so a file written
// by a newer build parses correctly here — the keys this build does not
// know are simply not read, and `writeUserConfigFileSync` preserves
// unknown top-level keys rather than dropping them.
//
// Refusing was actively harmful. Two builds share one `config.json`,
// so the moment the newer one wrote its version the older one died on
// *every* command — `models status`, `config get`, the TUI — with a
// validation error naming 33 acceptable versions and no way out.
// Running two versions side by side is normal (a release plus a build
// under test), and an additive schema has no reason to make it fatal.
//
// The other half of this contract lives in `ensureUserConfigFileSync`,
// which must not rewrite a newer file back down to this build's shape —
// reading it is safe, overwriting would delete the newer build's keys.
if (version < USER_CONFIG_VERSION && !SUPPORTED_INPUT_VERSIONS.includes(version)) {
throw new ConfigValidationError(
"version",
`unsupported config version ${JSON.stringify(version)}; expected one of ${SUPPORTED_INPUT_VERSIONS.join(", ")}`,
Expand Down
8 changes: 8 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ export {
type UserLlmFallbackConfig,
type UserLlmProviderEntry,
} from "./llm-config.js";
export {
DEFAULT_FUSION_CLOUD_SHARE,
parseLlmRunModeConfig,
type RunModeName,
type RunModeSubRunners,
type UserLlmFusionConfig,
type UserLlmRunModeConfig,
} from "./llm-run-mode-config.js";
export type {
DotenvLoadResult,
DotenvReadFailure,
Expand Down
17 changes: 12 additions & 5 deletions src/config/llm-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { ConfigValidationError } from "./config-validation-error.js";
import {
parseLlmRunModeConfig,
type UserLlmRunModeConfig,
} from "./llm-run-mode-config.js";

export type UserLlmToolTransport = "auto" | "grammar" | "native_tools";

Expand Down Expand Up @@ -53,6 +57,7 @@ export type UserLlmFileConfig = {
toolTransport: UserLlmToolTransport;
providers: UserLlmProviderEntry[];
fallback?: UserLlmFallbackConfig;
runMode?: UserLlmRunModeConfig;
};

const PROVIDER_ID_RE = /^[a-z][a-z0-9-]{0,31}$/;
Expand Down Expand Up @@ -343,20 +348,22 @@ export function parseUserLlmFileConfig(
"expected auto|grammar|native_tools",
);
}
const providerIds = new Set(providers.map((p) => p.id));
const fallback =
obj.fallback === undefined || obj.fallback === null
? undefined
: parseLlmFallbackConfig(
obj.fallback,
new Set(providers.map((p) => p.id)),
"llm.fallback",
);
: parseLlmFallbackConfig(obj.fallback, providerIds, "llm.fallback");
const runMode =
obj.runMode === undefined || obj.runMode === null
? undefined
: parseLlmRunModeConfig(obj.runMode, providerIds, "llm.runMode");

return {
activeTextProvider,
activeEmbeddingProvider,
toolTransport: toolTransportRaw,
providers,
...(fallback ? { fallback } : {}),
...(runMode ? { runMode } : {}),
};
}
107 changes: 107 additions & 0 deletions src/config/llm-run-mode-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";

import { parseUserConfigFile, USER_CONFIG_VERSION } from "./config-schema.js";
import { DEFAULT_FUSION_CLOUD_SHARE } from "./llm-run-mode-config.js";

/** Two-provider file (one local leg, one cloud leg) plus a runMode block. */
const withRunMode = (runMode: unknown) => ({
version: USER_CONFIG_VERSION,
llm: {
activeTextProvider: "openrouter",
activeEmbeddingProvider: "local-llama",
toolTransport: "auto",
providers: [
{ id: "local-llama", kind: "llama-server", url: "http://127.0.0.1:19091" },
{ id: "openrouter", kind: "openrouter", defaultChatModel: "openai/gpt-4o-mini" },
],
runMode,
},
});

describe("llm-run-mode-config", () => {
it("round-trips a full runMode block", () => {
const parsed = parseUserConfigFile(
withRunMode({
mode: "fusion",
localProvider: "local-llama",
cloudProvider: "openrouter",
fusion: { cloudShare: 65, subRunners: "follow" },
}),
);
expect(parsed.llm?.runMode).toEqual({
mode: "fusion",
localProvider: "local-llama",
cloudProvider: "openrouter",
fusion: { cloudShare: 65, subRunners: "follow" },
});
});

it("omits runMode entirely when not configured", () => {
const parsed = parseUserConfigFile({
version: USER_CONFIG_VERSION,
llm: {
activeTextProvider: "local-llama",
activeEmbeddingProvider: "local-llama",
toolTransport: "auto",
providers: [
{ id: "local-llama", kind: "llama-server", url: "http://127.0.0.1:19091" },
],
},
});
expect(parsed.llm?.runMode).toBeUndefined();
});

it("accepts a bare mode and leaves the dial to its default", () => {
const parsed = parseUserConfigFile(withRunMode({ mode: "local" }));
expect(parsed.llm?.runMode).toEqual({ mode: "local" });
// The default is applied by `resolveRunMode`, never written into the
// file — an absent dial must stay absent so the default can move.
expect(parsed.llm?.runMode?.fusion).toBeUndefined();
expect(DEFAULT_FUSION_CLOUD_SHARE).toBe(40);
});

it("rejects an unknown mode", () => {
expect(() => parseUserConfigFile(withRunMode({ mode: "hybrid" }))).toThrow(
/llm\.runMode\.mode/,
);
});

it("rejects a pinned leg that names an unconfigured provider", () => {
expect(() =>
parseUserConfigFile(withRunMode({ cloudProvider: "anthropic" })),
).toThrow(/llm\.runMode\.cloudProvider/);
expect(() =>
parseUserConfigFile(withRunMode({ localProvider: "ollama" })),
).toThrow(/llm\.runMode\.localProvider/);
});

it("accepts the inclusive cloudShare bounds", () => {
for (const cloudShare of [0, 100]) {
const parsed = parseUserConfigFile(withRunMode({ fusion: { cloudShare } }));
expect(parsed.llm?.runMode?.fusion?.cloudShare).toBe(cloudShare);
}
});

it("rejects a cloudShare outside 0-100 or non-integer", () => {
for (const bad of [-1, 101, 42.5, "40", null]) {
expect(() =>
parseUserConfigFile(withRunMode({ fusion: { cloudShare: bad } })),
).toThrow(/llm\.runMode\.fusion\.cloudShare/);
}
});

it("rejects an unknown subRunners target", () => {
expect(() =>
parseUserConfigFile(withRunMode({ fusion: { subRunners: "remote" } })),
).toThrow(/llm\.runMode\.fusion\.subRunners/);
});

it("rejects a non-object runMode or fusion block", () => {
expect(() => parseUserConfigFile(withRunMode("fusion"))).toThrow(
/llm\.runMode/,
);
expect(() => parseUserConfigFile(withRunMode({ fusion: [] }))).toThrow(
/llm\.runMode\.fusion/,
);
});
});
Loading