diff --git a/packages/core/src/db/migrations.ts b/packages/core/src/db/migrations.ts index 068bf5cc..3a989f5a 100644 --- a/packages/core/src/db/migrations.ts +++ b/packages/core/src/db/migrations.ts @@ -1584,4 +1584,18 @@ ALTER TABLE runners ADD COLUMN kind TEXT NOT NULL DEFAULT 'runner'; name: "spend_tracking", sql: SPEND_TRACKING_SQL, }, + { + id: 97, + name: "opus_fallback", + sql: /* sql */ ` +-- The fallback model is Opus by default now. A save of any AI setting stored the whole section with the old default: +-- those follow, with an audit entry like any other settings change. +INSERT INTO audit_log (id, ts, actor, action, target, details) +SELECT 'aud_' || lower(hex(randomblob(8))), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), 'system', 'settings.update', NULL, + json_object('via', 'update', 'changes', json_array(json_object('setting', 'runner.fallbackModel', 'from', 'claude-sonnet-5', 'to', 'claude-opus-5'))) +FROM settings WHERE key = 'runner' AND json_valid(value) AND json_extract(value, '$.fallbackModel') = 'claude-sonnet-5'; +UPDATE settings SET value = json_set(value, '$.fallbackModel', 'claude-opus-5') +WHERE key = 'runner' AND json_valid(value) AND json_extract(value, '$.fallbackModel') = 'claude-sonnet-5'; +`, + }, ]; diff --git a/packages/core/src/mcp/tools.ts b/packages/core/src/mcp/tools.ts index b1bb0276..7e6f9839 100644 --- a/packages/core/src/mcp/tools.ts +++ b/packages/core/src/mcp/tools.ts @@ -3183,7 +3183,7 @@ const TOOLS: ToolDef[] = [ defineTool({ name: "settings_update", description: - 'Change Godmode\'s own settings. `changes` is a partial object by section with only what should differ, e.g. { "runner": { "fallbackModel": "claude-opus-5" }, "memory": { "dreaming": { "model": "" } } }. It applies at once and to every agent, so change what the human asked for and name it in your answer. A model takes an id or alias; "" means none (fallback) or the agent\'s own (dreaming). What guards secrets, access from outside, what agents may do on this computer, what every agent is told and their limits stays with the human (`humanOnly` in settings_get).', + 'Change Godmode\'s own settings. `changes` is a partial object by section with only what should differ, e.g. { "runner": { "fallbackModel": "claude-opus-4-8" }, "memory": { "dreaming": { "model": "" } } }. It applies at once and to every agent, so change what the human asked for and name it in your answer. A model takes an id or alias; "" means none (fallback) or the agent\'s own (dreaming). What guards secrets, access from outside, what agents may do on this computer, what every agent is told and their limits stays with the human (`humanOnly` in settings_get).', schema: z.object({ changes: z.record(z.string(), z.unknown()).describe("Partial settings by section") }), when: managesSetup, run: ({ changes }, { agent, ctx }) => { diff --git a/packages/core/src/remote/deviceRouting.ts b/packages/core/src/remote/deviceRouting.ts index 727d4021..0287578b 100644 --- a/packages/core/src/remote/deviceRouting.ts +++ b/packages/core/src/remote/deviceRouting.ts @@ -13,9 +13,11 @@ import type { Context, MiddlewareHandler } from "hono"; import { EXECUTABLE_SETTINGS } from "../backup/backup"; import { get } from "../db"; +import { auditSettings } from "../services/agentSettings"; import { getSettings, updateSettings } from "../services/settings"; import { applyRuntimeSettings } from "../services/runtime"; import { checkSettingsPatch } from "../services/settingsPatch"; +import { requestVia } from "../server/auth"; import { adoptGrant, requireGrant } from "../server/grants"; import { HttpError, badRequest } from "../util"; import { forwarding, isSecondary, primaryRunnerId } from "./group"; @@ -178,7 +180,10 @@ async function putSettings(c: Context): Promise { } if (Object.keys(own).length) { checkSettingsPatch(own, () => requireGrant(c)); - applyRuntimeSettings(updateSettings(own as never)); + const before = getSettings(); + const next = updateSettings(own as never); + auditSettings("user", before, next, Object.keys(own), requestVia(c)); + applyRuntimeSettings(next); } let settings: Record = getSettings() as unknown as Record; if (Object.keys(shared).length) { diff --git a/packages/core/src/remote/snapshot.ts b/packages/core/src/remote/snapshot.ts index e0ef6b3d..2a6d2166 100644 --- a/packages/core/src/remote/snapshot.ts +++ b/packages/core/src/remote/snapshot.ts @@ -23,6 +23,7 @@ import { logger } from "../log"; import { listActiveRuns } from "../runner/runner"; import { workingDirectoryProblem } from "../services/folders"; import { applyRuntimeSettings } from "../services/runtime"; +import { auditSettings } from "../services/agentSettings"; import { DEFAULT_SETTINGS, getSettings, resetSettingsCache } from "../services/settings"; import { badRequest, parseJson } from "../util"; import { decrypt, sha256 } from "../vault/crypto"; @@ -249,6 +250,7 @@ async function apply(s: ConfigSnapshot): Promise<{ digest: string; warnings: str } const cfg = config(); + const settingsBefore = getSettings(); const warnings: string[] = []; const changed = new Set(); /** Keys of rows that were added or really changed, per table. */ @@ -404,7 +406,9 @@ async function apply(s: ConfigSnapshot): Promise<{ digest: string; warnings: str } resetSettingsCache(); - applyRuntimeSettings(getSettings()); + const settingsAfter = getSettings(); + applyRuntimeSettings(settingsAfter); + auditSettings("controller", settingsBefore, settingsAfter, [...Object.keys(s.settings), "onboardingComplete"], { via: "sync" }); ensureDefaultProfile(); // A removed agent's repository goes to the trash first: a new agent with the same folder name must not inherit its diff --git a/packages/core/src/server/app.ts b/packages/core/src/server/app.ts index d0e486f7..8b35417d 100644 --- a/packages/core/src/server/app.ts +++ b/packages/core/src/server/app.ts @@ -53,9 +53,12 @@ function isRoutineRefusal(err: HttpError) { return err.status === 401 || err.status === 423 || err.code === "grant_required"; } -/** A runner or the group's primary that didn't answer in time: its connection, not a fault here (the caller gets the 504). */ +/** + * A runner, the group's primary or an outside service (ElevenLabs, the cloud, a speech service) that didn't answer: its + * connection, not a fault here (the caller still gets the 502/504). + */ export function isUnanswered(err: HttpError) { - return err.code === "runner_timeout"; + return err.code === "runner_timeout" || !!err.code?.endsWith("_unreachable"); } function isApiPath(path: string) { diff --git a/packages/core/src/server/auth.ts b/packages/core/src/server/auth.ts index 12b3c7f7..9ea90493 100644 --- a/packages/core/src/server/auth.ts +++ b/packages/core/src/server/auth.ts @@ -183,6 +183,20 @@ export function requestDevice(c: Context): MobileDevice | null { return (c.get("device" as never) as MobileDevice | undefined) ?? null; } +/** Where a person's request came from, for the audit: a paired phone, the cloud, another computer of the group, or this computer's app. */ +export function requestVia(c: Context): { via: "phone" | "cloud" | "computer" | "app"; name?: string; userId?: string } { + const device = requestDevice(c); + if (device) return { via: "phone", name: device.name }; + if (isCloudChannel(c)) { + const user = env(c)?.cloud?.user; + return { via: "cloud", userId: user?.id ?? "unknown", name: user?.name ?? user?.email ?? "unknown" }; + } + const link = c.env as { channel?: string; controllerId?: string } | undefined; + if (link?.channel !== "runner-link") return { via: "app" }; + const name = link.controllerId ? get<{ name: string }>("SELECT name FROM link_controllers WHERE id = ?", link.controllerId)?.name : undefined; + return { via: "computer", name: name ?? "unknown" }; +} + const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1", "tauri.localhost"]); /** Origins of the Tauri webview on macOS/Linux (tauri://localhost) and Windows (http(s)://tauri.localhost). */ diff --git a/packages/core/src/server/routes/system.ts b/packages/core/src/server/routes/system.ts index 978bb97a..91c7d750 100644 --- a/packages/core/src/server/routes/system.ts +++ b/packages/core/src/server/routes/system.ts @@ -7,6 +7,7 @@ import { get } from "../../db"; import * as vault from "../../vault/vault"; import { getSettings, updateSettings } from "../../services/settings"; import { checkSettingsPatch } from "../../services/settingsPatch"; +import { auditSettings } from "../../services/agentSettings"; import { listNotifications, markRead, clearNotifications, unreadCount } from "../../services/notifications"; import { audit, listAudit } from "../../services/audit"; import { listAttention } from "../../services/attention"; @@ -22,7 +23,7 @@ import { getModelCatalog } from "../../runner/models"; import { getDefaultAgentId } from "../../agents/service"; import { applyRuntimeSettings } from "../../services/runtime"; import { powerStatus, setLidClosedAwake } from "../../services/power"; -import { hasDashboardPassword } from "../auth"; +import { hasDashboardPassword, requestVia } from "../auth"; import { requireGrant } from "../grants"; import { body, z } from "../validate"; import { pendingRequestCount } from "../../messaging/service"; @@ -72,8 +73,10 @@ export function registerSystemRoutes(app: Hono) { const patch = await body(c, z.record(z.string(), z.unknown())); checkSettingsPatch(patch, () => requireGrant(c)); const cloud = patch.cloud as Record | undefined; - const { monthlyBudgetUsd: before = null, spendTracking: tracking } = getSettings().runner; + const previous = getSettings(); + const { monthlyBudgetUsd: before = null, spendTracking: tracking } = previous.runner; const next = updateSettings(patch as never); + auditSettings("user", previous, next, Object.keys(patch), requestVia(c)); applyRuntimeSettings(next); if (cloud) audit("user", "cloud.settings", null, cloud); if ((next.runner.monthlyBudgetUsd ?? null) !== before) audit("user", "budget.set", null, { scope: "team", from: before, to: next.runner.monthlyBudgetUsd ?? null }); diff --git a/packages/core/src/services/agentSettings.ts b/packages/core/src/services/agentSettings.ts index d8bf5e94..44e19883 100644 --- a/packages/core/src/services/agentSettings.ts +++ b/packages/core/src/services/agentSettings.ts @@ -1,8 +1,9 @@ +import type { Settings } from "@godmode/shared"; import { INTERFACE_MODES } from "@godmode/shared"; import { HttpError } from "../util"; import { audit } from "./audit"; import { applyRuntimeSettings } from "./runtime"; -import { getSettings, updateSettings } from "./settings"; +import { DEFAULT_SETTINGS, getSettings, updateSettings } from "./settings"; import { sameSettingShape, settingInRange } from "./settingShape"; import { checkSettingsPatch } from "./settingsPatch"; @@ -50,6 +51,8 @@ const FRACTIONS = new Set(["voice.rate"]); const AT_LEAST: Record = { "runner.runTimeoutMinutes": 1 }; const MAX_TEXT = 200; const AUDIT_TEXT = 200; +/** Free text and CLI flags can carry passwords and tokens: the audit notes that they changed, not what they say. */ +const UNQUOTED = new Set(["runner.appendSystemPrompt", "runner.extraArgs"]); /** Line breaks and control characters: a setting's text is one plain line. */ const NOT_ONE_LINE = /[\u0000-\u001f\u007f\u0085\u2028\u2029]/; @@ -138,20 +141,32 @@ export function applyAgentSettings(actor: string, runId: string, changes: Record checkSettingsPatch(changes, () => { throw new HttpError(403, "This change needs the vault passphrase: the human makes it in Settings."); }); - const before = getSettings() as unknown as Sections; + const before = getSettings(); const next = updateSettings(changes as never); + const changed = auditSettings(actor, before, next, Object.keys(changes), { runId }); applyRuntimeSettings(next); - const after = next as unknown as Sections; + return changed; +} + +/** + * Records what a settings save changed in `sections`, setting by setting, as `settings.update` (nothing when nothing + * changed). Every save goes here, whoever made it: a setting that changes without an entry has no one to ask. + */ +export function auditSettings(actor: string, before: Settings, after: Settings, sections: string[], details: Record = {}): SettingChange[] { + const was = before as unknown as Sections; + const now = after as unknown as Sections; const changed: SettingChange[] = []; - for (const section of Object.keys(changes)) { - const was = leaves(before[section], section); - for (const [setting, to] of leaves(after[section], section)) { - const from = was.get(setting); + for (const section of sections) { + if (!Object.hasOwn(DEFAULT_SETTINGS, section)) continue; + const old = leaves(was[section], section); + for (const [setting, to] of leaves(now[section], section)) { + const from = old.get(setting); if (JSON.stringify(from) !== JSON.stringify(to)) changed.push({ setting, from, to }); } } if (changed.length) { - audit(actor, "settings.update", null, { runId, changes: changed.map((c) => ({ setting: c.setting, from: brief(c.from), to: brief(c.to) })) }); + const entry = (c: SettingChange) => (UNQUOTED.has(c.setting) ? { setting: c.setting } : { setting: c.setting, from: brief(c.from), to: brief(c.to) }); + audit(actor, "settings.update", null, { ...details, changes: changed.map(entry) }); } return changed; } diff --git a/packages/core/src/services/settings.ts b/packages/core/src/services/settings.ts index ac499673..15527cd5 100644 --- a/packages/core/src/services/settings.ts +++ b/packages/core/src/services/settings.ts @@ -1,5 +1,5 @@ import type { Settings } from "@godmode/shared"; -import { DEFAULT_CLOUD_SETTINGS, DEFAULT_LOOP_REPEATS, DEFAULT_MEMORY_CHARS, DEFAULT_MODEL, DEFAULT_PORT, DEFAULT_STALL_MINUTES, MOBILE_DEFAULT_PORT } from "@godmode/shared"; +import { DEFAULT_CLOUD_SETTINGS, DEFAULT_FALLBACK_MODEL, DEFAULT_LOOP_REPEATS, DEFAULT_MEMORY_CHARS, DEFAULT_MODEL, DEFAULT_PORT, DEFAULT_STALL_MINUTES, MOBILE_DEFAULT_PORT } from "@godmode/shared"; import { all, run } from "../db"; import { bus } from "../events/bus"; import { parseJson } from "../util"; @@ -18,7 +18,7 @@ export const DEFAULT_SETTINGS: Settings = { runner: { claudePath: "", model: DEFAULT_MODEL, - fallbackModel: "claude-sonnet-5", + fallbackModel: DEFAULT_FALLBACK_MODEL, effort: "high", ultracode: false, bypassPermissions: true, diff --git a/packages/core/src/services/settingsPatch.ts b/packages/core/src/services/settingsPatch.ts index c8c165d2..023526eb 100644 --- a/packages/core/src/services/settingsPatch.ts +++ b/packages/core/src/services/settingsPatch.ts @@ -5,7 +5,7 @@ import { isPremiumNumber } from "../calls/numbers"; import { get } from "../db"; import { badRequest } from "../util"; import { isValidDreamSchedule } from "../memory/dreaming"; -import { getSettings } from "./settings"; +import { DEFAULT_SETTINGS, getSettings } from "./settings"; const CLOUD_SWITCHES = ["enabled", "browserAccess", "phoneAccess", "allowSecrets"]; @@ -160,8 +160,15 @@ export function checkSettingsPatch(patch: Record, needsGrant: ( if (typeof value !== "boolean") throw badRequest(`cloud.${key} must be true or false`); } } + // A section saved as anything but an object replaces it whole: every later read and save would break on it. + for (const [section, value] of Object.entries(patch)) { + const base = (DEFAULT_SETTINGS as unknown as Record)[section]; + if (isObject(base) && value !== undefined && !isObject(value)) throw badRequest(`${section} takes an object of settings`); + } } +const isObject = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); + function checkPhonePatch(value: unknown): void { if (value === undefined) return; if (typeof value !== "object" || value === null || Array.isArray(value)) throw badRequest("Invalid phone call settings"); diff --git a/packages/core/test/glm.test.ts b/packages/core/test/glm.test.ts index f5e44d68..18edf491 100644 --- a/packages/core/test/glm.test.ts +++ b/packages/core/test/glm.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import type { Agent } from "@godmode/shared"; -import { DEFAULT_MODEL, GLM_MODELS, ZAI_ANTHROPIC_BASE_URL, modelProvider } from "@godmode/shared"; +import { DEFAULT_FALLBACK_MODEL, DEFAULT_MODEL, GLM_MODELS, ZAI_ANTHROPIC_BASE_URL, modelProvider } from "@godmode/shared"; import { argValue, invocations, makeAgent, setupEnv, type TestEnv } from "./fixtures/runner-harness"; import * as vault from "../src/vault/vault"; import { callTool } from "../src/mcp/tools"; @@ -133,7 +133,7 @@ describe("GLM on Z.ai", () => { expect(run.status).toBe("succeeded"); const inv = invocations(env).at(-1)!; expect(argValue(inv, "--model")).toBe(DEFAULT_MODEL); - expect(argValue(inv, "--fallback-model")).toBe("claude-sonnet-5"); + expect(argValue(inv, "--fallback-model")).toBe(DEFAULT_FALLBACK_MODEL); expect(inv.env.ANTHROPIC_API_KEY).toBe("sk-ant-must-not-leak"); expect(inv.env.ANTHROPIC_BASE_URL).toBeNull(); expect(inv.env.ANTHROPIC_AUTH_TOKEN).toBeNull(); diff --git a/packages/core/test/http-errors.test.ts b/packages/core/test/http-errors.test.ts index a64e14d0..af2863fa 100644 --- a/packages/core/test/http-errors.test.ts +++ b/packages/core/test/http-errors.test.ts @@ -8,4 +8,10 @@ describe("http errors", () => { expect(isUnanswered(new HttpError(500, "Internal error", "internal"))).toBe(false); expect(isUnanswered(new HttpError(502, "Bad gateway"))).toBe(false); }); + + test("an outside service that didn't answer is its connection, not a server fault", () => { + expect(isUnanswered(new HttpError(502, "ElevenLabs did not answer in time", "calls_unreachable"))).toBe(true); + expect(isUnanswered(new HttpError(502, "Godmode Cloud can't be reached right now.", "cloud_unreachable"))).toBe(true); + expect(isUnanswered(new HttpError(502, "ElevenLabs returned an unexpected answer", "calls_upstream"))).toBe(false); + }); }); diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index e7374138..eba08404 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -1551,19 +1551,19 @@ describe("settings tools", () => { const before = getSettings(); const audits = settingsAudits(); try { - const res = await update({ runner: { fallbackModel: "claude-opus-5", stallMinutes: 30 }, memory: { dreaming: { model: "" } } }); + const res = await update({ runner: { fallbackModel: "claude-opus-4-8", stallMinutes: 30 }, memory: { dreaming: { model: "" } } }); expect(res.isError).toBeUndefined(); const { changed } = JSON.parse(res.content[0]!.text) as { changed: { setting: string; from: unknown; to: unknown }[] }; expect(changed).toEqual( expect.arrayContaining([ - { setting: "runner.fallbackModel", from: before.runner.fallbackModel, to: "claude-opus-5" }, + { setting: "runner.fallbackModel", from: before.runner.fallbackModel, to: "claude-opus-4-8" }, { setting: "runner.stallMinutes", from: before.runner.stallMinutes, to: 30 }, { setting: "memory.dreaming.model", from: before.memory.dreaming.model, to: "" }, ]), ); expect(changed).toHaveLength(3); const now = getSettings(); - expect(now.runner.fallbackModel).toBe("claude-opus-5"); + expect(now.runner.fallbackModel).toBe("claude-opus-4-8"); expect(now.memory.dreaming).toEqual({ ...before.memory.dreaming, model: "" }); expect(now.runner.model).toBe(before.runner.model); const logged = listAudit(5).find((a) => a.action === "settings.update")!; diff --git a/packages/core/test/remote-snapshot.test.ts b/packages/core/test/remote-snapshot.test.ts index 2d8f6454..aceaa77d 100644 --- a/packages/core/test/remote-snapshot.test.ts +++ b/packages/core/test/remote-snapshot.test.ts @@ -416,6 +416,20 @@ describe("applying a snapshot on a runner", () => { expect(vault.status().autoLockMinutes).toBe(0); }); + test("a setting the controller changed is audited on the runner once", async () => { + const audits = () => all<{ actor: string; details: string }>("SELECT actor, details FROM audit_log WHERE action = 'settings.update' ORDER BY rowid"); + const before = audits().length; + use(controller); + const effort = getSettings().runner.effort; + await sync(() => updateSettings({ runner: { effort: effort === "max" ? "low" : "max" } })); + const logged = audits().slice(before); + expect(logged.map((a) => a.actor)).toEqual(["controller"]); + expect(JSON.parse(logged[0]!.details)).toEqual({ via: "sync", changes: [{ setting: "runner.effort", from: effort, to: effort === "max" ? "low" : "max" }] }); + await sync(() => {}); + expect(audits().length).toBe(before + 1); + await sync(() => updateSettings({ runner: { effort } })); + }); + test("applying the same snapshot again changes nothing", async () => { const before = state(); const claudeMd = readFileSync(join(runnerDir, "agents", "helper", "CLAUDE.md"), "utf8"); diff --git a/packages/core/test/runner.test.ts b/packages/core/test/runner.test.ts index 362b6181..264e5fac 100644 --- a/packages/core/test/runner.test.ts +++ b/packages/core/test/runner.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Agent, ServerEvent } from "@godmode/shared"; -import { MAX_INSTRUCTIONS_LENGTH } from "@godmode/shared"; +import { DEFAULT_FALLBACK_MODEL, MAX_INSTRUCTIONS_LENGTH } from "@godmode/shared"; import { argValue, browserRuns, captureEvents, invocations, makeAgent, setupEnv, until, type TestEnv } from "./fixtures/runner-harness"; import { all, get, insert, run as sql } from "../src/db"; import { getSettings, updateSettings } from "../src/services/settings"; @@ -100,7 +100,7 @@ describe("runner end-to-end with fake claude", () => { expect(argValue(inv, "--input-format")).toBe("text"); expect(argValue(inv, "--model")).toBe("claude-opus-5-5"); expect(argValue(inv, "--effort")).toBe("high"); - expect(argValue(inv, "--fallback-model")).toBe("claude-sonnet-5"); + expect(argValue(inv, "--fallback-model")).toBe(DEFAULT_FALLBACK_MODEL); expect(argValue(inv, "--setting-sources")).toBe("project,local"); expect(argValue(inv, "--append-system-prompt")).toContain("vault_fill_login"); expect(inv.args).not.toContain("--resume"); diff --git a/packages/core/test/settings-audit.test.ts b/packages/core/test/settings-audit.test.ts new file mode 100644 index 00000000..63afa065 --- /dev/null +++ b/packages/core/test/settings-audit.test.ts @@ -0,0 +1,106 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { DEFAULT_FALLBACK_MODEL, modelProvider } from "@godmode/shared"; +import { setupEnv, type TestEnv } from "./fixtures/runner-harness"; +import { all } from "../src/db"; +import { MIGRATIONS } from "../src/db/migrations"; +import { getAccessToken } from "../src/server/auth"; +import { auditSettings } from "../src/services/agentSettings"; +import { DEFAULT_SETTINGS, getSettings, updateSettings } from "../src/services/settings"; + +let env: TestEnv; + +beforeAll(async () => { + env = await setupEnv("godmode-settings-audit-"); +}); + +afterAll(async () => { + await env.close(); +}); + +const put = (body: unknown) => + fetch(`${env.baseUrl}/api/settings`, { + method: "PUT", + headers: { Authorization: `Bearer ${getAccessToken()}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + +const updates = (): Record[] => + all<{ actor: string; details: string }>("SELECT actor, details FROM audit_log WHERE action = 'settings.update' ORDER BY rowid").map((r) => ({ + actor: r.actor, + ...(JSON.parse(r.details) as Record), + })); + +describe("settings changes", () => { + test("the fallback model is Opus unless someone picks another", () => { + expect(DEFAULT_SETTINGS.runner.fallbackModel).toBe(DEFAULT_FALLBACK_MODEL); + expect(DEFAULT_FALLBACK_MODEL).toStartWith("claude-opus-"); + expect(modelProvider(getSettings().runner.fallbackModel)).toBe("anthropic"); + }); + + test("a save from the app is audited setting by setting, and a save that changes nothing is not", async () => { + updateSettings({ runner: { fallbackModel: "claude-opus-5", effort: "high" } }); + const before = updates().length; + expect((await put({ runner: { fallbackModel: "sonnet", effort: "xhigh" } })).status).toBe(200); + expect(updates().slice(before)).toEqual([ + { + actor: "user", + via: "app", + changes: [ + { setting: "runner.fallbackModel", from: "claude-opus-5", to: "sonnet" }, + { setting: "runner.effort", from: "high", to: "xhigh" }, + ], + }, + ]); + expect((await put({ runner: { fallbackModel: "sonnet" } })).status).toBe(200); + expect(updates().length).toBe(before + 1); + expect(getSettings().runner.fallbackModel).toBe("sonnet"); + }); + + test("a phone's or another computer's save names where it came from; secrets in free text stay out of the audit", () => { + const before = getSettings(); + const after = updateSettings({ runner: { appendSystemPrompt: "Log in with password hunter2", extraArgs: ["--token", "t0k"], stallMinutes: before.runner.stallMinutes + 1 } }); + const changed = auditSettings("user", before, after, ["runner", "bogus"], { via: "phone", name: "Pixel" }); + expect(changed.map((c) => c.setting).sort()).toEqual(["runner.appendSystemPrompt", "runner.extraArgs", "runner.stallMinutes"]); + const logged = updates().at(-1)!; + expect(logged).toMatchObject({ actor: "user", via: "phone", name: "Pixel" }); + expect(JSON.stringify(logged)).not.toContain("hunter2"); + expect(JSON.stringify(logged)).not.toContain("t0k"); + expect(logged.changes).toContainEqual({ setting: "runner.stallMinutes", from: before.runner.stallMinutes, to: before.runner.stallMinutes + 1 }); + updateSettings({ runner: { appendSystemPrompt: before.runner.appendSystemPrompt, extraArgs: before.runner.extraArgs, stallMinutes: before.runner.stallMinutes } }); + }); + + test("a section can't be replaced by something that isn't one", async () => { + for (const body of [{ runner: null }, { general: "x" }, { browser: [] }, { voice: 3 }]) expect((await put(body)).status).toBe(400); + expect(getSettings().runner.model).toBeTruthy(); + expect((await put({ onboardingComplete: true })).status).toBe(200); + }); + + test("saved settings with the old Sonnet default fall back to Opus; a model someone picked stays", () => { + const migration = MIGRATIONS.find((m) => m.name === "opus_fallback")!; + const after = (runner: Record | null) => { + const db = new Database(":memory:"); + db.run("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)"); + db.run("CREATE TABLE audit_log (id TEXT PRIMARY KEY, ts TEXT NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL, target TEXT, details TEXT NOT NULL DEFAULT '{}')"); + db.query("INSERT INTO settings (key, value) VALUES ('memory', ?)").run(JSON.stringify({ fallbackModel: "claude-sonnet-5" })); + if (runner) db.query("INSERT INTO settings (key, value) VALUES ('runner', ?)").run(JSON.stringify(runner)); + db.run(migration.sql); + const rows = db.query<{ key: string; value: string }, []>("SELECT key, value FROM settings").all(); + const audits = db.query<{ actor: string; action: string; details: string }, []>("SELECT actor, action, details FROM audit_log").all(); + db.close(); + expect(JSON.parse(rows.find((r) => r.key === "memory")!.value)).toEqual({ fallbackModel: "claude-sonnet-5" }); + const row = rows.find((r) => r.key === "runner"); + const value = row ? (JSON.parse(row.value) as Record) : null; + const moved = runner?.fallbackModel === "claude-sonnet-5"; + expect(audits.map((a) => ({ actor: a.actor, action: a.action, details: JSON.parse(a.details) }))).toEqual( + moved ? [{ actor: "system", action: "settings.update", details: { via: "update", changes: [{ setting: "runner.fallbackModel", from: "claude-sonnet-5", to: "claude-opus-5" }] } }] : [], + ); + return value; + }; + expect(after({ model: "claude-opus-5-5", fallbackModel: "claude-sonnet-5", effort: "high" })).toEqual({ model: "claude-opus-5-5", fallbackModel: "claude-opus-5", effort: "high" }); + expect(after({ fallbackModel: "sonnet" })!.fallbackModel).toBe("sonnet"); + expect(after({ fallbackModel: "" })!.fallbackModel).toBe(""); + expect(after({ effort: "high" })).toEqual({ effort: "high" }); + expect(after(null)).toBeNull(); + }); +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index d626dfee..5da9a899 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -4,6 +4,8 @@ export const APP_NAME = "Godmode Bot"; export const APP_SLUG = "godmode"; export const DEFAULT_PORT = 7777; export const DEFAULT_MODEL = "claude-opus-5-5"; +/** Takes over when the default model is overloaded: the Opus before it. */ +export const DEFAULT_FALLBACK_MODEL = "claude-opus-5"; export const DEFAULT_AGENT_SLUG = "godmode"; /** MEMORY.md: what dreams keep it under by default, and the range the setting takes (characters). */ export const DEFAULT_MEMORY_CHARS = 8_000;