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
14 changes: 14 additions & 0 deletions packages/core/src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
`,
},
];
2 changes: 1 addition & 1 deletion packages/core/src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/remote/deviceRouting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -178,7 +180,10 @@ async function putSettings(c: Context): Promise<Response> {
}
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<string, unknown> = getSettings() as unknown as Record<string, unknown>;
if (Object.keys(shared).length) {
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/remote/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<EntityName>();
/** Keys of rows that were added or really changed, per table. */
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/server/routes/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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<string, unknown> | 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 });
Expand Down
31 changes: 23 additions & 8 deletions packages/core/src/services/agentSettings.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -50,6 +51,8 @@ const FRACTIONS = new Set(["voice.rate"]);
const AT_LEAST: Record<string, number> = { "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]/;

Expand Down Expand Up @@ -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<string, unknown> = {}): 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;
}
4 changes: 2 additions & 2 deletions packages/core/src/services/settings.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/services/settingsPatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];

Expand Down Expand Up @@ -160,8 +160,15 @@ export function checkSettingsPatch(patch: Record<string, unknown>, 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<string, unknown>)[section];
if (isObject(base) && value !== undefined && !isObject(value)) throw badRequest(`${section} takes an object of settings`);
}
}

const isObject = (v: unknown): v is Record<string, unknown> => 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");
Expand Down
4 changes: 2 additions & 2 deletions packages/core/test/glm.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions packages/core/test/http-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
6 changes: 3 additions & 3 deletions packages/core/test/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")!;
Expand Down
14 changes: 14 additions & 0 deletions packages/core/test/remote-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
4 changes: 2 additions & 2 deletions packages/core/test/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading