From 3bd1daac7b7c144c85924c9e571127dd67ae8279 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Mon, 7 Sep 2026 17:39:29 -0400 Subject: [PATCH 1/4] feat(settings): route Agent settings by the canonical Cloud contract F2 convergence, Agent step 1: teach this repository the canonical settings contract instead of letting it keep an independent product schema. Aether Cloud owns the contract. `src/generated/settings_vectors.ts` is a vendored copy of `contracts/aether-settings/cross-surface-vectors.v1.json`, generated there by `lib/code_settings/generate_registry.py`, following the same vendoring convention this repository already uses for `src/generated/agent_capabilities.ts`. It is the only place the Agent learns a canonical key, type, default, allowed scope or persistence owner. `src/core/settings_canonical.ts` decides routing only, and performs no I/O: - persistence `server` -> account/project scope -> the Cloud settings API with the revision/CAS contract - persistence `device` -> this machine's local authority; works offline and is never given a server row, because the settings DB deliberately has none It also records the Section D classification of the settings this repository already had. Three are the canonical setting under another name (`code.hosted_model`, `code.effort`, `voice.enabled`); the rest are listed in NON_CANONICAL_AGENT_SETTINGS with the reason they were not equated, so nothing is silently reclassified. `code.auto_apply` is deliberately NOT equated with `actions.liveCanvas.autoApply`: the first is this agent's workspace edit-apply gate, the second is the Cloud Live Canvas mutation ceiling, and equating them would let a local toggle imply a server capability. Stable public failure codes land here too (AETHER_SETTINGS_REVISION_CONFLICT, _OFFLINE, _SCOPE_INVALID, _POLICY_DENIED, _KEY_UNKNOWN and siblings). The error surface carries canonical keys and revisions only -- never a setting value. No behaviour change yet: nothing routes through this module until the Cloud client lands. No existing setting, file or CLI command is altered. Tests (test/settings_canonical.test.ts, 14 cases): digest reproduction under the documented recipe, contract/scope/type/default parity against the vendored vectors, routing by persistence owner, out-of-scope and unknown-key refusal, value validation including the offline model-catalog case, value-free errors, and the alias/exclusion classification being total and non-overlapping. --- src/core/settings_canonical.ts | 291 +++++++++++++++++++++++++++++ src/generated/settings_vectors.ts | 297 ++++++++++++++++++++++++++++++ test/settings_canonical.test.ts | 229 +++++++++++++++++++++++ 3 files changed, 817 insertions(+) create mode 100644 src/core/settings_canonical.ts create mode 100644 src/generated/settings_vectors.ts create mode 100644 test/settings_canonical.test.ts diff --git a/src/core/settings_canonical.ts b/src/core/settings_canonical.ts new file mode 100644 index 00000000..a7b29d7b --- /dev/null +++ b/src/core/settings_canonical.ts @@ -0,0 +1,291 @@ +// The canonical settings contract, as this repository sees it. +// +// F2 convergence rule: Aether Cloud owns the settings contract. This module is +// the ONLY place Aether Agent learns a canonical setting's key, type, default, +// allowed scopes or persistence owner, and it learns all of them from the +// vendored vector file in src/generated/settings_vectors.ts. Nothing here +// re-declares a canonical fact; a hand-written second schema is exactly what +// this lane exists to remove. +// +// What this module decides is only ROUTING: given a setting, which authority +// answers for it. +// +// persistence "server" -> account/project scope -> canonical Cloud settings +// API, with the revision/CAS contract +// persistence "device" -> this machine's local settings authority +// session -> memory only, never durable +// +// It deliberately does NOT perform I/O. settings_cloud.ts owns the HTTP side and +// settings_store.ts owns the local side; both consume the routing decided here. + +import { + AETHER_SETTINGS_CONTRACT_DIGEST, + AETHER_SETTINGS_VECTORS, +} from "../generated/settings_vectors.js"; + +export const CANONICAL_SETTINGS_SCHEMA = "aether.settings/1" as const; +export const CANONICAL_SETTINGS_VECTORS_SCHEMA = + "aether.settings.cross-surface-vectors/1" as const; + +/** Highest safe integer revision the canonical CAS contract accepts. */ +export const CANONICAL_MAX_REVISION = 9_007_199_254_740_991; + +export type CanonicalScope = "device" | "account" | "project"; +export type CanonicalPersistence = "device" | "server"; +export type CanonicalApply = "immediate" | "next_session" | "approval_step_up"; +export type CanonicalValueType = "boolean" | "integer" | "string"; + +/** + * Which authority answers for a setting. + * + * "cloud" — account/project scope; the Cloud service is authoritative and a + * write must carry the expected revision. + * "device" — this machine only; local authority, works offline, and is never + * given a server row (the settings DB deliberately has none). + */ +export type CanonicalAuthority = "cloud" | "device"; + +export interface CanonicalValidation { + readonly kind: "boolean" | "integer" | "enum" | "catalog_model_id"; + readonly options?: readonly string[]; + readonly minimum?: number; + readonly maximum?: number; + readonly maximumLength?: number; +} + +export interface CanonicalSetting { + readonly key: string; + readonly valueType: CanonicalValueType; + readonly defaultValue: boolean | number | string; + readonly allowedScopes: readonly CanonicalScope[]; + readonly persistence: CanonicalPersistence; + readonly apply: CanonicalApply; + readonly managedPolicy: boolean; + readonly policyOnlyScopes: readonly string[]; + readonly deprecated: boolean; + readonly validation: CanonicalValidation; +} + +/** + * Stable public failure codes. Internal detail (HTTP status, PostgREST error, + * store errno) is mapped onto these without losing the diagnostic trace id that + * accompanies them; see settings_cloud.ts. + */ +export type AetherSettingsErrorCode = + | "AETHER_SETTINGS_REVISION_CONFLICT" + | "AETHER_SETTINGS_OFFLINE" + | "AETHER_SETTINGS_SCOPE_INVALID" + | "AETHER_SETTINGS_POLICY_DENIED" + | "AETHER_SETTINGS_KEY_UNKNOWN" + | "AETHER_SETTINGS_VALUE_INVALID" + | "AETHER_SETTINGS_UNAUTHORIZED" + | "AETHER_SETTINGS_PROJECT_NOT_FOUND" + | "AETHER_SETTINGS_DISABLED" + | "AETHER_SETTINGS_BACKEND_ERROR"; + +export interface AetherSettingsConflictDetail { + readonly key: string; + readonly expectedRevision: number; + readonly actualRevision: number; +} + +/** A typed, value-free failure. Setting VALUES never appear on an error. */ +export class AetherSettingsError extends Error { + readonly code: AetherSettingsErrorCode; + /** Canonical keys only — never values. */ + readonly keys: readonly string[]; + readonly conflicts: readonly AetherSettingsConflictDetail[]; + /** Correlates with the server log line; safe to print. */ + readonly traceId: string | undefined; + + constructor( + code: AetherSettingsErrorCode, + message: string, + opts: { + keys?: readonly string[]; + conflicts?: readonly AetherSettingsConflictDetail[]; + traceId?: string | undefined; + } = {}, + ) { + super(message); + this.name = "AetherSettingsError"; + this.code = code; + this.keys = opts.keys ? [...opts.keys] : []; + this.conflicts = opts.conflicts ? [...opts.conflicts] : []; + this.traceId = opts.traceId; + } +} + +const SETTINGS: readonly CanonicalSetting[] = Object.freeze( + AETHER_SETTINGS_VECTORS.settings.map( + (item) => Object.freeze({ ...item }) as unknown as CanonicalSetting, + ), +); + +const BY_KEY = new Map( + SETTINGS.map((item) => [item.key, item]), +); + +/** Every canonical setting, in canonical (key-sorted) order. */ +export function canonicalSettings(): readonly CanonicalSetting[] { + return SETTINGS; +} + +/** The canonical definition, or null when the key is not canonical at all. */ +export function canonicalSetting(key: string): CanonicalSetting | null { + return BY_KEY.get(key) ?? null; +} + +/** Throws AETHER_SETTINGS_KEY_UNKNOWN rather than inventing a definition. */ +export function requireCanonicalSetting(key: string): CanonicalSetting { + const found = BY_KEY.get(key); + if (!found) { + throw new AetherSettingsError( + "AETHER_SETTINGS_KEY_UNKNOWN", + `${key} is not a canonical Aether setting`, + { keys: [key] }, + ); + } + return found; +} + +/** Which authority answers for this key. */ +export function canonicalAuthority(key: string): CanonicalAuthority { + return requireCanonicalSetting(key).persistence === "server" + ? "cloud" + : "device"; +} + +/** + * The scope a write lands in when the caller did not name one. + * + * A device key has exactly one scope. A server key defaults to `account`; + * `project` is opt-in because a project write is invisible from every other + * project and is easy to make by accident. + */ +export function defaultWriteScope(key: string): CanonicalScope { + const definition = requireCanonicalSetting(key); + return definition.persistence === "device" ? "device" : "account"; +} + +export function assertScopeAllowed(key: string, scope: CanonicalScope): void { + const definition = requireCanonicalSetting(key); + if (!definition.allowedScopes.includes(scope)) { + throw new AetherSettingsError( + "AETHER_SETTINGS_SCOPE_INVALID", + `${key} cannot be written at ${scope} scope`, + { keys: [key] }, + ); + } +} + +/** + * Validate a value against the canonical constraint. + * + * `availableModels` is supplied by the caller for `catalog_model_id`; the agent + * never hard-codes a model list. When it is omitted the check is limited to + * shape, because refusing every model offline would be worse than accepting a + * canonical-looking id the server will re-validate anyway. + */ +export function validateCanonicalValue( + key: string, + value: unknown, + opts: { availableModels?: readonly string[] } = {}, +): boolean | number | string { + const definition = requireCanonicalSetting(key); + const invalid = (why: string): never => { + throw new AetherSettingsError( + "AETHER_SETTINGS_VALUE_INVALID", + `${key} ${why}`, + { keys: [key] }, + ); + }; + const v = definition.validation; + + if (definition.valueType === "boolean") { + if (typeof value !== "boolean") return invalid("must be a boolean"); + return value; + } + if (definition.valueType === "integer") { + if (typeof value !== "number" || !Number.isInteger(value)) { + return invalid("must be an integer"); + } + if (v.minimum !== undefined && value < v.minimum) { + return invalid(`must be at least ${v.minimum}`); + } + if (v.maximum !== undefined && value > v.maximum) { + return invalid(`must be at most ${v.maximum}`); + } + return value; + } + if (typeof value !== "string") return invalid("must be a string"); + if (v.kind === "enum") { + if (!v.options?.includes(value)) { + return invalid(`must be one of ${(v.options ?? []).join(", ")}`); + } + return value; + } + if (v.kind === "catalog_model_id") { + if (v.maximumLength !== undefined && value.length > v.maximumLength) { + return invalid(`must be at most ${v.maximumLength} characters`); + } + if (value !== value.trim() || value.length === 0) { + return invalid("must not be empty or padded"); + } + // A stored model can outlive its catalog entry; only reject when a catalog + // was actually supplied, so `settings get` stays usable offline. + if (opts.availableModels && !opts.availableModels.includes(value)) { + return invalid("is not an available model for this account"); + } + return value; + } + return invalid("failed canonical validation"); +} + +/** + * Existing Agent setting ids that ARE the canonical setting under another name. + * + * Only semantic identity belongs here. A merely similar setting is listed in + * NON_CANONICAL_AGENT_SETTINGS with the reason it was not equated, so the + * decision is reviewable instead of implicit. + */ +export const AGENT_SETTING_ALIASES: Readonly> = + Object.freeze({ + "code.hosted_model": "agent.defaultModel", + "code.effort": "agent.defaultEffort", + "voice.enabled": "voice.enabled", + }); + +/** + * Agent settings deliberately NOT mapped onto a canonical key, and why. + * + * These stay on their existing local persistence and keep their existing CLI + * behaviour. Recording them here is what makes "unknown keys are never silently + * discarded" checkable. + */ +export const NON_CANONICAL_AGENT_SETTINGS: Readonly> = + Object.freeze({ + "code.auto_apply": + "distinct semantics: this is the agent's workspace edit-apply gate, " + + "whereas actions.liveCanvas.autoApply is the Cloud Live Canvas mutation " + + "ceiling. Equating them would let a local toggle imply a server capability.", + "code.backend": "local route selection (auto/local/cloud); has no canonical key", + "code.permission_mode": "local tool-authority gate; has no canonical key", + "agent.api_base_url": "local transport target; must not be server-owned", + "agent.telemetry": "existing agent-owned opt-in; has no canonical key", + "ollama.selected_model": "local runtime slot; never a hosted catalog id", + "ollama.host": "local runtime endpoint", + "voice.interaction_mode": "not yet in the canonical contract", + "voice.hotkey": "not yet in the canonical contract", + "voice.profile": "not yet in the canonical contract", + "voice.speech_output": "not yet in the canonical contract", + "voice.local_fallback": "not yet in the canonical contract", + "voice.end_of_turn_silence_ms": "not yet in the canonical contract", + }); + +/** The canonical key an existing agent id maps to, or null when it is local. */ +export function canonicalKeyForAgentSetting(agentId: string): string | null { + return AGENT_SETTING_ALIASES[agentId] ?? null; +} + +export { AETHER_SETTINGS_CONTRACT_DIGEST }; diff --git a/src/generated/settings_vectors.ts b/src/generated/settings_vectors.ts new file mode 100644 index 00000000..6afc0917 --- /dev/null +++ b/src/generated/settings_vectors.ts @@ -0,0 +1,297 @@ +// GENERATED — do not edit by hand. +// Source: AetherAI3/AETHER-CLOUD contracts/aether-settings/cross-surface-vectors.v1.json +// Source commit: 80e810ada7501127019c704a9a9aeadcee2edb96 +// Contract version: 1 +// Canonical sha256 (of THIS vector file): 8f4ff065855169a642cc1dca2d5b83a85bee13db9c45aa05ab434b746379d68e +// Canonical sha256 (of the registry it was generated from): 4cd562dab6b53338e7fa57e5ddbb59d8809093da4a426f5c9d72d9a167d70e6a +// Regenerate (no generator script is checked in here; the generator lives in the +// source repo at lib/code_settings/generate_registry.py — this is the whole +// procedure on this side): +// 1. Run `python lib/code_settings/generate_registry.py` in AETHER-CLOUD and +// take contracts/aether-settings/cross-surface-vectors.v1.json at the +// commit you want to pin. +// 2. Paste it as AETHER_SETTINGS_VECTORS below and set the header's +// "Source commit" to that commit. +// 3. Recompute AETHER_SETTINGS_VECTORS_DIGEST as the sha256 of the CANONICAL +// encoding — JSON with object keys sorted recursively, no whitespace: +// sha256(JSON.stringify(sortKeysDeep(vectors))) +// and copy it into the "Canonical sha256" header line too. +// 4. Update AETHER_SETTINGS_VECTORS_SOURCE.commit / .contractVersion to match. +// test/settings_canonical.test.ts verifies the digest reproduces under exactly +// that recipe, so a bad paste fails before it can reach a user. +// +// This file is the ONE place Aether Agent learns a canonical setting's key, +// type, default, allowed scopes and persistence owner. Never re-declare those +// facts anywhere else in this repository. + +/** Vendored copy of the canonical cross-surface settings vectors. */ +export const AETHER_SETTINGS_VECTORS = { + "contractDigest": "4cd562dab6b53338e7fa57e5ddbb59d8809093da4a426f5c9d72d9a167d70e6a", + "contractSchema": "aether.settings/1", + "contractVersion": 1, + "schema": "aether.settings.cross-surface-vectors/1", + "settings": [ + { + "allowedScopes": [ + "account", + "project" + ], + "apply": "approval_step_up", + "defaultValue": false, + "deprecated": false, + "key": "actions.liveCanvas.autoApply", + "managedPolicy": true, + "persistence": "server", + "policyOnlyScopes": [ + "team" + ], + "validation": { + "kind": "boolean" + }, + "valueType": "boolean" + }, + { + "allowedScopes": [ + "account", + "project" + ], + "apply": "next_session", + "defaultValue": "medium", + "deprecated": false, + "key": "agent.defaultEffort", + "managedPolicy": false, + "persistence": "server", + "policyOnlyScopes": [ + "team" + ], + "validation": { + "kind": "enum", + "options": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + "valueType": "string" + }, + { + "allowedScopes": [ + "account", + "project" + ], + "apply": "next_session", + "defaultValue": "sonnet", + "deprecated": false, + "key": "agent.defaultModel", + "managedPolicy": false, + "persistence": "server", + "policyOnlyScopes": [ + "team" + ], + "validation": { + "kind": "catalog_model_id", + "maximumLength": 256 + }, + "valueType": "string" + }, + { + "allowedScopes": [ + "device" + ], + "apply": "immediate", + "defaultValue": "blue", + "deprecated": false, + "key": "appearance.themeId", + "managedPolicy": false, + "persistence": "device", + "policyOnlyScopes": [], + "validation": { + "kind": "enum", + "options": [ + "blue", + "light", + "sage", + "olive", + "forest", + "black" + ] + }, + "valueType": "string" + }, + { + "allowedScopes": [ + "device" + ], + "apply": "immediate", + "defaultValue": false, + "deprecated": false, + "key": "editor.autoSave", + "managedPolicy": false, + "persistence": "device", + "policyOnlyScopes": [], + "validation": { + "kind": "boolean" + }, + "valueType": "boolean" + }, + { + "allowedScopes": [ + "device" + ], + "apply": "immediate", + "defaultValue": 14, + "deprecated": false, + "key": "editor.fontSize", + "managedPolicy": false, + "persistence": "device", + "policyOnlyScopes": [], + "validation": { + "kind": "integer", + "maximum": 40, + "minimum": 8 + }, + "valueType": "integer" + }, + { + "allowedScopes": [ + "device" + ], + "apply": "immediate", + "defaultValue": true, + "deprecated": false, + "key": "editor.guides", + "managedPolicy": false, + "persistence": "device", + "policyOnlyScopes": [], + "validation": { + "kind": "boolean" + }, + "valueType": "boolean" + }, + { + "allowedScopes": [ + "device" + ], + "apply": "immediate", + "defaultValue": true, + "deprecated": false, + "key": "editor.insertSpaces", + "managedPolicy": false, + "persistence": "device", + "policyOnlyScopes": [], + "validation": { + "kind": "boolean" + }, + "valueType": "boolean" + }, + { + "allowedScopes": [ + "device" + ], + "apply": "immediate", + "defaultValue": false, + "deprecated": false, + "key": "editor.minimap", + "managedPolicy": false, + "persistence": "device", + "policyOnlyScopes": [], + "validation": { + "kind": "boolean" + }, + "valueType": "boolean" + }, + { + "allowedScopes": [ + "device" + ], + "apply": "immediate", + "defaultValue": "none", + "deprecated": false, + "key": "editor.renderWhitespace", + "managedPolicy": false, + "persistence": "device", + "policyOnlyScopes": [], + "validation": { + "kind": "enum", + "options": [ + "none", + "selection", + "boundary", + "all" + ] + }, + "valueType": "string" + }, + { + "allowedScopes": [ + "device" + ], + "apply": "immediate", + "defaultValue": 2, + "deprecated": false, + "key": "editor.tabSize", + "managedPolicy": false, + "persistence": "device", + "policyOnlyScopes": [], + "validation": { + "kind": "integer", + "maximum": 8, + "minimum": 1 + }, + "valueType": "integer" + }, + { + "allowedScopes": [ + "device" + ], + "apply": "immediate", + "defaultValue": false, + "deprecated": false, + "key": "editor.wordWrap", + "managedPolicy": false, + "persistence": "device", + "policyOnlyScopes": [], + "validation": { + "kind": "boolean" + }, + "valueType": "boolean" + }, + { + "allowedScopes": [ + "device" + ], + "apply": "immediate", + "defaultValue": true, + "deprecated": false, + "key": "voice.enabled", + "managedPolicy": false, + "persistence": "device", + "policyOnlyScopes": [], + "validation": { + "kind": "boolean" + }, + "valueType": "boolean" + } + ] +} as const; + +/** sha256 of the canonical encoding of AETHER_SETTINGS_VECTORS. */ +export const AETHER_SETTINGS_VECTORS_DIGEST = + "8f4ff065855169a642cc1dca2d5b83a85bee13db9c45aa05ab434b746379d68e" as const; + +/** + * sha256 of the canonical encoding of the registry these vectors came from. + * AETHER-CLOUD pins the same literal, so a default/scope/key changed on one + * side alone fails the other side's parity test. + */ +export const AETHER_SETTINGS_CONTRACT_DIGEST = + "4cd562dab6b53338e7fa57e5ddbb59d8809093da4a426f5c9d72d9a167d70e6a" as const; + +export const AETHER_SETTINGS_VECTORS_SOURCE = { + repo: "AetherAI3/AETHER-CLOUD", + path: "contracts/aether-settings/cross-surface-vectors.v1.json", + commit: "80e810ada7501127019c704a9a9aeadcee2edb96", + contractVersion: 1, +} as const; diff --git a/test/settings_canonical.test.ts b/test/settings_canonical.test.ts new file mode 100644 index 00000000..4ea59529 --- /dev/null +++ b/test/settings_canonical.test.ts @@ -0,0 +1,229 @@ +// Cross-surface parity for the canonical settings contract. +// +// These are the Agent half of the F2 Section M vectors. If AETHER-CLOUD changes +// a canonical key, scope, type or default without re-vendoring +// src/generated/settings_vectors.ts, the digest assertions below fail here; if +// this repository edits the vendored copy by hand, they fail too. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; + +import { + AETHER_SETTINGS_CONTRACT_DIGEST, + AETHER_SETTINGS_VECTORS, + AETHER_SETTINGS_VECTORS_DIGEST, + AETHER_SETTINGS_VECTORS_SOURCE, +} from "../src/generated/settings_vectors.js"; +import { + AGENT_SETTING_ALIASES, + AetherSettingsError, + CANONICAL_SETTINGS_SCHEMA, + CANONICAL_SETTINGS_VECTORS_SCHEMA, + NON_CANONICAL_AGENT_SETTINGS, + assertScopeAllowed, + canonicalAuthority, + canonicalKeyForAgentSetting, + canonicalSetting, + canonicalSettings, + defaultWriteScope, + requireCanonicalSetting, + validateCanonicalValue, +} from "../src/core/settings_canonical.js"; + +/** The exact recipe the vendored file's header documents. */ +function sortKeysDeep(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeysDeep); + if (value && typeof value === "object") { + const out: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + out[key] = sortKeysDeep((value as Record)[key]); + } + return out; + } + return value; +} + +function canonicalDigest(value: unknown): string { + return createHash("sha256") + .update(JSON.stringify(sortKeysDeep(value)), "utf8") + .digest("hex"); +} + +test("the vendored vector file reproduces its own pinned digest", () => { + assert.equal(canonicalDigest(AETHER_SETTINGS_VECTORS), AETHER_SETTINGS_VECTORS_DIGEST); +}); + +test("the vendored vectors name the contract they were generated from", () => { + assert.equal(AETHER_SETTINGS_VECTORS.schema, CANONICAL_SETTINGS_VECTORS_SCHEMA); + assert.equal(AETHER_SETTINGS_VECTORS.contractSchema, CANONICAL_SETTINGS_SCHEMA); + assert.equal(AETHER_SETTINGS_VECTORS.contractDigest, AETHER_SETTINGS_CONTRACT_DIGEST); + assert.equal( + AETHER_SETTINGS_VECTORS.contractVersion, + AETHER_SETTINGS_VECTORS_SOURCE.contractVersion, + ); + assert.equal(AETHER_SETTINGS_VECTORS_SOURCE.repo, "AetherAI3/AETHER-CLOUD"); + assert.match(AETHER_SETTINGS_VECTORS_SOURCE.commit, /^[0-9a-f]{40}$/); +}); + +test("every canonical setting is exposed with its canonical facts intact", () => { + const exposed = canonicalSettings(); + assert.equal(exposed.length, AETHER_SETTINGS_VECTORS.settings.length); + for (const pinned of AETHER_SETTINGS_VECTORS.settings) { + const actual = requireCanonicalSetting(pinned.key); + assert.equal(actual.valueType, pinned.valueType); + assert.deepEqual(actual.defaultValue, pinned.defaultValue); + assert.deepEqual([...actual.allowedScopes], [...pinned.allowedScopes]); + assert.equal(actual.persistence, pinned.persistence); + assert.equal(actual.apply, pinned.apply); + assert.equal(actual.managedPolicy, pinned.managedPolicy); + assert.equal(actual.deprecated, pinned.deprecated); + } +}); + +test("the exposed settings cannot be mutated by a consumer", () => { + const [first] = canonicalSettings(); + assert.ok(first); + assert.ok(Object.isFrozen(first)); +}); + +test("routing follows the canonical persistence owner, never a local guess", () => { + for (const item of canonicalSettings()) { + const expected = item.persistence === "server" ? "cloud" : "device"; + assert.equal(canonicalAuthority(item.key), expected, item.key); + } + assert.equal(canonicalAuthority("agent.defaultModel"), "cloud"); + assert.equal(canonicalAuthority("editor.fontSize"), "device"); +}); + +test("a device key is never account/project scoped and vice versa", () => { + for (const item of canonicalSettings()) { + if (item.persistence === "device") { + assert.deepEqual([...item.allowedScopes], ["device"], item.key); + assert.equal(defaultWriteScope(item.key), "device", item.key); + } else { + assert.ok(!item.allowedScopes.includes("device"), item.key); + assert.equal(defaultWriteScope(item.key), "account", item.key); + } + } +}); + +test("an out-of-scope write is refused with a stable public code", () => { + assert.throws( + () => assertScopeAllowed("editor.fontSize", "account"), + (err: unknown) => + err instanceof AetherSettingsError && + err.code === "AETHER_SETTINGS_SCOPE_INVALID" && + err.keys.includes("editor.fontSize"), + ); + assert.throws( + () => assertScopeAllowed("agent.defaultModel", "device"), + (err: unknown) => + err instanceof AetherSettingsError && err.code === "AETHER_SETTINGS_SCOPE_INVALID", + ); + assert.doesNotThrow(() => assertScopeAllowed("agent.defaultModel", "project")); +}); + +test("an unknown key is refused, never invented", () => { + assert.equal(canonicalSetting("nope.not.a.key"), null); + assert.throws( + () => requireCanonicalSetting("nope.not.a.key"), + (err: unknown) => + err instanceof AetherSettingsError && err.code === "AETHER_SETTINGS_KEY_UNKNOWN", + ); +}); + +test("values are validated against the canonical constraint", () => { + assert.equal(validateCanonicalValue("editor.wordWrap", true), true); + assert.equal(validateCanonicalValue("editor.fontSize", 14), 14); + assert.equal(validateCanonicalValue("agent.defaultEffort", "high"), "high"); + + const rejected: Array<[string, unknown]> = [ + ["editor.wordWrap", "true"], + ["editor.fontSize", 7], + ["editor.fontSize", 41], + ["editor.fontSize", 14.5], + ["editor.tabSize", 0], + ["agent.defaultEffort", "turbo"], + ["appearance.themeId", "chartreuse"], + ["agent.defaultModel", ""], + ["agent.defaultModel", " opus5 "], + ]; + for (const [key, value] of rejected) { + assert.throws( + () => validateCanonicalValue(key, value), + (err: unknown) => + err instanceof AetherSettingsError && + err.code === "AETHER_SETTINGS_VALUE_INVALID", + `${key} should have rejected ${JSON.stringify(value)}`, + ); + } +}); + +test("a model outside the supplied catalog is refused, but only when a catalog is supplied", () => { + assert.equal( + validateCanonicalValue("agent.defaultModel", "opus5", { availableModels: ["opus5"] }), + "opus5", + ); + assert.throws( + () => + validateCanonicalValue("agent.defaultModel", "opus5", { + availableModels: ["sonnet"], + }), + (err: unknown) => + err instanceof AetherSettingsError && err.code === "AETHER_SETTINGS_VALUE_INVALID", + ); + // Offline: no catalog is available, so shape alone decides. + assert.equal(validateCanonicalValue("agent.defaultModel", "opus5"), "opus5"); +}); + +test("a typed failure carries keys and never a value", () => { + const err = new AetherSettingsError("AETHER_SETTINGS_REVISION_CONFLICT", "conflict", { + keys: ["agent.defaultModel"], + conflicts: [ + { key: "agent.defaultModel", expectedRevision: 10, actualRevision: 11 }, + ], + traceId: "trc_0123456789abcdef", + }); + assert.equal(err.code, "AETHER_SETTINGS_REVISION_CONFLICT"); + assert.deepEqual([...err.keys], ["agent.defaultModel"]); + assert.equal(err.conflicts[0]?.actualRevision, 11); + assert.equal(err.traceId, "trc_0123456789abcdef"); + // The error surface has no field that could hold a setting value. + const allowed = new Set(["name", "code", "keys", "conflicts", "traceId"]); + for (const field of Object.keys(err)) { + assert.ok(allowed.has(field), `unexpected field on the error surface: ${field}`); + } + assert.ok(!JSON.stringify({ ...err }).includes("opus5")); +}); + +test("every alias points at a real canonical key", () => { + for (const [agentId, canonicalKey] of Object.entries(AGENT_SETTING_ALIASES)) { + assert.ok( + canonicalSetting(canonicalKey), + `${agentId} aliases ${canonicalKey}, which is not canonical`, + ); + assert.equal(canonicalKeyForAgentSetting(agentId), canonicalKey); + } + assert.equal(canonicalKeyForAgentSetting("code.backend"), null); +}); + +test("no agent setting is both aliased and declared non-canonical", () => { + for (const agentId of Object.keys(AGENT_SETTING_ALIASES)) { + assert.ok( + !(agentId in NON_CANONICAL_AGENT_SETTINGS), + `${agentId} is classified twice`, + ); + } + for (const reason of Object.values(NON_CANONICAL_AGENT_SETTINGS)) { + assert.ok(reason.length > 0, "every exclusion must record a reason"); + } +}); + +test("code.auto_apply is deliberately not equated with the Live Canvas ceiling", () => { + assert.equal(canonicalKeyForAgentSetting("code.auto_apply"), null); + assert.match( + NON_CANONICAL_AGENT_SETTINGS["code.auto_apply"] ?? "", + /actions\.liveCanvas\.autoApply/, + ); +}); From 0ec3b48c7edf50d80a8d4b7d2f4ee589cf2fa810 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Mon, 7 Sep 2026 17:50:01 -0400 Subject: [PATCH 2/4] feat(settings): read and write Cloud-authoritative settings with CAS F2 convergence, Agent step 2: give the Agent the one client it is allowed to use for account- and project-scoped settings, and make a stale write impossible to perform by accident. `src/core/settings_cloud.ts` talks to the canonical service at /code/settings. It owns no schema -- every key, type, default and scope comes from settings_canonical.ts, which reads the vendored canonical contract. Compare-and-swap is not optional here: - every write sends the per-key `expectedRevisions` the caller actually read - a write with a missing or non-integer revision is refused BEFORE the wire, because sending 0 would mean "I expect this to be unset" and could clobber a value the caller never saw - a REVISION_CONFLICT is surfaced with both the expected and the actual revision so the user or model can decide; the client never re-reads and retries, since a silent retry is blind last-write-wins under another name - the Idempotency-Key is derived from the exact request, so an interrupted retry of the SAME edit replays the server's original receipt instead of applying twice, while a different edit gets a different key A device-scoped key is refused before the wire: those have no server row and never will. Offline is kept honest. A transport failure, a 5xx, and the server's own SETTINGS_BACKEND_UNAVAILABLE / MODEL_CATALOG_UNAVAILABLE all become AETHER_SETTINGS_OFFLINE, which is distinct from a 4xx refusal -- only the refusal means the edit was seen. Nothing here reports "saved" without a server receipt. Server error codes are mapped onto the stable public codes from step 1. A diagnostic traceId survives the mapping; a setting value never appears on an error. transport.ts gains `patchJson` and `postJsonWithHeaders` plus an optional `headers` on the private request(). Caller headers are merged FIRST so the client's own Authorization, Content-Type and Accept always win: a caller can add Idempotency-Key, never replace credentials. Existing methods are unchanged. Nothing routes through this client yet; no existing setting, file or CLI command is altered. Tests: test/settings_cloud.test.ts (14 cases) covers the Section H conflict scenario end to end, idempotency-key stability, pre-wire refusal of device keys/invalid values/missing revisions, project scoping, reset, offline vs refusal, the server-code map, trace-id survival and a malformed conflict payload. Full agent suite re-run for the transport change: 2279 passed, 0 failed, 7 skipped. --- src/core/settings_cloud.ts | 357 ++++++++++++++++++++++++++++++++++++ src/core/transport.ts | 44 ++++- test/settings_cloud.test.ts | 336 +++++++++++++++++++++++++++++++++ 3 files changed, 736 insertions(+), 1 deletion(-) create mode 100644 src/core/settings_cloud.ts create mode 100644 test/settings_cloud.test.ts diff --git a/src/core/settings_cloud.ts b/src/core/settings_cloud.ts new file mode 100644 index 00000000..ff36ed63 --- /dev/null +++ b/src/core/settings_cloud.ts @@ -0,0 +1,357 @@ +// The Agent's client for the canonical Cloud settings service. +// +// Account- and project-scoped settings are Cloud-authoritative. This module is +// the only place the Agent reads or writes them, and every write carries the +// expected per-key revision, so a stale write is refused by the server instead +// of silently winning. +// +// It owns no schema: keys, types, defaults and scopes all come from +// settings_canonical.ts, which reads the vendored canonical contract. +// +// Two rules this module exists to keep honest: +// +// 1. A local cache of an account/project value is NEVER authoritative. It is +// returned only as an explicitly-stale projection with the revision it was +// captured at. +// 2. An offline write is never reported as saved. It fails with +// AETHER_SETTINGS_OFFLINE and the caller keeps it as pending user state. + +import { createHash } from "node:crypto"; + +import { HttpError } from "./errors.js"; +import type { ApiClient } from "./transport.js"; +import { + AetherSettingsError, + assertScopeAllowed, + requireCanonicalSetting, + validateCanonicalValue, + type AetherSettingsConflictDetail, + type AetherSettingsErrorCode, + type CanonicalScope, +} from "./settings_canonical.js"; + +export const CODE_SETTINGS_BASE = "/code/settings" as const; + +/** The scopes the server accepts a mutation at. `device` is never one of them. */ +export type CloudWritableScope = Extract; + +export interface EffectiveOverride { + readonly configured: boolean; + readonly value?: unknown; + readonly revision: number; +} + +export interface EffectiveSetting { + readonly key: string; + readonly effectiveValue: unknown; + readonly sourceScope: "default" | "account" | "project" | "policy"; + readonly sourceId: string | null; + readonly revision: number; + readonly overrides: Readonly>; + readonly managed: boolean; + readonly locked: boolean; + readonly reason: string | null; + readonly policyRef: string | null; + readonly capability: { readonly available: boolean; readonly reason?: string | null }; + readonly apply: string; +} + +export interface EffectiveSettingsResponse { + readonly schema: "aether.settings.effective/1"; + /** Opaque digest of the whole resolved view; not a per-key revision. */ + readonly revision: string; + readonly projectId: string | null; + readonly settings: Readonly>; +} + +export interface SettingsMutationResponse { + readonly schema: "aether.settings.mutation/1"; + readonly operation: "patch" | "reset"; + readonly scope: CloudWritableScope; + readonly scopeId: string | null; + /** True when the server replayed an earlier identical request. */ + readonly duplicate: boolean; + readonly changedKeys: readonly string[]; + readonly revisions: Readonly>; + readonly revision: string; +} + +/** Maps the server's closed error codes onto this repository's public codes. */ +const SERVER_CODE_MAP: Readonly> = Object.freeze( + { + REVISION_CONFLICT: "AETHER_SETTINGS_REVISION_CONFLICT", + IDEMPOTENCY_CONFLICT: "AETHER_SETTINGS_REVISION_CONFLICT", + UNAUTHORIZED: "AETHER_SETTINGS_UNAUTHORIZED", + PROJECT_NOT_FOUND: "AETHER_SETTINGS_PROJECT_NOT_FOUND", + SETTINGS_DISABLED: "AETHER_SETTINGS_DISABLED", + SETTINGS_UNAVAILABLE: "AETHER_SETTINGS_DISABLED", + CAPABILITY_DENIED: "AETHER_SETTINGS_POLICY_DENIED", + POLICY_DENIED: "AETHER_SETTINGS_POLICY_DENIED", + POLICY_LOCKED: "AETHER_SETTINGS_POLICY_DENIED", + TEAM_POLICY_ONLY: "AETHER_SETTINGS_POLICY_DENIED", + FORBIDDEN_SCOPE: "AETHER_SETTINGS_SCOPE_INVALID", + INCORRECT_SCOPE: "AETHER_SETTINGS_SCOPE_INVALID", + INVALID_PROJECT_SCOPE: "AETHER_SETTINGS_SCOPE_INVALID", + DEVICE_SCOPE_SERVER_REJECTED: "AETHER_SETTINGS_SCOPE_INVALID", + INVALID_REQUEST: "AETHER_SETTINGS_VALUE_INVALID", + INVALID_IDEMPOTENCY_KEY: "AETHER_SETTINGS_VALUE_INVALID", + MODEL_CATALOG_UNAVAILABLE: "AETHER_SETTINGS_OFFLINE", + SETTINGS_BACKEND_UNAVAILABLE: "AETHER_SETTINGS_OFFLINE", + }, +); + +function detailOf(body: unknown): Record | null { + if (!body || typeof body !== "object") return null; + const outer = body as Record; + const inner = outer["detail"]; + if (inner && typeof inner === "object") return inner as Record; + return "code" in outer ? outer : null; +} + +function conflictsOf( + detail: Record | null, +): AetherSettingsConflictDetail[] { + const raw = detail?.["conflicts"]; + if (!raw || typeof raw !== "object") return []; + const out: AetherSettingsConflictDetail[] = []; + for (const [key, value] of Object.entries(raw as Record)) { + if (!value || typeof value !== "object") continue; + const pair = value as Record; + const expected = pair["expected"]; + const actual = pair["actual"]; + if (typeof expected !== "number" || typeof actual !== "number") continue; + out.push({ key, expectedRevision: expected, actualRevision: actual }); + } + return out; +} + +/** + * Translate any failure into a stable public error. + * + * A transport failure becomes AETHER_SETTINGS_OFFLINE, because from the user's + * point of view "the server did not answer" and "the server refused" must never + * look the same: only the second one means their edit was seen. + */ +export function toSettingsError( + err: unknown, + keys: readonly string[], +): AetherSettingsError { + if (err instanceof AetherSettingsError) return err; + if (err instanceof HttpError) { + const detail = detailOf(err.body); + const serverCode = + typeof detail?.["code"] === "string" ? (detail["code"] as string) : ""; + const message = + typeof detail?.["message"] === "string" + ? (detail["message"] as string) + : err.message; + const traceId = + typeof detail?.["traceId"] === "string" ? (detail["traceId"] as string) : undefined; + // 5xx is the server telling us its own backend is unreachable; that is an + // availability failure, not a rejection of the edit. + const mapped: AetherSettingsErrorCode = + SERVER_CODE_MAP[serverCode] ?? + (err.status >= 500 ? "AETHER_SETTINGS_OFFLINE" : "AETHER_SETTINGS_BACKEND_ERROR"); + return new AetherSettingsError(mapped, message, { + keys, + conflicts: conflictsOf(detail), + traceId, + }); + } + return new AetherSettingsError( + "AETHER_SETTINGS_OFFLINE", + "Aether Cloud did not answer; the change was not saved", + { keys }, + ); +} + +/** + * Deterministic idempotency key for one exact edit. + * + * Deriving it from the request means an interrupted retry of the SAME edit + * replays the server's original receipt instead of applying twice, while a + * genuinely different edit gets a different key. Matches the server's required + * shape: ^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$. + */ +export function idempotencyKeyFor(request: { + operation: "patch" | "reset"; + scope: CloudWritableScope; + projectId?: string | null; + values?: Readonly>; + keys?: readonly string[]; + expectedRevisions: Readonly>; +}): string { + const sortedKeys = [...(request.keys ?? Object.keys(request.values ?? {}))].sort(); + const canonical = JSON.stringify({ + operation: request.operation, + scope: request.scope, + scopeId: request.projectId ?? null, + keys: sortedKeys, + values: request.values + ? Object.fromEntries(sortedKeys.map((k) => [k, request.values?.[k] ?? null])) + : null, + expectedRevisions: Object.fromEntries( + sortedKeys.map((k) => [k, request.expectedRevisions[k] ?? null]), + ), + }); + return `s${createHash("sha256").update(canonical, "utf8").digest("hex").slice(0, 48)}`; +} + +export interface CloudSettingsClientDeps { + readonly api: Pick; +} + +export class CloudSettingsClient { + private readonly api: CloudSettingsClientDeps["api"]; + + constructor(deps: CloudSettingsClientDeps) { + this.api = deps.api; + } + + /** The canonical resolved view. Read-only; never a write path. */ + async effective( + opts: { projectId?: string | null; signal?: AbortSignal } = {}, + ): Promise { + const query = opts.projectId + ? `?project_id=${encodeURIComponent(opts.projectId)}` + : ""; + try { + return await this.api.getJson( + `${CODE_SETTINGS_BASE}/effective${query}`, + opts.signal, + ); + } catch (err) { + throw toSettingsError(err, []); + } + } + + /** + * Write server-backed settings with compare-and-swap. + * + * `expectedRevisions` must be the revisions the caller actually READ. A stale + * one is refused with AETHER_SETTINGS_REVISION_CONFLICT carrying both + * revisions; this client never re-reads and retries, because a silent retry + * is a blind last-write-wins wearing a different name. + */ + async patch( + scope: CloudWritableScope, + request: { + values: Readonly>; + expectedRevisions: Readonly>; + projectId?: string | null; + availableModels?: readonly string[]; + }, + opts: { signal?: AbortSignal } = {}, + ): Promise { + const keys = Object.keys(request.values).sort(); + this.assertWritable(scope, keys, request.expectedRevisions); + const values: Record = {}; + for (const key of keys) { + const validateOpts = request.availableModels + ? { availableModels: request.availableModels } + : {}; + values[key] = validateCanonicalValue(key, request.values[key], validateOpts); + } + const body = { + ...(request.projectId ? { projectId: request.projectId } : {}), + values, + expectedRevisions: Object.fromEntries( + keys.map((k) => [k, request.expectedRevisions[k]]), + ), + }; + const idempotencyKey = idempotencyKeyFor({ + operation: "patch", + scope, + projectId: request.projectId ?? null, + values, + expectedRevisions: request.expectedRevisions, + }); + try { + return await this.api.patchJson( + `${CODE_SETTINGS_BASE}/${scope}`, + body, + { headers: { "Idempotency-Key": idempotencyKey }, ...opts }, + ); + } catch (err) { + throw toSettingsError(err, keys); + } + } + + /** Clear overrides at one scope, under the same CAS contract as patch(). */ + async reset( + scope: CloudWritableScope, + request: { + keys: readonly string[]; + expectedRevisions: Readonly>; + projectId?: string | null; + }, + opts: { signal?: AbortSignal } = {}, + ): Promise { + const keys = [...request.keys].sort(); + this.assertWritable(scope, keys, request.expectedRevisions); + const body = { + ...(request.projectId ? { projectId: request.projectId } : {}), + keys, + expectedRevisions: Object.fromEntries( + keys.map((k) => [k, request.expectedRevisions[k]]), + ), + }; + const idempotencyKey = idempotencyKeyFor({ + operation: "reset", + scope, + projectId: request.projectId ?? null, + keys, + expectedRevisions: request.expectedRevisions, + }); + try { + return await this.api.postJsonWithHeaders( + `${CODE_SETTINGS_BASE}/${scope}/reset`, + body, + { headers: { "Idempotency-Key": idempotencyKey }, ...opts }, + ); + } catch (err) { + throw toSettingsError(err, keys); + } + } + + /** + * Refuse locally what the server would refuse anyway. + * + * This is a courtesy, never an authority: the server re-checks scope, policy + * and ownership. Checking here keeps a device key from ever being put on the + * wire at account scope, and keeps a missing revision from being sent as 0 — + * which would read as "I expect this to be unset" and could clobber a real + * value the caller never saw. + */ + private assertWritable( + scope: CloudWritableScope, + keys: readonly string[], + expectedRevisions: Readonly>, + ): void { + if (keys.length === 0) { + throw new AetherSettingsError( + "AETHER_SETTINGS_VALUE_INVALID", + "a settings write must name at least one key", + ); + } + for (const key of keys) { + const definition = requireCanonicalSetting(key); + if (definition.persistence !== "server") { + throw new AetherSettingsError( + "AETHER_SETTINGS_SCOPE_INVALID", + `${key} is a device setting and is never written through Aether Cloud`, + { keys: [key] }, + ); + } + assertScopeAllowed(key, scope); + const revision = expectedRevisions[key]; + if (typeof revision !== "number" || !Number.isInteger(revision) || revision < 0) { + throw new AetherSettingsError( + "AETHER_SETTINGS_REVISION_CONFLICT", + `${key} was written without the revision it was read at`, + { keys: [key] }, + ); + } + } + } +} diff --git a/src/core/transport.ts b/src/core/transport.ts index 9778c910..056c870f 100644 --- a/src/core/transport.ts +++ b/src/core/transport.ts @@ -377,6 +377,39 @@ export class ApiClient { return this.request("GET", path, { signal, timeoutMs }); } + /** + * PATCH with caller-supplied headers. + * + * `headers` exists for request-identifying metadata a route requires — today + * only `Idempotency-Key` on the Code settings mutation routes, which reject a + * request without one. It can never override the Authorization, Content-Type + * or Accept headers this client sets; see request(). + */ + async patchJson( + path: string, + body: unknown, + opts: { + headers?: Record; + signal?: AbortSignal; + timeoutMs?: number; + } = {}, + ): Promise { + return this.request("PATCH", path, { body, ...opts }); + } + + /** POST with caller-supplied headers; see patchJson for the header rules. */ + async postJsonWithHeaders( + path: string, + body: unknown, + opts: { + headers?: Record; + signal?: AbortSignal; + timeoutMs?: number; + } = {}, + ): Promise { + return this.request("POST", path, { body, ...opts }); + } + async deleteJson(path: string, signal?: AbortSignal, timeoutMs?: number): Promise { return this.request("DELETE", path, { signal, timeoutMs }); } @@ -590,7 +623,12 @@ export class ApiClient { private async request( method: string, path: string, - opts: { body?: unknown; signal?: AbortSignal; timeoutMs?: number } = {}, + opts: { + body?: unknown; + signal?: AbortSignal; + timeoutMs?: number; + headers?: Record; + } = {}, ): Promise { // `?? ` (not `||`) so an explicit 0 (disabled) from a caller survives — // only an OMITTED timeoutMs falls back to the env-driven default. @@ -617,6 +655,10 @@ export class ApiClient { fetch(this.url(path), { method, headers: { + // Caller headers go FIRST so the client's own transport and + // credential headers below always win: a caller can add + // Idempotency-Key, never replace Authorization. + ...(opts.headers ?? {}), ...(opts.body !== undefined ? { "Content-Type": "application/json" } : {}), Accept: "application/json", ...(await this.authHeaders(used)), diff --git a/test/settings_cloud.test.ts b/test/settings_cloud.test.ts new file mode 100644 index 00000000..48d218e5 --- /dev/null +++ b/test/settings_cloud.test.ts @@ -0,0 +1,336 @@ +// CAS, conflict and offline behaviour for the canonical Cloud settings client. +// +// The headline case is F2 Section H: two surfaces read the same revision, one +// writes, and the second must be REFUSED with both revisions surfaced — never +// retried into a silent overwrite. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { HttpError } from "../src/core/errors.js"; +import { AetherSettingsError } from "../src/core/settings_canonical.js"; +import { + CloudSettingsClient, + idempotencyKeyFor, + toSettingsError, +} from "../src/core/settings_cloud.js"; + +interface Call { + method: "GET" | "PATCH" | "POST"; + path: string; + body?: unknown; + headers?: Record; +} + +/** A recording fake; no network, no disk. */ +function fakeApi(handler: (call: Call) => unknown) { + const calls: Call[] = []; + const api = { + async getJson(path: string): Promise { + const call: Call = { method: "GET", path }; + calls.push(call); + return handler(call) as T; + }, + async patchJson( + path: string, + body: unknown, + opts: { headers?: Record } = {}, + ): Promise { + const call: Call = { + method: "PATCH", + path, + body, + ...(opts.headers ? { headers: opts.headers } : {}), + }; + calls.push(call); + return handler(call) as T; + }, + async postJsonWithHeaders( + path: string, + body: unknown, + opts: { headers?: Record } = {}, + ): Promise { + const call: Call = { + method: "POST", + path, + body, + ...(opts.headers ? { headers: opts.headers } : {}), + }; + calls.push(call); + return handler(call) as T; + }, + }; + return { api, calls }; +} + +const OK_MUTATION = { + schema: "aether.settings.mutation/1", + operation: "patch", + scope: "account", + scopeId: null, + duplicate: false, + changedKeys: ["agent.defaultModel"], + revisions: { "agent.defaultModel": 11 }, + revision: "sha256:deadbeef", +}; + +test("a server-backed write carries the expected revision and an idempotency key", async () => { + const { api, calls } = fakeApi(() => OK_MUTATION); + const client = new CloudSettingsClient({ api }); + + await client.patch("account", { + values: { "agent.defaultModel": "opus5" }, + expectedRevisions: { "agent.defaultModel": 10 }, + }); + + assert.equal(calls.length, 1); + const [call] = calls; + assert.ok(call); + assert.equal(call.method, "PATCH"); + assert.equal(call.path, "/code/settings/account"); + assert.deepEqual(call.body, { + values: { "agent.defaultModel": "opus5" }, + expectedRevisions: { "agent.defaultModel": 10 }, + }); + const idem = call.headers?.["Idempotency-Key"] ?? ""; + assert.match(idem, /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/); +}); + +test("the same edit reuses one idempotency key; a different edit does not", () => { + const base = { + operation: "patch" as const, + scope: "account" as const, + values: { "agent.defaultModel": "opus5" }, + expectedRevisions: { "agent.defaultModel": 10 }, + }; + assert.equal(idempotencyKeyFor(base), idempotencyKeyFor({ ...base })); + assert.notEqual( + idempotencyKeyFor(base), + idempotencyKeyFor({ ...base, values: { "agent.defaultModel": "sonnet" } }), + ); + assert.notEqual( + idempotencyKeyFor(base), + idempotencyKeyFor({ ...base, expectedRevisions: { "agent.defaultModel": 11 } }), + ); + assert.notEqual( + idempotencyKeyFor(base), + idempotencyKeyFor({ ...base, scope: "project", projectId: "prj_00112233445566aa" }), + ); +}); + +test("Section H: a stale write is refused with both revisions, and never retried", async () => { + // Online read 10, this surface read 10, Online wrote -> 11. + let attempts = 0; + const { api } = fakeApi(() => { + attempts += 1; + throw new HttpError(409, "conflict", { + detail: { + code: "REVISION_CONFLICT", + message: "One or more settings changed before this request", + conflicts: { "agent.defaultModel": { expected: 10, actual: 11 } }, + }, + }); + }); + const client = new CloudSettingsClient({ api }); + + await assert.rejects( + client.patch("account", { + values: { "agent.defaultModel": "opus5" }, + expectedRevisions: { "agent.defaultModel": 10 }, + }), + (err: unknown) => { + assert.ok(err instanceof AetherSettingsError); + assert.equal(err.code, "AETHER_SETTINGS_REVISION_CONFLICT"); + assert.deepEqual( + [...err.conflicts], + [{ key: "agent.defaultModel", expectedRevision: 10, actualRevision: 11 }], + ); + return true; + }, + ); + // The decision belongs to the user; the client must not have tried again. + assert.equal(attempts, 1); +}); + +test("a write without the revision it was read at is refused before the wire", async () => { + const { api, calls } = fakeApi(() => OK_MUTATION); + const client = new CloudSettingsClient({ api }); + + await assert.rejects( + client.patch("account", { + values: { "agent.defaultModel": "opus5" }, + expectedRevisions: {}, + }), + (err: unknown) => + err instanceof AetherSettingsError && + err.code === "AETHER_SETTINGS_REVISION_CONFLICT", + ); + assert.equal(calls.length, 0); +}); + +test("a device setting never reaches the server", async () => { + const { api, calls } = fakeApi(() => OK_MUTATION); + const client = new CloudSettingsClient({ api }); + + await assert.rejects( + client.patch("account", { + values: { "editor.fontSize": 16 }, + expectedRevisions: { "editor.fontSize": 3 }, + }), + (err: unknown) => + err instanceof AetherSettingsError && err.code === "AETHER_SETTINGS_SCOPE_INVALID", + ); + assert.equal(calls.length, 0); +}); + +test("an invalid value is refused before the wire", async () => { + const { api, calls } = fakeApi(() => OK_MUTATION); + const client = new CloudSettingsClient({ api }); + + await assert.rejects( + client.patch("account", { + values: { "agent.defaultEffort": "turbo" }, + expectedRevisions: { "agent.defaultEffort": 1 }, + }), + (err: unknown) => + err instanceof AetherSettingsError && err.code === "AETHER_SETTINGS_VALUE_INVALID", + ); + assert.equal(calls.length, 0); +}); + +test("a project write sends the project id and targets the project scope", async () => { + const { api, calls } = fakeApi(() => ({ ...OK_MUTATION, scope: "project" })); + const client = new CloudSettingsClient({ api }); + + await client.patch("project", { + values: { "agent.defaultEffort": "high" }, + expectedRevisions: { "agent.defaultEffort": 2 }, + projectId: "prj_00112233445566aa", + }); + + const [call] = calls; + assert.ok(call); + assert.equal(call.path, "/code/settings/project"); + assert.deepEqual(call.body, { + projectId: "prj_00112233445566aa", + values: { "agent.defaultEffort": "high" }, + expectedRevisions: { "agent.defaultEffort": 2 }, + }); +}); + +test("reset uses the same CAS contract on its own route", async () => { + const { api, calls } = fakeApi(() => ({ ...OK_MUTATION, operation: "reset" })); + const client = new CloudSettingsClient({ api }); + + await client.reset("account", { + keys: ["agent.defaultModel"], + expectedRevisions: { "agent.defaultModel": 11 }, + }); + + const [call] = calls; + assert.ok(call); + assert.equal(call.method, "POST"); + assert.equal(call.path, "/code/settings/account/reset"); + assert.deepEqual(call.body, { + keys: ["agent.defaultModel"], + expectedRevisions: { "agent.defaultModel": 11 }, + }); + assert.match( + call.headers?.["Idempotency-Key"] ?? "", + /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/, + ); +}); + +test("the effective read is a plain GET and passes the project id through", async () => { + const { api, calls } = fakeApi(() => ({ + schema: "aether.settings.effective/1", + revision: "sha256:abc", + projectId: "prj_00112233445566aa", + settings: {}, + })); + const client = new CloudSettingsClient({ api }); + + await client.effective(); + await client.effective({ projectId: "prj_00112233445566aa" }); + + assert.equal(calls[0]?.path, "/code/settings/effective"); + assert.equal( + calls[1]?.path, + "/code/settings/effective?project_id=prj_00112233445566aa", + ); +}); + +test("a transport failure is offline, never a rejection of the edit", async () => { + const { api } = fakeApi(() => { + throw new TypeError("fetch failed"); + }); + const client = new CloudSettingsClient({ api }); + + await assert.rejects( + client.patch("account", { + values: { "agent.defaultModel": "opus5" }, + expectedRevisions: { "agent.defaultModel": 10 }, + }), + (err: unknown) => + err instanceof AetherSettingsError && err.code === "AETHER_SETTINGS_OFFLINE", + ); +}); + +test("a 5xx is offline; a 4xx refusal is not", () => { + const offline = toSettingsError(new HttpError(503, "unavailable", {}), ["k"]); + assert.equal(offline.code, "AETHER_SETTINGS_OFFLINE"); + + const refused = toSettingsError(new HttpError(400, "bad", {}), ["k"]); + assert.equal(refused.code, "AETHER_SETTINGS_BACKEND_ERROR"); +}); + +test("server codes map onto the stable public codes", () => { + const cases: Array<[string, number, string]> = [ + ["UNAUTHORIZED", 401, "AETHER_SETTINGS_UNAUTHORIZED"], + ["PROJECT_NOT_FOUND", 404, "AETHER_SETTINGS_PROJECT_NOT_FOUND"], + ["SETTINGS_DISABLED", 403, "AETHER_SETTINGS_DISABLED"], + ["POLICY_LOCKED", 403, "AETHER_SETTINGS_POLICY_DENIED"], + ["TEAM_POLICY_ONLY", 403, "AETHER_SETTINGS_POLICY_DENIED"], + ["DEVICE_SCOPE_SERVER_REJECTED", 400, "AETHER_SETTINGS_SCOPE_INVALID"], + ["INVALID_REQUEST", 422, "AETHER_SETTINGS_VALUE_INVALID"], + ["SETTINGS_BACKEND_UNAVAILABLE", 503, "AETHER_SETTINGS_OFFLINE"], + ]; + for (const [serverCode, status, expected] of cases) { + const err = toSettingsError( + new HttpError(status, "x", { detail: { code: serverCode, message: "m" } }), + ["agent.defaultModel"], + ); + assert.equal(err.code, expected, serverCode); + } +}); + +test("a diagnostic trace id survives, but a setting value never does", () => { + const err = toSettingsError( + new HttpError(409, "conflict", { + detail: { + code: "REVISION_CONFLICT", + message: "changed before this request", + traceId: "trc_0123456789abcdef", + conflicts: { "agent.defaultModel": { expected: 10, actual: 11 } }, + }, + }), + ["agent.defaultModel"], + ); + assert.equal(err.traceId, "trc_0123456789abcdef"); + assert.ok(!JSON.stringify({ ...err }).includes("opus5")); +}); + +test("a malformed conflict payload degrades to no conflicts, not a crash", () => { + const err = toSettingsError( + new HttpError(409, "conflict", { + detail: { + code: "REVISION_CONFLICT", + message: "m", + conflicts: { "agent.defaultModel": { expected: "ten", actual: null } }, + }, + }), + ["agent.defaultModel"], + ); + assert.equal(err.code, "AETHER_SETTINGS_REVISION_CONFLICT"); + assert.equal(err.conflicts.length, 0); +}); From c567907095a468419c50f2824581f234820e0a4a Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Mon, 7 Sep 2026 18:13:03 -0400 Subject: [PATCH 3/4] feat(settings): add `aether settings status` F2 Section L: one diagnostic path that answers "is this machine agreeing with Cloud, and about what revision?" without dumping the user's settings. Schema aether.settings/1 Cloud connected Account rev 84 Project prj_00112233445566aa Project rev 21 Device ok Device rev a1b2c3d4e5f6 Pending 0 Conflicts 0 Design decisions worth naming: - Cloud is read LIVE. A status command that prints a cached account revision as though it were current is worse than one that says it does not know, so a failed read reports `offline` / `unauthorized` / `disabled` / `error` and the revisions read `unknown` rather than showing a stale number. - There is no single "account revision" in the contract -- revisions are per key -- so the report shows the newest CONFIGURED one, which is what an operator comparing two machines actually needs. A fresh account reads 0. - Values are never printed, in either the human or the --json form. The tests assert the configured model and effort do not appear in the output. - The device digest is truncated: its purpose is comparison between machines, not reconstruction. - Policy-locked keys are named so an operator stops hunting for a setting the server will never let them change, and a server key the contract knows but the response omitted is reported as drift. - A local store that cannot even be inspected is reported as `unreadable` rather than thrown, because the whole point of `status` is to still answer when something is broken. src/core/settings_status.ts is pure; the caller performs the reads. The command takes an optional `statusDeps` test seam and otherwise uses ctx.api and the existing VersionedSettingsStore. Additive only: no existing subcommand, exit code or flag changes behaviour. Tests: test/settings_status.test.ts (13 cases). Full agent suite: 2292 passed, 0 failed, 7 skipped (2299 total). --- src/commands/settings.ts | 69 +++++++++ src/core/settings_status.ts | 172 ++++++++++++++++++++++ test/settings_status.test.ts | 266 +++++++++++++++++++++++++++++++++++ 3 files changed, 507 insertions(+) create mode 100644 src/core/settings_status.ts create mode 100644 test/settings_status.test.ts diff --git a/src/commands/settings.ts b/src/commands/settings.ts index 5c9494ae..7b4d9696 100644 --- a/src/commands/settings.ts +++ b/src/commands/settings.ts @@ -27,6 +27,13 @@ import { type SettingValueType, type WritableSettingScope, } from "../core/settings_registry.js"; +import { AetherSettingsError } from "../core/settings_canonical.js"; +import { CloudSettingsClient, type EffectiveSettingsResponse } from "../core/settings_cloud.js"; +import { + buildSettingsStatus, + renderSettingsStatus, + type SettingsStatusReport, +} from "../core/settings_status.js"; import { VersionedSettingsStore, type SettingsStorePaths } from "../core/settings_store.js"; import { detectTerminalCapabilities, @@ -59,6 +66,7 @@ const MAX_IMPORT_BYTES = 1_048_576; const MAX_IMPORT_SETTINGS = 1_000; const USAGE = [ "usage: aether settings [list [section] | show | get ]", + " aether settings status", " aether settings set [--scope global|project]", " aether settings unset [--scope global|project]", " aether settings reset
[--scope global|project] [--preview]", @@ -83,6 +91,11 @@ export interface SettingsCommandOptions { readonly sessionId?: string; readonly confirmPhrase?: (confirmation: RequiredConfirmation) => Promise; readonly interactiveRuntime?: SettingsInteractiveRuntime; + /** Test seam for `settings status`; production leaves this absent. */ + readonly statusDeps?: { + readonly readEffective?: () => Promise; + readonly store?: Pick; + }; } /** Convert the settings command's owned flags without reparsing argv. Scope is @@ -131,6 +144,54 @@ export function createSettingsCommandRegistry( }); } +/** + * Build `aether settings status` without printing anything the user owns. + * + * Cloud is read live because a cached account revision is exactly the thing a + * status command must not report as current. When the read fails the report + * says so — offline, unauthorized or disabled — rather than showing a stale + * number as if it were fresh. + * + * `statusDeps` is a test seam; production passes nothing and gets the real + * Cloud client and the real local store. + */ +async function settingsStatusReport( + ctx: AppContext, + options: SettingsCommandOptions, +): Promise { + const deps = options.statusDeps; + let effective: EffectiveSettingsResponse | undefined; + let cloudError: SettingsStatusInputError; + try { + effective = deps?.readEffective + ? await deps.readEffective() + : await new CloudSettingsClient({ api: ctx.api }).effective(); + } catch (error) { + cloudError = + error instanceof AetherSettingsError ? error.code : "AETHER_SETTINGS_BACKEND_ERROR"; + } + + let device: { status: string; digest?: string | undefined } | undefined; + try { + const store = + deps?.store ?? new VersionedSettingsStore(settingsStorePaths(ctx, options)); + const inspection = store.inspect("global"); + device = { status: inspection.status, digest: inspection.digest }; + } catch { + // A store that cannot even be inspected is reported, not thrown: the whole + // point of `status` is to still answer when something is broken. + device = { status: "unreadable" }; + } + + return buildSettingsStatus({ + ...(effective ? { effective } : {}), + ...(cloudError ? { cloudError } : {}), + ...(device ? { device } : {}), + }); +} + +type SettingsStatusInputError = Parameters[0]["cloudError"]; + interface PresentedSetting { readonly id: string; readonly section: string; @@ -1077,6 +1138,14 @@ export async function cmdSettings( return SETTINGS_EXIT.ok; } + if (subcommand === "status") { + if (argv.length > 1) return usageProblem(ctx, out, err); + const report = await settingsStatusReport(ctx, options); + if (ctx.flags.json) writeJson(out, "status", true, { status: report }); + else out.write(renderSettingsStatus(report)); + return SETTINGS_EXIT.ok; + } + const scope = parseScope(options.scope); if (!scope) return usageProblem(ctx, out, err, "--scope must be global or project"); diff --git a/src/core/settings_status.ts b/src/core/settings_status.ts new file mode 100644 index 00000000..93be6c29 --- /dev/null +++ b/src/core/settings_status.ts @@ -0,0 +1,172 @@ +// `aether settings status` — one diagnostic view of the settings system. +// +// It answers the operator's question ("is this machine agreeing with Cloud, and +// about what revision?") without dumping the user's settings. Values are never +// included: only schema, reachability, revisions, and counts. +// +// Pure: the caller performs the reads and passes the results in, so the report +// is testable without a network or a home directory. + +import { + CANONICAL_SETTINGS_SCHEMA, + canonicalSettings, + type AetherSettingsErrorCode, +} from "./settings_canonical.js"; +import type { EffectiveSettingsResponse } from "./settings_cloud.js"; + +/** How the Cloud half of the system is doing right now. */ +export type CloudReachability = + | "connected" + | "offline" + | "unauthorized" + | "disabled" + | "error"; + +export interface DeviceStoreStatus { + /** The local store's own status word, e.g. "ok" | "missing" | "corrupt". */ + readonly status: string; + /** Opaque CAS token for the local store; never an account revision. */ + readonly digest?: string | undefined; +} + +export interface SettingsStatusInput { + /** Absent when the effective read failed; pair it with `cloudError`. */ + readonly effective?: EffectiveSettingsResponse | undefined; + readonly cloudError?: AetherSettingsErrorCode | undefined; + /** Human-facing project label, e.g. "AetherAI3/AETHER-CLOUD". */ + readonly projectLabel?: string | undefined; + readonly device?: DeviceStoreStatus | undefined; + /** Edits the user has made that Cloud has not acknowledged. */ + readonly pending?: number | undefined; + /** Keys currently sitting in a revision conflict. */ + readonly conflicts?: number | undefined; +} + +export interface SettingsStatusReport { + readonly schema: string; + readonly cloud: CloudReachability; + readonly accountRevision: number | null; + readonly project: string | null; + readonly projectRevision: number | null; + readonly deviceStatus: string; + readonly deviceRevision: string | null; + readonly pending: number; + readonly conflicts: number; + /** Canonical keys whose effective value comes from a managed policy. */ + readonly policyLockedKeys: readonly string[]; + /** Canonical keys the contract knows that the server did not return. */ + readonly missingKeys: readonly string[]; +} + +function reachabilityFor(input: SettingsStatusInput): CloudReachability { + if (input.effective) return "connected"; + switch (input.cloudError) { + case "AETHER_SETTINGS_OFFLINE": + return "offline"; + case "AETHER_SETTINGS_UNAUTHORIZED": + return "unauthorized"; + case "AETHER_SETTINGS_DISABLED": + return "disabled"; + case undefined: + return "offline"; + default: + return "error"; + } +} + +/** + * The highest configured revision at one scope. + * + * There is no single "account revision" in the contract — revisions are per + * key — so the report shows the newest one, which is what an operator + * comparing two machines actually needs. Unconfigured keys contribute nothing, + * so a fresh account reads 0 rather than a misleading number. + */ +function highestRevision( + effective: EffectiveSettingsResponse | undefined, + scope: "account" | "project", +): number | null { + if (!effective) return null; + let highest = 0; + for (const setting of Object.values(effective.settings)) { + const override = setting.overrides[scope]; + if (override?.configured && override.revision > highest) highest = override.revision; + } + return highest; +} + +export function buildSettingsStatus(input: SettingsStatusInput): SettingsStatusReport { + const effective = input.effective; + const returned = new Set(Object.keys(effective?.settings ?? {})); + const policyLocked = effective + ? Object.values(effective.settings) + .filter((item) => item.locked || item.sourceScope === "policy") + .map((item) => item.key) + .sort() + : []; + // Only meaningful once the server answered; an offline read is missing + // everything, and reporting that as contract drift would be a false alarm. + const missing = effective + ? canonicalSettings() + .filter((item) => item.persistence === "server" && !returned.has(item.key)) + .map((item) => item.key) + .sort() + : []; + + return { + schema: CANONICAL_SETTINGS_SCHEMA, + cloud: reachabilityFor(input), + accountRevision: highestRevision(effective, "account"), + project: input.projectLabel ?? effective?.projectId ?? null, + projectRevision: effective?.projectId ? highestRevision(effective, "project") : null, + deviceStatus: input.device?.status ?? "unknown", + deviceRevision: input.device?.digest ?? null, + pending: input.pending ?? 0, + conflicts: input.conflicts ?? 0, + policyLockedKeys: policyLocked, + missingKeys: missing, + }; +} + +function row(label: string, value: string): string { + return `${label.padEnd(14)}${value}`; +} + +/** + * Render the report for a terminal. + * + * Deliberately short: no values, and only the lines that carry information. A + * digest is truncated because its purpose is comparison between two machines, + * not reconstruction. + */ +export function renderSettingsStatus(report: SettingsStatusReport): string { + const lines = [row("Schema", report.schema), row("Cloud", report.cloud)]; + lines.push( + row( + "Account rev", + report.accountRevision === null ? "unknown" : String(report.accountRevision), + ), + ); + if (report.project) { + lines.push(row("Project", report.project)); + lines.push( + row( + "Project rev", + report.projectRevision === null ? "unknown" : String(report.projectRevision), + ), + ); + } + lines.push(row("Device", report.deviceStatus)); + if (report.deviceRevision) { + lines.push(row("Device rev", report.deviceRevision.slice(0, 12))); + } + lines.push(row("Pending", String(report.pending))); + lines.push(row("Conflicts", String(report.conflicts))); + if (report.policyLockedKeys.length) { + lines.push(row("Policy locked", report.policyLockedKeys.join(", "))); + } + if (report.missingKeys.length) { + lines.push(row("Not returned", report.missingKeys.join(", "))); + } + return lines.join("\n") + "\n"; +} diff --git a/test/settings_status.test.ts b/test/settings_status.test.ts new file mode 100644 index 00000000..39658b61 --- /dev/null +++ b/test/settings_status.test.ts @@ -0,0 +1,266 @@ +// `aether settings status` — the F2 Section L diagnostic. +// +// The reason this has tests of its own is the failure mode it is designed +// against: a status command that prints a cached revision as though it were +// current, or that leaks the user's settings while trying to be helpful. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { Writable } from "node:stream"; + +import { DEFAULT_CONFIG } from "../src/core/config.js"; +import type { AppContext } from "../src/core/context.js"; +import { runSettingsCommand } from "../src/commands/settings.js"; +import { AetherSettingsError } from "../src/core/settings_canonical.js"; +import type { EffectiveSettingsResponse } from "../src/core/settings_cloud.js"; +import { buildSettingsStatus, renderSettingsStatus } from "../src/core/settings_status.js"; + +function effective( + overrides: Partial = {}, +): EffectiveSettingsResponse { + return { + schema: "aether.settings.effective/1", + revision: "sha256:abc", + projectId: null, + settings: { + "agent.defaultModel": { + key: "agent.defaultModel", + effectiveValue: "opus5", + sourceScope: "account", + sourceId: null, + revision: 84, + overrides: { account: { configured: true, value: "opus5", revision: 84 } }, + managed: false, + locked: false, + reason: null, + policyRef: null, + capability: { available: true }, + apply: "next_session", + }, + "agent.defaultEffort": { + key: "agent.defaultEffort", + effectiveValue: "medium", + sourceScope: "default", + sourceId: null, + revision: 0, + overrides: {}, + managed: false, + locked: false, + reason: null, + policyRef: null, + capability: { available: true }, + apply: "next_session", + }, + "actions.liveCanvas.autoApply": { + key: "actions.liveCanvas.autoApply", + effectiveValue: false, + sourceScope: "policy", + sourceId: null, + revision: 0, + overrides: {}, + managed: true, + locked: true, + reason: "capability unavailable", + policyRef: "capability:live-canvas", + capability: { available: false, reason: "capability unavailable" }, + apply: "approval_step_up", + }, + }, + ...overrides, + } as EffectiveSettingsResponse; +} + +function context(json = false): AppContext { + return { + cfg: { ...DEFAULT_CONFIG }, + api: {} as AppContext["api"], + tokens: {} as AppContext["tokens"], + flags: { cwd: process.cwd(), json, yes: false, audit: false }, + confirm: async () => false, + }; +} + +function captureIo() { + let stdout = ""; + const writer: Pick = { + write: ((chunk: string | Uint8Array) => { + stdout += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + return true; + }) as Writable["write"], + }; + return { io: { out: writer, err: writer }, out: () => stdout }; +} + +const OK_STORE = { inspect: () => ({ status: "ok", digest: "a1b2c3d4" }) } as never; + +test("the account revision is the newest configured one, not a guess", () => { + const report = buildSettingsStatus({ + effective: effective(), + device: { status: "ok", digest: "a1b2c3d4e5f6a7b8" }, + }); + assert.equal(report.schema, "aether.settings/1"); + assert.equal(report.cloud, "connected"); + assert.equal(report.accountRevision, 84); + // No project was requested, so there is no project revision to report. + assert.equal(report.project, null); + assert.equal(report.projectRevision, null); + assert.equal(report.deviceStatus, "ok"); + assert.equal(report.deviceRevision, "a1b2c3d4e5f6a7b8"); +}); + +test("a fresh account reads 0, never a misleading number", () => { + const bare = effective(); + const stripped = { + ...bare, + settings: { "agent.defaultEffort": bare.settings["agent.defaultEffort"]! }, + } as EffectiveSettingsResponse; + const report = buildSettingsStatus({ effective: stripped }); + assert.equal(report.accountRevision, 0); +}); + +test("a project read reports the project and its own revision", () => { + const base = effective(); + const withProject = { + ...base, + projectId: "prj_00112233445566aa", + settings: { + ...base.settings, + "agent.defaultEffort": { + ...base.settings["agent.defaultEffort"]!, + overrides: { project: { configured: true, value: "high", revision: 21 } }, + }, + }, + } as EffectiveSettingsResponse; + const report = buildSettingsStatus({ effective: withProject }); + assert.equal(report.project, "prj_00112233445566aa"); + assert.equal(report.projectRevision, 21); +}); + +test("a policy-locked key is named so an operator stops hunting for it", () => { + const report = buildSettingsStatus({ effective: effective() }); + assert.deepEqual([...report.policyLockedKeys], ["actions.liveCanvas.autoApply"]); +}); + +test("an unreachable Cloud is reported, never shown as a current revision", () => { + const report = buildSettingsStatus({ + cloudError: "AETHER_SETTINGS_OFFLINE", + device: { status: "ok", digest: "a1b2c3d4e5f6a7b8" }, + }); + assert.equal(report.cloud, "offline"); + assert.equal(report.accountRevision, null); + assert.equal(report.projectRevision, null); + // Device settings still work offline, so the device half is still answered. + assert.equal(report.deviceStatus, "ok"); + // Nothing came back, so "not returned" would be noise rather than drift. + assert.equal(report.missingKeys.length, 0); +}); + +test("each failure code becomes its own reachability word", () => { + const cases: Array<[Parameters[0]["cloudError"], string]> = [ + ["AETHER_SETTINGS_OFFLINE", "offline"], + ["AETHER_SETTINGS_UNAUTHORIZED", "unauthorized"], + ["AETHER_SETTINGS_DISABLED", "disabled"], + ["AETHER_SETTINGS_BACKEND_ERROR", "error"], + ]; + for (const [code, expected] of cases) { + assert.equal(buildSettingsStatus({ cloudError: code }).cloud, expected, String(code)); + } +}); + +test("a server key the contract knows but the server omitted is flagged as drift", () => { + const base = effective(); + const partial = { + ...base, + settings: { + "agent.defaultModel": base.settings["agent.defaultModel"]!, + "agent.defaultEffort": base.settings["agent.defaultEffort"]!, + }, + } as EffectiveSettingsResponse; + const report = buildSettingsStatus({ effective: partial }); + assert.deepEqual([...report.missingKeys], ["actions.liveCanvas.autoApply"]); +}); + +test("the rendered report contains no setting value", () => { + const text = renderSettingsStatus( + buildSettingsStatus({ + effective: effective(), + device: { status: "ok", digest: "a1b2c3d4e5f6a7b8" }, + }), + ); + assert.ok(!text.includes("opus5"), "the configured model must not be printed"); + assert.ok(!text.includes("medium"), "the configured effort must not be printed"); + assert.match(text, /^Schema {8}aether\.settings\/1$/m); + assert.match(text, /^Cloud {9}connected$/m); + assert.match(text, /^Account rev {3}84$/m); + assert.match(text, /^Pending {7}0$/m); + assert.match(text, /^Conflicts {5}0$/m); +}); + +test("a long device digest is truncated; it is for comparison, not reconstruction", () => { + const text = renderSettingsStatus( + buildSettingsStatus({ + effective: effective(), + device: { status: "ok", digest: "0123456789abcdef0123456789abcdef" }, + }), + ); + assert.match(text, /^Device rev {4}0123456789ab$/m); + assert.ok(!text.includes("0123456789abcdef0123456789abcdef")); +}); + +test("`settings status` prints the report and exits ok", async () => { + const { io, out } = captureIo(); + const code = await runSettingsCommand( + context(), + ["status"], + { statusDeps: { readEffective: async () => effective(), store: OK_STORE } }, + io, + ); + assert.equal(code, 0); + assert.match(out(), /^Cloud {9}connected$/m); + assert.ok(!out().includes("opus5")); +}); + +test("`settings status --json` emits the same value-free report", async () => { + const { io, out } = captureIo(); + const code = await runSettingsCommand( + context(true), + ["status"], + { statusDeps: { readEffective: async () => effective(), store: OK_STORE } }, + io, + ); + assert.equal(code, 0); + const payload = JSON.parse(out()); + assert.equal(payload.ok, true); + assert.equal(payload.command, "status"); + assert.equal(payload.protocol, "aether.settings/1"); + assert.equal(payload.data.status.cloud, "connected"); + assert.equal(payload.data.status.accountRevision, 84); + assert.deepEqual(payload.data.status.policyLockedKeys, ["actions.liveCanvas.autoApply"]); + assert.ok(!out().includes("opus5")); +}); + +test("`settings status` still answers when Cloud is unreachable", async () => { + const { io, out } = captureIo(); + const code = await runSettingsCommand( + context(), + ["status"], + { + statusDeps: { + readEffective: async () => { + throw new AetherSettingsError("AETHER_SETTINGS_OFFLINE", "no answer"); + }, + store: OK_STORE, + }, + }, + io, + ); + assert.equal(code, 0); + assert.match(out(), /^Cloud {9}offline$/m); + assert.match(out(), /^Account rev {3}unknown$/m); +}); + +test("`settings status` takes no arguments", async () => { + const { io } = captureIo(); + const code = await runSettingsCommand(context(), ["status", "extra"], {}, io); + assert.equal(code, 2); +}); From 3c049b70d396cb1f2c5f23eeda8650f3947f13b3 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Mon, 7 Sep 2026 19:18:08 -0400 Subject: [PATCH 4/4] feat(settings): route account/project settings through a Cloud registry adapter F2 Section C. The Agent now has a registry adapter whose authority is the canonical Cloud settings service, so an account- or project-scoped canonical setting is read from Cloud and written to Cloud under the revision/CAS contract -- through the same registry seam every other Agent setting uses. The rule this adapter exists to keep is that a local cached account/project value is NEVER authoritative: - when Cloud answers, only Cloud's value becomes a layer - when Cloud does NOT answer, NO layer is offered at all. An account value this machine happens to remember is not authority, and offering it as one is exactly the drift this lane removes. The read reports `unavailable` instead. - a pre-existing local value is still surfaced, as `legacyLocalValue` + `legacyLocalIsAuthoritative: false`, so a user can see what would be migrated without the Agent quietly acting on it. Section D: it is reported, never uploaded. Other decisions worth naming: - Session scope is refused outright. A server-backed setting has no session representation, and silently downgrading `--scope session` to a durable account write is the accident this lane exists to prevent. - A managed policy is reported at `server_policy`, so the existing precedence rule -- a visible policy value is never silently shadowed -- keeps working. - `plan()` is read-only and captures the revision the change is based on; `apply()` sends exactly that revision. An unconfigured key plans revision 0, which is what the server expects for a first write. - `rollback()` expects the revision the write PRODUCED, so a concurrent change elsewhere refuses the rollback rather than reverting a value nobody chose. A first write rolls back with reset, not a patch to a guessed previous value. - A failed apply returns the stable public code as its error string, never the value that failed. - The declared scopes come from the contract, so a device key cannot be given a Cloud adapter at all. Not yet wired: settings_adapters.ts still registers `code.hosted_model` and `code.effort` against local config. This commit adds the mechanism and its tests; switching those two registrations over is the next step, and is where the Section D migration report attaches. Tests: test/settings_cloud_adapter.test.ts (18 cases). Full agent suite green. --- src/core/settings_cloud_adapter.ts | 398 +++++++++++++++++++++++++++ test/settings_cloud_adapter.test.ts | 399 ++++++++++++++++++++++++++++ 2 files changed, 797 insertions(+) create mode 100644 src/core/settings_cloud_adapter.ts create mode 100644 test/settings_cloud_adapter.test.ts diff --git a/src/core/settings_cloud_adapter.ts b/src/core/settings_cloud_adapter.ts new file mode 100644 index 00000000..ff8ea925 --- /dev/null +++ b/src/core/settings_cloud_adapter.ts @@ -0,0 +1,398 @@ +// A registry adapter whose authority is the canonical Cloud settings service. +// +// This is what makes the Agent a ROUTER over the canonical scopes rather than a +// second settings product: an account- or project-scoped canonical setting is +// read from Cloud and written to Cloud under the revision/CAS contract, through +// the same registry seam every other Agent setting uses. +// +// The rule this module exists to keep is Section C's: a local cached +// account/project value is NEVER authoritative. When Cloud answers, its value +// is the only layer offered. A pre-existing local value is reported alongside +// as an explicitly non-authoritative fact, so a user can see what would be +// migrated without the Agent quietly acting on it. +// +// Session scope is refused outright. A server-backed setting has no session +// representation, and silently downgrading a `--scope session` write to a +// durable account write is exactly the accident this lane exists to prevent. + +import { + AetherSettingsError, + requireCanonicalSetting, + validateCanonicalValue, +} from "./settings_canonical.js"; +import type { + CloudSettingsClient, + EffectiveSetting, + EffectiveSettingsResponse, +} from "./settings_cloud.js"; +import type { + AdapterApplyReceipt, + AdapterApplyResult, + ConfirmationResolver, + SettingChange, + SettingDefinition, + SettingHealth, + SettingLayer, + SettingPlanContext, + SettingReadResult, + SettingScope, + SettingValue, + SettingValueType, + SettingsOperationContext, + ValidationResult, + WritableSettingScope, +} from "./settings_registry.js"; + +/** The Cloud scope an Agent writable scope corresponds to. */ +export function cloudScopeFor(scope: WritableSettingScope): "account" | "project" { + if (scope === "global") return "account"; + if (scope === "project") return "project"; + throw new AetherSettingsError( + "AETHER_SETTINGS_SCOPE_INVALID", + "a Cloud-backed setting has no session scope", + ); +} + +/** What a pre-existing local value looks like to this adapter. */ +export interface LegacyLocalValue { + readonly value: SettingValue; + /** Where it came from, for the migration report. Never a credential. */ + readonly source: string; +} + +export interface CloudSettingAdapterDeps { + readonly client: Pick; + /** The project a project-scoped write targets; null means none is selected. */ + readonly projectId?: () => string | null; + /** Catalogue for `catalog_model_id` validation; omitted when unknown. */ + readonly availableModels?: () => readonly string[] | undefined; + /** + * The value this setting had before it became Cloud-backed. + * + * Reported, never uploaded. Section D: a server-backed legacy value is not + * pushed to Cloud until the user asks, so an offline machine can never + * resurrect an old choice over a newer one made elsewhere. + */ + readonly legacy?: () => Promise; +} + +export interface CloudSettingOptions { + /** The existing user-facing Agent id, kept for compatibility. */ + readonly id: string; + /** The canonical key it routes to. */ + readonly canonicalKey: string; + readonly section: string; + readonly label: string; + readonly description: string; + readonly valueType: SettingValueType; + readonly deps: CloudSettingAdapterDeps; + readonly confirmation?: ConfirmationResolver; +} + +interface CloudPlan { + readonly canonicalKey: string; + readonly cloudScope: "account" | "project"; + readonly projectId: string | null; + readonly operation: "set" | "unset"; + readonly value?: SettingValue; + readonly expectedRevision: number; +} + +interface CloudRollbackToken { + readonly canonicalKey: string; + readonly cloudScope: "account" | "project"; + readonly projectId: string | null; + /** The revision the write produced; the rollback expects it. */ + readonly revision: number; + /** What to restore. Absent means the key was unconfigured before. */ + readonly previousValue?: SettingValue; +} + +function healthFor(error: AetherSettingsError): SettingHealth { + switch (error.code) { + case "AETHER_SETTINGS_OFFLINE": + return { state: "unavailable", summary: "Aether Cloud is unreachable" }; + case "AETHER_SETTINGS_UNAUTHORIZED": + return { state: "unconfigured", summary: "sign in to read account settings" }; + case "AETHER_SETTINGS_DISABLED": + return { + state: "disabled_by_policy", + summary: "settings are not enabled for this account", + }; + default: + return { state: "degraded", summary: error.code }; + } +} + +/** + * Turn one canonical setting's effective state into registry layers. + * + * Only Cloud's answer becomes a layer. A managed policy is reported at + * `server_policy` so the existing precedence rule — a visible policy value is + * never silently shadowed — keeps working unchanged. + */ +function layersFor(setting: EffectiveSetting): SettingLayer[] { + const layers: SettingLayer[] = []; + const account = setting.overrides["account"]; + if (account?.configured) { + layers.push({ scope: "global", source: "aether cloud (account)", value: account.value }); + } + const project = setting.overrides["project"]; + if (project?.configured) { + layers.push({ scope: "project", source: "aether cloud (project)", value: project.value }); + } + if (setting.locked || setting.sourceScope === "policy") { + layers.push({ + scope: "server_policy", + source: setting.policyRef ?? "aether cloud policy", + value: setting.effectiveValue, + }); + } + return layers; +} + +export function cloudSettingDefinition( + options: CloudSettingOptions, +): SettingDefinition { + const { canonicalKey, deps } = options; + const definition = requireCanonicalSetting(canonicalKey); + if (definition.persistence !== "server") { + throw new AetherSettingsError( + "AETHER_SETTINGS_SCOPE_INVALID", + `${canonicalKey} is a device setting and has no Cloud adapter`, + { keys: [canonicalKey] }, + ); + } + + const scopes: SettingScope[] = ["default"]; + if (definition.allowedScopes.includes("account")) scopes.push("global"); + if (definition.allowedScopes.includes("project")) scopes.push("project"); + if (definition.managedPolicy) scopes.push("server_policy"); + + const projectId = (): string | null => deps.projectId?.() ?? null; + + async function readEffective(): Promise { + const id = projectId(); + return deps.client.effective(id ? { projectId: id } : {}); + } + + function expectedRevisionFor( + effective: EffectiveSettingsResponse, + cloudScope: "account" | "project", + ): number { + const setting = effective.settings[canonicalKey]; + // An unconfigured key is revision 0 by the contract, which is exactly what + // the server expects for a first write. + return setting?.overrides[cloudScope]?.revision ?? 0; + } + + return { + id: options.id, + section: options.section, + label: options.label, + description: options.description, + valueType: options.valueType, + scopes, + ...(options.confirmation ? { confirmation: options.confirmation } : {}), + + async read(): Promise { + let effective: EffectiveSettingsResponse; + try { + effective = await readEffective(); + } catch (error) { + const settingsError = + error instanceof AetherSettingsError + ? error + : new AetherSettingsError("AETHER_SETTINGS_BACKEND_ERROR", "settings read failed"); + // Cloud did not answer. Report NO layer: an account value this machine + // happens to remember is not authoritative, and offering it as one is + // precisely the drift this lane removes. The legacy value is still + // surfaced, clearly marked, so the user can see it. + const legacy = (await deps.legacy?.()) ?? null; + return { + layers: [], + health: healthFor(settingsError), + extensions: { + canonicalKey, + cloudAuthoritative: true, + ...(legacy + ? { + legacyLocalValue: legacy.value, + legacyLocalSource: legacy.source, + legacyLocalIsAuthoritative: false, + } + : {}), + }, + }; + } + + const setting = effective.settings[canonicalKey]; + const legacy = (await deps.legacy?.()) ?? null; + return { + layers: setting ? layersFor(setting) : [], + health: { state: "verified", summary: "aether cloud" }, + extensions: { + canonicalKey, + cloudAuthoritative: true, + ...(setting + ? { + cloudRevision: setting.revision, + cloudSourceScope: setting.sourceScope, + ...(setting.managed ? { managed: true } : {}), + ...(setting.locked ? { locked: true } : {}), + ...(setting.reason ? { policyReason: setting.reason } : {}), + } + : {}), + ...(legacy + ? { + legacyLocalValue: legacy.value, + legacyLocalSource: legacy.source, + legacyLocalIsAuthoritative: false, + } + : {}), + }, + }; + }, + + validate(value: unknown): ValidationResult { + try { + const models = deps.availableModels?.(); + return { + ok: true, + value: validateCanonicalValue( + canonicalKey, + value, + models ? { availableModels: models } : {}, + ), + }; + } catch (error) { + const message = error instanceof Error ? error.message : "invalid value"; + const code = + error instanceof AetherSettingsError ? error.code : "AETHER_SETTINGS_VALUE_INVALID"; + return { ok: false, issues: [{ code, message }] }; + } + }, + + /** Read-only: capture the revision this change is based on. */ + async plan( + change: SettingChange, + context: SettingPlanContext, + ): Promise { + const cloudScope = cloudScopeFor(change.scope); + const id = cloudScope === "project" ? projectId() : null; + if (cloudScope === "project" && !id) { + throw new AetherSettingsError( + "AETHER_SETTINGS_PROJECT_NOT_FOUND", + "a project-scoped setting needs a selected project", + { keys: [canonicalKey] }, + ); + } + context.signal.throwIfAborted(); + const effective = await readEffective(); + const expectedRevision = expectedRevisionFor(effective, cloudScope); + const after = change.afterAtScope; + return { + canonicalKey, + cloudScope, + projectId: id, + operation: change.operation, + ...(change.operation === "set" && after ? { value: after.value } : {}), + expectedRevision, + }; + }, + + async apply( + plan: unknown, + context?: SettingsOperationContext, + ): Promise { + const command = plan as CloudPlan; + try { + context?.signal.throwIfAborted(); + const models = deps.availableModels?.(); + const previous = await readEffective(); + const before = previous.settings[command.canonicalKey]?.overrides[command.cloudScope]; + const response = + command.operation === "set" + ? await deps.client.patch(command.cloudScope, { + values: { [command.canonicalKey]: command.value }, + expectedRevisions: { [command.canonicalKey]: command.expectedRevision }, + ...(command.projectId ? { projectId: command.projectId } : {}), + ...(models ? { availableModels: models } : {}), + }) + : await deps.client.reset(command.cloudScope, { + keys: [command.canonicalKey], + expectedRevisions: { [command.canonicalKey]: command.expectedRevision }, + ...(command.projectId ? { projectId: command.projectId } : {}), + }); + + const revision = response.revisions[command.canonicalKey] ?? command.expectedRevision; + const rollbackToken: CloudRollbackToken = { + canonicalKey: command.canonicalKey, + cloudScope: command.cloudScope, + projectId: command.projectId, + revision, + ...(before?.configured ? { previousValue: before.value as SettingValue } : {}), + }; + return { + ok: true, + receipt: { + rollbackToken, + summary: response.duplicate + ? `aether cloud replayed an identical ${command.cloudScope} write` + : `aether cloud ${command.cloudScope} revision ${revision}`, + extensions: { canonicalKey: command.canonicalKey, revision }, + }, + }; + } catch (error) { + // The message is the stable public code, never the value that failed. + const code = + error instanceof AetherSettingsError ? error.code : "AETHER_SETTINGS_BACKEND_ERROR"; + return { ok: false, error: code }; + } + }, + + /** + * Undo one applied write. + * + * The rollback carries the revision the write PRODUCED, so if anything else + * changed the key in between, the server refuses it rather than reverting a + * value the user never asked to lose. + */ + async rollback( + receipt: AdapterApplyReceipt, + context?: SettingsOperationContext, + ): Promise { + const token = receipt.rollbackToken as CloudRollbackToken | undefined; + if (!token) return; + context?.signal.throwIfAborted(); + const expectedRevisions = { [token.canonicalKey]: token.revision }; + const scoped = token.projectId ? { projectId: token.projectId } : {}; + if (token.previousValue === undefined) { + await deps.client.reset(token.cloudScope, { + keys: [token.canonicalKey], + expectedRevisions, + ...scoped, + }); + return; + } + await deps.client.patch(token.cloudScope, { + values: { [token.canonicalKey]: token.previousValue }, + expectedRevisions, + ...scoped, + }); + }, + + async doctor(): Promise { + try { + await readEffective(); + return { state: "verified", summary: "aether cloud reachable" }; + } catch (error) { + return healthFor( + error instanceof AetherSettingsError + ? error + : new AetherSettingsError("AETHER_SETTINGS_BACKEND_ERROR", "unreachable"), + ); + } + }, + }; +} diff --git a/test/settings_cloud_adapter.test.ts b/test/settings_cloud_adapter.test.ts new file mode 100644 index 00000000..c1ffdfc4 --- /dev/null +++ b/test/settings_cloud_adapter.test.ts @@ -0,0 +1,399 @@ +// The registry adapter that routes a canonical account/project setting to Cloud. +// +// The behaviour under test is the Section C rule: a local cached account value +// is never authoritative. When Cloud answers, only Cloud's value is offered as +// a layer; when Cloud does not answer, NO layer is offered at all, and the +// pre-existing local value is reported as an explicitly non-authoritative fact. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { AetherSettingsError } from "../src/core/settings_canonical.js"; +import type { EffectiveSettingsResponse } from "../src/core/settings_cloud.js"; +import { + cloudScopeFor, + cloudSettingDefinition, +} from "../src/core/settings_cloud_adapter.js"; +import type { SettingChange, SettingValue } from "../src/core/settings_registry.js"; + +function effective( + overrides: Record = {}, + extra: Partial = {}, +): EffectiveSettingsResponse { + return { + schema: "aether.settings.effective/1", + revision: "sha256:abc", + projectId: null, + settings: { + "agent.defaultModel": { + key: "agent.defaultModel", + effectiveValue: overrides["account"]?.value ?? "sonnet", + sourceScope: overrides["account"]?.configured ? "account" : "default", + sourceId: null, + revision: overrides["account"]?.revision ?? 0, + overrides, + managed: false, + locked: false, + reason: null, + policyRef: null, + capability: { available: true }, + apply: "next_session", + }, + }, + ...extra, + } as EffectiveSettingsResponse; +} + +const MUTATION = { + schema: "aether.settings.mutation/1", + operation: "patch", + scope: "account", + scopeId: null, + duplicate: false, + changedKeys: ["agent.defaultModel"], + revisions: { "agent.defaultModel": 11 }, + revision: "sha256:next", +}; + +function makeClient(overrides: Partial> = {}) { + const calls: Array<{ op: string; args: unknown[] }> = []; + const client = { + effective: async (...args: unknown[]) => { + calls.push({ op: "effective", args }); + const impl = overrides.effective as ((...a: unknown[]) => unknown) | undefined; + return (impl ? impl(...args) : effective()) as EffectiveSettingsResponse; + }, + patch: async (...args: unknown[]) => { + calls.push({ op: "patch", args }); + const impl = overrides.patch as ((...a: unknown[]) => unknown) | undefined; + return (impl ? impl(...args) : MUTATION) as never; + }, + reset: async (...args: unknown[]) => { + calls.push({ op: "reset", args }); + const impl = overrides.reset as ((...a: unknown[]) => unknown) | undefined; + return (impl ? impl(...args) : { ...MUTATION, operation: "reset" }) as never; + }, + }; + return { client: client as never, calls }; +} + +function defineModelSetting(deps: Parameters[0]["deps"]) { + return cloudSettingDefinition({ + id: "code.hosted_model", + canonicalKey: "agent.defaultModel", + section: "Aether Code", + label: "Hosted model", + description: "Hosted model id.", + valueType: "string", + deps, + }); +} + +function change( + operation: "set" | "unset", + scope: "global" | "project" | "session", + value?: SettingValue, +): SettingChange { + return { + settingId: "code.hosted_model", + scope, + operation, + before: {} as never, + after: {} as never, + ...(value === undefined + ? {} + : { afterAtScope: { state: "known", scope, source: "cli", rank: 1, value } }), + } as SettingChange; +} + +const PLAN_CONTEXT = { batchKey: {}, signal: new AbortController().signal }; + +test("agent writable scopes map onto the canonical Cloud scopes", () => { + assert.equal(cloudScopeFor("global"), "account"); + assert.equal(cloudScopeFor("project"), "project"); + assert.throws( + () => cloudScopeFor("session"), + (err: unknown) => + err instanceof AetherSettingsError && err.code === "AETHER_SETTINGS_SCOPE_INVALID", + ); +}); + +test("a device key has no Cloud adapter at all", () => { + assert.throws( + () => + cloudSettingDefinition({ + id: "appearance.theme", + canonicalKey: "editor.fontSize", + section: "Appearance", + label: "Font size", + description: "x", + valueType: "number", + deps: { client: makeClient().client }, + }), + (err: unknown) => + err instanceof AetherSettingsError && err.code === "AETHER_SETTINGS_SCOPE_INVALID", + ); +}); + +test("the declared scopes come from the contract, not from a local guess", () => { + const definition = defineModelSetting({ client: makeClient().client }); + assert.deepEqual([...definition.scopes], ["default", "global", "project"]); +}); + +test("when Cloud answers, only Cloud's value is a layer", async () => { + const { client } = makeClient({ + effective: () => effective({ account: { configured: true, value: "opus5", revision: 10 } }), + }); + const definition = defineModelSetting({ + client, + legacy: async () => ({ value: "sonnet", source: "local config" }), + }); + + const read = await definition.read(); + assert.deepEqual(read.layers, [ + { scope: "global", source: "aether cloud (account)", value: "opus5" }, + ]); + assert.equal(read.health?.state, "verified"); + // The legacy value is visible but explicitly not authoritative. + assert.equal(read.extensions?.["legacyLocalValue"], "sonnet"); + assert.equal(read.extensions?.["legacyLocalIsAuthoritative"], false); + assert.equal(read.extensions?.["cloudAuthoritative"], true); + assert.equal(read.extensions?.["cloudRevision"], 10); +}); + +test("when Cloud does not answer, there is NO layer to mistake for authority", async () => { + const { client } = makeClient({ + effective: () => { + throw new AetherSettingsError("AETHER_SETTINGS_OFFLINE", "no answer"); + }, + }); + const definition = defineModelSetting({ + client, + legacy: async () => ({ value: "sonnet", source: "local config" }), + }); + + const read = await definition.read(); + assert.deepEqual(read.layers, [], "an offline read must not offer a cached account value"); + assert.equal(read.health?.state, "unavailable"); + assert.equal(read.extensions?.["legacyLocalValue"], "sonnet"); + assert.equal(read.extensions?.["legacyLocalIsAuthoritative"], false); +}); + +test("each failure code becomes its own health state", async () => { + const cases: Array<[string, string]> = [ + ["AETHER_SETTINGS_OFFLINE", "unavailable"], + ["AETHER_SETTINGS_UNAUTHORIZED", "unconfigured"], + ["AETHER_SETTINGS_DISABLED", "disabled_by_policy"], + ["AETHER_SETTINGS_BACKEND_ERROR", "degraded"], + ]; + for (const [code, state] of cases) { + const { client } = makeClient({ + effective: () => { + throw new AetherSettingsError(code as never, "x"); + }, + }); + const read = await defineModelSetting({ client }).read(); + assert.equal(read.health?.state, state, code); + } +}); + +test("a policy-locked value is reported at server_policy so it is never shadowed", async () => { + const base = effective(); + const setting = base.settings["agent.defaultModel"]!; + const withPolicy = { + ...base, + settings: { + "agent.defaultModel": { + ...setting, + locked: true, + managed: true, + sourceScope: "policy", + effectiveValue: "sonnet", + policyRef: "policy:managed-model", + reason: "managed by your team", + }, + }, + } as EffectiveSettingsResponse; + const { client } = makeClient({ effective: () => withPolicy }); + + const read = await defineModelSetting({ client }).read(); + assert.deepEqual(read.layers, [ + { scope: "server_policy", source: "policy:managed-model", value: "sonnet" }, + ]); + assert.equal(read.extensions?.["locked"], true); + assert.equal(read.extensions?.["policyReason"], "managed by your team"); +}); + +test("planning is read-only and captures the revision the change is based on", async () => { + const { client, calls } = makeClient({ + effective: () => effective({ account: { configured: true, value: "sonnet", revision: 10 } }), + }); + const definition = defineModelSetting({ client }); + + const plan = (await definition.plan(change("set", "global", "opus5"), PLAN_CONTEXT)) as { + expectedRevision: number; + cloudScope: string; + operation: string; + }; + assert.equal(plan.expectedRevision, 10); + assert.equal(plan.cloudScope, "account"); + assert.equal(plan.operation, "set"); + assert.ok( + calls.every((c) => c.op === "effective"), + "plan must not mutate", + ); +}); + +test("an unconfigured key plans revision 0, which is a first write", async () => { + const { client } = makeClient({ effective: () => effective() }); + const plan = (await defineModelSetting({ client }).plan( + change("set", "global", "opus5"), + PLAN_CONTEXT, + )) as { expectedRevision: number }; + assert.equal(plan.expectedRevision, 0); +}); + +test("a project write without a selected project is refused", async () => { + const { client } = makeClient(); + const definition = defineModelSetting({ client, projectId: () => null }); + await assert.rejects( + definition.plan(change("set", "project", "opus5"), PLAN_CONTEXT), + (err: unknown) => + err instanceof AetherSettingsError && err.code === "AETHER_SETTINGS_PROJECT_NOT_FOUND", + ); +}); + +test("apply sends the captured revision and returns a rollback token", async () => { + const { client, calls } = makeClient({ + effective: () => effective({ account: { configured: true, value: "sonnet", revision: 10 } }), + }); + const definition = defineModelSetting({ client }); + const plan = await definition.plan(change("set", "global", "opus5"), PLAN_CONTEXT); + + const result = await definition.apply(plan); + assert.equal(result.ok, true); + const patch = calls.find((c) => c.op === "patch"); + assert.ok(patch); + assert.deepEqual(patch.args[0], "account"); + assert.deepEqual((patch.args[1] as Record)["values"], { + "agent.defaultModel": "opus5", + }); + assert.deepEqual((patch.args[1] as Record)["expectedRevisions"], { + "agent.defaultModel": 10, + }); + if (result.ok) { + const token = result.receipt.rollbackToken as { revision: number; previousValue?: unknown }; + assert.equal(token.revision, 11); + assert.equal(token.previousValue, "sonnet"); + } +}); + +test("a conflicting apply fails with the stable public code and no value", async () => { + const { client } = makeClient({ + effective: () => effective({ account: { configured: true, value: "sonnet", revision: 10 } }), + patch: () => { + throw new AetherSettingsError("AETHER_SETTINGS_REVISION_CONFLICT", "changed", { + keys: ["agent.defaultModel"], + conflicts: [{ key: "agent.defaultModel", expectedRevision: 10, actualRevision: 11 }], + }); + }, + }); + const definition = defineModelSetting({ client }); + const plan = await definition.plan(change("set", "global", "opus5"), PLAN_CONTEXT); + + const result = await definition.apply(plan); + assert.equal(result.ok, false); + if (!result.ok) { + assert.equal(result.error, "AETHER_SETTINGS_REVISION_CONFLICT"); + assert.ok(!result.error.includes("opus5")); + } +}); + +test("rollback restores the previous value under the revision the write produced", async () => { + const { client, calls } = makeClient({ + effective: () => effective({ account: { configured: true, value: "sonnet", revision: 10 } }), + }); + const definition = defineModelSetting({ client }); + const plan = await definition.plan(change("set", "global", "opus5"), PLAN_CONTEXT); + const result = await definition.apply(plan); + assert.equal(result.ok, true); + if (!result.ok) return; + + await definition.rollback?.(result.receipt); + const rollbackPatch = calls.filter((c) => c.op === "patch").at(-1); + assert.ok(rollbackPatch); + assert.deepEqual((rollbackPatch.args[1] as Record)["values"], { + "agent.defaultModel": "sonnet", + }); + // The rollback expects the revision the write produced, so a concurrent + // change elsewhere refuses it rather than reverting a value nobody chose. + assert.deepEqual((rollbackPatch.args[1] as Record)["expectedRevisions"], { + "agent.defaultModel": 11, + }); +}); + +test("rolling back a first write clears the key instead of inventing a value", async () => { + const { client, calls } = makeClient({ effective: () => effective() }); + const definition = defineModelSetting({ client }); + const plan = await definition.plan(change("set", "global", "opus5"), PLAN_CONTEXT); + const result = await definition.apply(plan); + assert.equal(result.ok, true); + if (!result.ok) return; + + await definition.rollback?.(result.receipt); + const reset = calls.find((c) => c.op === "reset"); + assert.ok(reset, "an unconfigured key must be reset, not patched to a guess"); +}); + +test("unset routes to the canonical reset with the same CAS contract", async () => { + const { client, calls } = makeClient({ + effective: () => effective({ account: { configured: true, value: "opus5", revision: 10 } }), + }); + const definition = defineModelSetting({ client }); + const plan = await definition.plan(change("unset", "global"), PLAN_CONTEXT); + + const result = await definition.apply(plan); + assert.equal(result.ok, true); + const reset = calls.find((c) => c.op === "reset"); + assert.ok(reset); + assert.deepEqual((reset.args[1] as Record)["keys"], ["agent.defaultModel"]); + assert.deepEqual((reset.args[1] as Record)["expectedRevisions"], { + "agent.defaultModel": 10, + }); +}); + +test("validation uses the canonical constraint and the supplied catalogue", () => { + const { client } = makeClient(); + const definition = defineModelSetting({ client, availableModels: () => ["opus5"] }); + assert.deepEqual(definition.validate("opus5"), { ok: true, value: "opus5" }); + const rejected = definition.validate("not-in-catalog"); + assert.equal(rejected.ok, false); + if (!rejected.ok) { + assert.equal(rejected.issues[0]?.code, "AETHER_SETTINGS_VALUE_INVALID"); + } +}); + +test("a duplicate write is reported as a replay, not as a fresh change", async () => { + const { client } = makeClient({ + effective: () => effective({ account: { configured: true, value: "sonnet", revision: 10 } }), + patch: () => ({ ...MUTATION, duplicate: true }), + }); + const definition = defineModelSetting({ client }); + const plan = await definition.plan(change("set", "global", "opus5"), PLAN_CONTEXT); + const result = await definition.apply(plan); + assert.equal(result.ok, true); + if (result.ok) assert.match(result.receipt.summary ?? "", /replayed/); +}); + +test("doctor reports reachability without changing anything", async () => { + const { client, calls } = makeClient(); + assert.equal((await defineModelSetting({ client }).doctor?.())?.state, "verified"); + assert.ok(calls.every((c) => c.op === "effective")); + + const { client: broken } = makeClient({ + effective: () => { + throw new AetherSettingsError("AETHER_SETTINGS_OFFLINE", "x"); + }, + }); + assert.equal((await defineModelSetting({ client: broken }).doctor?.())?.state, "unavailable"); +});