dsh-coding-subscription-oauth 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +258 -250
  2. package/CONTRIBUTING.md +129 -129
  3. package/INSTALL.md +256 -255
  4. package/LICENSE +19 -19
  5. package/NOTICE +11 -11
  6. package/README.de.md +303 -303
  7. package/README.es.md +304 -304
  8. package/README.fr.md +304 -304
  9. package/README.ja.md +304 -304
  10. package/README.ko.md +304 -304
  11. package/README.md +320 -320
  12. package/README.pt-BR.md +304 -304
  13. package/README.ru.md +304 -304
  14. package/README.zh-CN.md +318 -318
  15. package/compatibility/dsh-bom.json +30 -30
  16. package/cordis.patch.yml +13 -13
  17. package/docs/00-project-rules.md +212 -212
  18. package/docs/02-architecture.md +138 -138
  19. package/docs/02-architecture.zh-CN.md +138 -138
  20. package/lib/bin.js +1333 -569
  21. package/lib/bin.js.map +4 -4
  22. package/lib/client.js +1 -1
  23. package/lib/client.js.map +2 -2
  24. package/lib/index.js +689 -25492
  25. package/lib/index.js.map +4 -4
  26. package/lib/invariant.js.map +1 -1
  27. package/media/en/settings_accounts.png +0 -0
  28. package/media/en/settings_capabilities.png +0 -0
  29. package/media/en/settings_gateway.png +0 -0
  30. package/media/settings_accounts.png +0 -0
  31. package/media/settings_capabilities.png +0 -0
  32. package/media/settings_gateway.png +0 -0
  33. package/media/settings_overview.png +0 -0
  34. package/media/zh-CN/settings_accounts.png +0 -0
  35. package/media/zh-CN/settings_capabilities.png +0 -0
  36. package/media/zh-CN/settings_gateway.png +0 -0
  37. package/package.json +223 -223
  38. package/patches/dsh-agy@0.1.2.patch +25 -25
  39. package/scripts/release.mjs +186 -186
  40. package/scripts/smoke-deployed-routes.mjs +146 -146
  41. package/scripts/verify-deployed-catalog.mjs +87 -87
  42. package/src/adapter.ts +348 -348
  43. package/src/alias-adapter.ts +147 -147
  44. package/src/auth-routes.ts +921 -921
  45. package/src/auth.ts +67 -67
  46. package/src/bin.ts +350 -350
  47. package/src/capability-routes.ts +279 -279
  48. package/src/capability-runtime.ts +314 -314
  49. package/src/capability-settings.ts +671 -671
  50. package/src/capability-tools.ts +685 -685
  51. package/src/catalog.ts +271 -271
  52. package/src/client/GrokBuildSettings.tsx +771 -771
  53. package/src/client/api.ts +88 -88
  54. package/src/client/components/AboutTab.tsx +30 -30
  55. package/src/client/components/AccountsTab.tsx +241 -241
  56. package/src/client/components/Badge.tsx +33 -33
  57. package/src/client/components/CapabilitiesTab.tsx +265 -265
  58. package/src/client/components/CliPullPreview.tsx +116 -116
  59. package/src/client/components/CopyButton.tsx +57 -57
  60. package/src/client/components/GatewayTab.tsx +469 -469
  61. package/src/client/components/NoticeBanner.tsx +46 -46
  62. package/src/client/components/ProgressBar.tsx +53 -53
  63. package/src/client/components/ProviderCard.tsx +606 -606
  64. package/src/client/components/SettingsTabs.tsx +75 -75
  65. package/src/client/components/ToggleSwitch.tsx +71 -71
  66. package/src/client/constants.ts +230 -230
  67. package/src/client/display.ts +61 -61
  68. package/src/client/dshClientAdapter.ts +127 -127
  69. package/src/client/gatewaySnippets.ts +37 -37
  70. package/src/client/index.tsx +156 -156
  71. package/src/client/locales.ts +540 -540
  72. package/src/client/microStyles.ts +52 -52
  73. package/src/client/parsers.ts +398 -398
  74. package/src/client/styles.ts +325 -325
  75. package/src/client/types.ts +199 -199
  76. package/src/codex-http.ts +447 -447
  77. package/src/codex-images.ts +503 -503
  78. package/src/codex-model-capabilities.ts +320 -320
  79. package/src/codex-search.ts +245 -245
  80. package/src/codex-usage.ts +263 -263
  81. package/src/compatibility.ts +55 -55
  82. package/src/dsh-host-adapter.ts +173 -173
  83. package/src/gateway-anthropic-messages.ts +84 -84
  84. package/src/gateway-auth.ts +102 -102
  85. package/src/gateway-backend.ts +274 -274
  86. package/src/gateway-body.ts +49 -49
  87. package/src/gateway-config.ts +76 -76
  88. package/src/gateway-http.ts +104 -104
  89. package/src/gateway-openai-chat.ts +124 -124
  90. package/src/gateway-openai-responses.ts +53 -53
  91. package/src/gateway-parse.ts +224 -224
  92. package/src/gateway-protocol.ts +52 -52
  93. package/src/gateway-routes.ts +158 -158
  94. package/src/gateway.ts +258 -258
  95. package/src/grok-errors.ts +24 -24
  96. package/src/grok-imagine.ts +1627 -1627
  97. package/src/grok-import.ts +151 -151
  98. package/src/http-json.ts +82 -82
  99. package/src/ids.ts +59 -59
  100. package/src/imagine-routes.ts +463 -463
  101. package/src/index.ts +735 -735
  102. package/src/invariant.ts +17 -17
  103. package/src/kimi-errors.ts +26 -26
  104. package/src/media-store.ts +927 -927
  105. package/src/oauth-import-routes.ts +324 -324
  106. package/src/oauth-providers.ts +152 -152
  107. package/src/oauth-session.ts +183 -183
  108. package/src/oauth-sources.ts +1104 -1104
  109. package/src/oauth.ts +620 -620
  110. package/src/provider.ts +128 -128
  111. package/src/proxy.ts +11 -11
  112. package/src/redact.ts +72 -72
  113. package/src/session.ts +218 -218
  114. package/src/store.ts +217 -217
  115. package/src/web-origin.ts +296 -296
  116. package/src/web-routes.ts +38 -38
@@ -1,671 +1,671 @@
1
- import Schema from "@deepseek-ai/schemastery";
2
- import { CAPABILITY_SETTINGS_NAMESPACE } from "./ids.ts";
3
-
4
- /**
5
- * Integration-ready capability settings controller for the
6
- * `coding-subscription-oauth` namespace. Schema defaults sit under the
7
- * composition/YAML `base`, and the user section layers on top. The controller
8
- * talks to an injected structural settings service while registering a real
9
- * Schemastery section that the Host settings service can render and validate.
10
- * @module dsh-coding-subscription-oauth/capability-settings
11
- */
12
-
13
- /** Settings namespace owned by this plugin. */
14
- export { CAPABILITY_SETTINGS_NAMESPACE } from "./ids.ts";
15
-
16
- /** Default-off capability flags. Presence in the user section marks an override. */
17
- export const CAPABILITY_FLAG_KEYS = [
18
- "codexSearch",
19
- "codexImages",
20
- "codexImageEdits",
21
- "codexImagesAnyModel",
22
- "codexUsage",
23
- "codexFast",
24
- "grokImagineImage",
25
- "grokImagineVideo",
26
- ] as const;
27
-
28
- /** Conservative numeric limits persisted beside the flags. */
29
- export const CAPABILITY_LIMIT_KEYS = ["searchResults", "imageCount", "videoArtifactTtlMs"] as const;
30
-
31
- /** Every key the controller admits into secret-free state. */
32
- export const CAPABILITY_SETTINGS_KEYS = [...CAPABILITY_FLAG_KEYS, ...CAPABILITY_LIMIT_KEYS] as const;
33
-
34
- export type CapabilityFlagKey = (typeof CAPABILITY_FLAG_KEYS)[number];
35
- export type CapabilityLimitKey = (typeof CAPABILITY_LIMIT_KEYS)[number];
36
- export type CapabilitySettingsKey = (typeof CAPABILITY_SETTINGS_KEYS)[number];
37
-
38
- /** Resolved, secret-free capability section. */
39
- export interface CapabilitySettings {
40
- readonly codexSearch: boolean;
41
- readonly codexImages: boolean;
42
- readonly codexImageEdits: boolean;
43
- /** Allow non-Codex-route models to use the Codex image generate/edit tools. */
44
- readonly codexImagesAnyModel: boolean;
45
- readonly codexUsage: boolean;
46
- readonly codexFast: boolean;
47
- readonly grokImagineImage: boolean;
48
- readonly grokImagineVideo: boolean;
49
- readonly searchResults: number;
50
- readonly imageCount: number;
51
- readonly videoArtifactTtlMs: number;
52
- }
53
-
54
- /** Sparse overlay used for YAML/composition `base` and the user section. */
55
- export type CapabilitySettingsPatch = Partial<CapabilitySettings>;
56
-
57
- /** Inclusive bounds and schema defaults for each numeric limit. */
58
- export const CAPABILITY_SETTINGS_BOUNDS = {
59
- searchResults: { min: 1, max: 20, default: 5 },
60
- imageCount: { min: 1, max: 4, default: 1 },
61
- videoArtifactTtlMs: {
62
- min: 60 * 60 * 1000,
63
- max: 7 * 24 * 60 * 60 * 1000,
64
- default: 7 * 24 * 60 * 60 * 1000,
65
- },
66
- } as const;
67
-
68
- /** Schema defaults: every flag off, every limit at its conservative default. */
69
- export const DEFAULT_CAPABILITY_SETTINGS: CapabilitySettings = Object.freeze({
70
- codexSearch: false,
71
- codexImages: false,
72
- codexImageEdits: false,
73
- codexImagesAnyModel: false,
74
- codexUsage: false,
75
- codexFast: false,
76
- grokImagineImage: false,
77
- grokImagineVideo: false,
78
- searchResults: CAPABILITY_SETTINGS_BOUNDS.searchResults.default,
79
- imageCount: CAPABILITY_SETTINGS_BOUNDS.imageCount.default,
80
- videoArtifactTtlMs: CAPABILITY_SETTINGS_BOUNDS.videoArtifactTtlMs.default,
81
- });
82
-
83
- /**
84
- * Real Schemastery schema registered with the Host settings service. Defaults
85
- * remain conservative, and bounds are enforced before a user document commits.
86
- */
87
- export const CapabilitySettingsSchema = Schema.object({
88
- codexSearch: Schema.boolean().default(false),
89
- codexImages: Schema.boolean().default(false),
90
- codexImageEdits: Schema.boolean().default(false),
91
- codexImagesAnyModel: Schema.boolean().default(false),
92
- codexUsage: Schema.boolean().default(false),
93
- codexFast: Schema.boolean().default(false),
94
- grokImagineImage: Schema.boolean().default(false),
95
- grokImagineVideo: Schema.boolean().default(false),
96
- searchResults: Schema.number()
97
- .step(1)
98
- .min(CAPABILITY_SETTINGS_BOUNDS.searchResults.min)
99
- .max(CAPABILITY_SETTINGS_BOUNDS.searchResults.max)
100
- .default(CAPABILITY_SETTINGS_BOUNDS.searchResults.default),
101
- imageCount: Schema.number()
102
- .step(1)
103
- .min(CAPABILITY_SETTINGS_BOUNDS.imageCount.min)
104
- .max(CAPABILITY_SETTINGS_BOUNDS.imageCount.max)
105
- .default(CAPABILITY_SETTINGS_BOUNDS.imageCount.default),
106
- videoArtifactTtlMs: Schema.number()
107
- .step(1)
108
- .min(CAPABILITY_SETTINGS_BOUNDS.videoArtifactTtlMs.min)
109
- .max(CAPABILITY_SETTINGS_BOUNDS.videoArtifactTtlMs.max)
110
- .default(CAPABILITY_SETTINGS_BOUNDS.videoArtifactTtlMs.default),
111
- });
112
-
113
- /** Serialized schema metadata consumed by Settings UI tests and diagnostics. */
114
- export const CAPABILITY_SETTINGS_SCHEMA_JSON = CapabilitySettingsSchema.toJSON();
115
-
116
- export type CapabilitySettingsSchemaType = typeof CapabilitySettingsSchema;
117
-
118
- /** Revision-bearing, secret-free snapshot used for CAS writes and UI. */
119
- export interface CapabilitySettingsSnapshot {
120
- readonly ns: typeof CAPABILITY_SETTINGS_NAMESPACE;
121
- readonly value: CapabilitySettings;
122
- readonly base?: CapabilitySettingsPatch;
123
- readonly user?: CapabilitySettingsPatch;
124
- readonly revision: number;
125
- readonly writable: boolean;
126
- readonly applies: "live";
127
- readonly secrets: readonly [];
128
- }
129
-
130
- /** Owner-facing subset of `ctx.settings.register()` used when the parent injects a provider. */
131
- export interface CapabilitySettingsScope {
132
- get(): unknown;
133
- watch(callback: (next: unknown, prev: unknown) => void | Promise<void>): () => void;
134
- update(patch: object): Promise<void>;
135
- replace(section: object): Promise<void>;
136
- }
137
-
138
- /** One namespace descriptor as returned by a structural `describe()`. */
139
- export interface CapabilitySettingsDescriptor {
140
- readonly ns: string;
141
- readonly value?: unknown;
142
- readonly base?: unknown;
143
- readonly user?: unknown;
144
- readonly revision?: number;
145
- readonly applies?: "live" | "restart";
146
- readonly secrets?: readonly { readonly path?: readonly string[]; readonly set?: boolean }[];
147
- }
148
-
149
- /**
150
- * Duck-typed settings service. A real `ctx.settings` satisfies this without a
151
- * compile-time dependency on `@deepseek-ai/dsh-settings`.
152
- */
153
- export interface CapabilitySettingsService {
154
- readonly writable?: boolean;
155
- describe?(options?: { readonly redactSecrets?: boolean }): readonly CapabilitySettingsDescriptor[];
156
- get?(ns: string): unknown;
157
- update?(ns: string, patch: object, expectedRevision?: number): Promise<void>;
158
- replace?(ns: string, section: object, expectedRevision?: number): Promise<void>;
159
- register?(
160
- ns: string,
161
- schema: CapabilitySettingsSchemaType,
162
- options?: {
163
- readonly base?: CapabilitySettingsPatch;
164
- readonly applies?: "live" | "restart";
165
- readonly validate?: (value: CapabilitySettings) => void;
166
- },
167
- ): CapabilitySettingsScope;
168
- }
169
-
170
- /** Construction options. `base` is the YAML / composition entry layered under the user section. */
171
- export interface CapabilitySettingsControllerOptions {
172
- readonly settings?: CapabilitySettingsService | undefined;
173
- readonly base?: CapabilitySettingsPatch | undefined;
174
- /** Contain both synchronous and asynchronous observer failures. */
175
- readonly onListenerError?: ((error: unknown) => void) | undefined;
176
- }
177
-
178
- /** Listener invoked after a committed snapshot change. */
179
- export type CapabilitySettingsListener = (snapshot: CapabilitySettingsSnapshot) => void | Promise<void>;
180
-
181
- const SECRET_KEY = /secret|token|password|passphrase|apikey|api_key|authorization|credential|cookie|private[_-]?key/iu;
182
- const RESERVED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
183
- const KNOWN_KEYS = new Set<string>(CAPABILITY_SETTINGS_KEYS);
184
-
185
- /**
186
- * A write refused because the namespace moved since the caller read it.
187
- * `code` matches the Host settings seam so a later wire layer can map it.
188
- */
189
- export class CapabilitySettingsConflictError extends Error {
190
- readonly code = "SETTINGS_CONFLICT";
191
- readonly ns = CAPABILITY_SETTINGS_NAMESPACE;
192
- readonly expected: number;
193
- readonly actual: number;
194
-
195
- constructor(expected: number, actual: number) {
196
- super(
197
- `settings namespace "${CAPABILITY_SETTINGS_NAMESPACE}" changed since it was read ` +
198
- `(expected revision ${String(expected)}, now ${String(actual)})`,
199
- );
200
- this.name = "CapabilitySettingsConflictError";
201
- this.expected = expected;
202
- this.actual = actual;
203
- }
204
- }
205
-
206
- /** A write refused because no writable settings provider is attached. */
207
- export class CapabilitySettingsReadOnlyError extends Error {
208
- readonly code: "SETTINGS_PROVIDER_ABSENT" | "SETTINGS_READ_ONLY" | "SETTINGS_DISPOSED";
209
- readonly ns = CAPABILITY_SETTINGS_NAMESPACE;
210
- readonly reason: "absent" | "read-only" | "disposed";
211
-
212
- constructor(reason: "absent" | "read-only" | "disposed") {
213
- const code =
214
- reason === "absent"
215
- ? "SETTINGS_PROVIDER_ABSENT"
216
- : reason === "disposed"
217
- ? "SETTINGS_DISPOSED"
218
- : "SETTINGS_READ_ONLY";
219
- const detail =
220
- reason === "absent"
221
- ? `settings provider is absent: "${CAPABILITY_SETTINGS_NAMESPACE}" cannot be updated`
222
- : reason === "disposed"
223
- ? `settings controller is disposed: "${CAPABILITY_SETTINGS_NAMESPACE}" cannot be updated`
224
- : `settings provider is read-only: "${CAPABILITY_SETTINGS_NAMESPACE}" cannot be updated in-process`;
225
- super(detail);
226
- this.name = "CapabilitySettingsReadOnlyError";
227
- this.code = code;
228
- this.reason = reason;
229
- }
230
- }
231
-
232
- export function isCapabilitySettingsConflictError(error: unknown): error is CapabilitySettingsConflictError {
233
- return error instanceof CapabilitySettingsConflictError;
234
- }
235
-
236
- export function isCapabilitySettingsReadOnlyError(error: unknown): error is CapabilitySettingsReadOnlyError {
237
- return error instanceof CapabilitySettingsReadOnlyError;
238
- }
239
-
240
- /** Pick every independently default-off flag from a resolved section. */
241
- export function capabilityFlags(settings: CapabilitySettings): Pick<CapabilitySettings, CapabilityFlagKey> {
242
- return {
243
- codexSearch: settings.codexSearch,
244
- codexImages: settings.codexImages,
245
- codexImageEdits: settings.codexImageEdits,
246
- codexImagesAnyModel: settings.codexImagesAnyModel,
247
- codexUsage: settings.codexUsage,
248
- codexFast: settings.codexFast,
249
- grokImagineImage: settings.grokImagineImage,
250
- grokImagineVideo: settings.grokImagineVideo,
251
- };
252
- }
253
-
254
- /** Pick the conservative numeric limits from a resolved section. */
255
- export function capabilityLimits(settings: CapabilitySettings): Pick<CapabilitySettings, CapabilityLimitKey> {
256
- return {
257
- searchResults: settings.searchResults,
258
- imageCount: settings.imageCount,
259
- videoArtifactTtlMs: settings.videoArtifactTtlMs,
260
- };
261
- }
262
-
263
- /** Layer schema defaults, then YAML/composition `base`, then the user section. */
264
- export function resolveCapabilitySettings(
265
- base?: CapabilitySettingsPatch | undefined,
266
- user?: CapabilitySettingsPatch | undefined,
267
- ): CapabilitySettings {
268
- return normalizeCapabilitySettings({
269
- ...DEFAULT_CAPABILITY_SETTINGS,
270
- ...normalizeCapabilitySettingsPatch(base),
271
- ...normalizeCapabilitySettingsPatch(user),
272
- });
273
- }
274
-
275
- /**
276
- * Admit a candidate section: known keys only, flags default off, limits clamped,
277
- * secret-shaped keys dropped. Used for both reads and the structural schema.
278
- */
279
- export function normalizeCapabilitySettings(input?: unknown): CapabilitySettings {
280
- const patch = normalizeCapabilitySettingsPatch(input);
281
- const codexImages = patch.codexImages ?? DEFAULT_CAPABILITY_SETTINGS.codexImages;
282
- return Object.freeze({
283
- codexSearch: patch.codexSearch ?? DEFAULT_CAPABILITY_SETTINGS.codexSearch,
284
- codexImages,
285
- codexImageEdits: patch.codexImageEdits ?? DEFAULT_CAPABILITY_SETTINGS.codexImageEdits,
286
- // any-model is meaningless without images; clear at admit so UI cascade alone cannot leave it on
287
- codexImagesAnyModel: codexImages
288
- ? (patch.codexImagesAnyModel ?? DEFAULT_CAPABILITY_SETTINGS.codexImagesAnyModel)
289
- : false,
290
- codexUsage: patch.codexUsage ?? DEFAULT_CAPABILITY_SETTINGS.codexUsage,
291
- codexFast: patch.codexFast ?? DEFAULT_CAPABILITY_SETTINGS.codexFast,
292
- grokImagineImage: patch.grokImagineImage ?? DEFAULT_CAPABILITY_SETTINGS.grokImagineImage,
293
- grokImagineVideo: patch.grokImagineVideo ?? DEFAULT_CAPABILITY_SETTINGS.grokImagineVideo,
294
- searchResults: patch.searchResults ?? DEFAULT_CAPABILITY_SETTINGS.searchResults,
295
- imageCount: patch.imageCount ?? DEFAULT_CAPABILITY_SETTINGS.imageCount,
296
- videoArtifactTtlMs: patch.videoArtifactTtlMs ?? DEFAULT_CAPABILITY_SETTINGS.videoArtifactTtlMs,
297
- });
298
- }
299
-
300
- /**
301
- * Normalize a sparse overlay. Invalid or secret fields are omitted so a lower
302
- * layer (YAML base / schema default) remains authoritative for that key.
303
- */
304
- export function normalizeCapabilitySettingsPatch(input?: unknown): CapabilitySettingsPatch {
305
- if (!isPlainObject(input)) return {};
306
- const patch: {
307
- codexSearch?: boolean;
308
- codexImages?: boolean;
309
- codexImageEdits?: boolean;
310
- codexImagesAnyModel?: boolean;
311
- codexUsage?: boolean;
312
- codexFast?: boolean;
313
- grokImagineImage?: boolean;
314
- grokImagineVideo?: boolean;
315
- searchResults?: number;
316
- imageCount?: number;
317
- videoArtifactTtlMs?: number;
318
- } = {};
319
- const flags = input as Record<string, unknown>;
320
- assignFlag(patch, "codexSearch", flags["codexSearch"]);
321
- assignFlag(patch, "codexImages", flags["codexImages"]);
322
- assignFlag(patch, "codexImageEdits", flags["codexImageEdits"]);
323
- assignFlag(patch, "codexImagesAnyModel", flags["codexImagesAnyModel"]);
324
- assignFlag(patch, "codexUsage", flags["codexUsage"]);
325
- assignFlag(patch, "codexFast", flags["codexFast"]);
326
- assignFlag(patch, "grokImagineImage", flags["grokImagineImage"]);
327
- assignFlag(patch, "grokImagineVideo", flags["grokImagineVideo"]);
328
- assignLimit(patch, "searchResults", flags["searchResults"]);
329
- assignLimit(patch, "imageCount", flags["imageCount"]);
330
- assignLimit(patch, "videoArtifactTtlMs", flags["videoArtifactTtlMs"]);
331
- return Object.freeze(patch);
332
- }
333
-
334
- /**
335
- * Strictly admit a caller-authored sparse section before normalizing it. Reads
336
- * remain compatibility-tolerant, but writes must never silently drop unknown
337
- * fields, coerce types, truncate decimals, or clamp out-of-range limits.
338
- */
339
- export function assertCapabilitySettingsPatch(
340
- input: unknown,
341
- label = "capability settings",
342
- ): asserts input is CapabilitySettingsPatch {
343
- assertPlainObject(input, label);
344
- for (const [key, value] of Object.entries(input)) {
345
- if (!KNOWN_KEYS.has(key)) throw new TypeError(`${label} contains unknown key ${key}`);
346
- if ((CAPABILITY_FLAG_KEYS as readonly string[]).includes(key)) {
347
- if (typeof value !== "boolean") throw new TypeError(`${label}.${key} must be a boolean`);
348
- continue;
349
- }
350
- const limitKey = key as CapabilityLimitKey;
351
- const bounds = CAPABILITY_SETTINGS_BOUNDS[limitKey];
352
- if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) {
353
- throw new TypeError(`${label}.${key} must be an integer`);
354
- }
355
- if (value < bounds.min || value > bounds.max) {
356
- throw new TypeError(`${label}.${key} must be in [${String(bounds.min)}, ${String(bounds.max)}]`);
357
- }
358
- }
359
- }
360
-
361
- /** Reject a resolved section the owner could not act on. Schema-valid by construction after normalize. */
362
- export function assertServiceableCapabilitySettings(value: CapabilitySettings): void {
363
- for (const key of CAPABILITY_FLAG_KEYS) {
364
- if (typeof value[key] !== "boolean") {
365
- throw new TypeError(`capability settings: ${key} must be a boolean`);
366
- }
367
- }
368
- for (const key of CAPABILITY_LIMIT_KEYS) {
369
- const bounds = CAPABILITY_SETTINGS_BOUNDS[key];
370
- const numeric = value[key];
371
- if (!Number.isFinite(numeric) || !Number.isInteger(numeric) || numeric < bounds.min || numeric > bounds.max) {
372
- throw new TypeError(
373
- `capability settings: ${key} must be an integer in [${String(bounds.min)}, ${String(bounds.max)}]`,
374
- );
375
- }
376
- }
377
- }
378
-
379
- /**
380
- * Live capability-settings controller. Without an injected provider the
381
- * resolved state is the YAML/default layer and every write fails explicitly.
382
- */
383
- export class CapabilitySettingsController {
384
- readonly ns = CAPABILITY_SETTINGS_NAMESPACE;
385
- private readonly settings: CapabilitySettingsService | undefined;
386
- private readonly base: CapabilitySettingsPatch;
387
- private readonly onListenerError: (error: unknown) => void;
388
- private readonly listeners = new Set<CapabilitySettingsListener>();
389
- private scope: CapabilitySettingsScope | undefined;
390
- private scopeDisposer: (() => void) | undefined;
391
- private localRevision = 0;
392
- private lastSnapshot: CapabilitySettingsSnapshot;
393
- private disposed = false;
394
-
395
- constructor(options: CapabilitySettingsControllerOptions = {}) {
396
- this.settings = options.settings;
397
- this.base = normalizeCapabilitySettingsPatch(options.base);
398
- this.onListenerError = options.onListenerError ?? (() => undefined);
399
- this.attachScope();
400
- this.lastSnapshot = this.readSnapshot();
401
- }
402
-
403
- /** Current revision-bearing snapshot. Re-reads the injected provider when present. */
404
- snapshot(): CapabilitySettingsSnapshot {
405
- const next = this.readSnapshot();
406
- this.lastSnapshot = next;
407
- return next;
408
- }
409
-
410
- /** Resolved capability section (schema defaults ← YAML base ← user). */
411
- current(): CapabilitySettings {
412
- return this.snapshot().value;
413
- }
414
-
415
- /**
416
- * Merge a secret-free patch into the user layer using compare-and-swap on
417
- * `expectedRevision` from a previously read {@link snapshot}.
418
- */
419
- async patch(patch: CapabilitySettingsPatch, expectedRevision: number): Promise<CapabilitySettingsSnapshot> {
420
- return this.write("update", patch, expectedRevision);
421
- }
422
-
423
- /**
424
- * Replace the user section wholesale (`{}` re-inherits YAML base and defaults).
425
- * Compare-and-swap uses the same revision token as {@link patch}.
426
- */
427
- async replace(section: CapabilitySettingsPatch, expectedRevision: number): Promise<CapabilitySettingsSnapshot> {
428
- return this.write("replace", section, expectedRevision);
429
- }
430
-
431
- /**
432
- * Observe committed snapshot changes. The disposer removes this listener;
433
- * an invocation already running still settles.
434
- */
435
- subscribe(listener: CapabilitySettingsListener): () => void {
436
- if (this.disposed) return () => undefined;
437
- this.listeners.add(listener);
438
- return () => {
439
- this.listeners.delete(listener);
440
- };
441
- }
442
-
443
- /**
444
- * Re-read the injected provider (or the local YAML/default layer) and notify
445
- * listeners when the secret-free snapshot moved.
446
- */
447
- reconcile(): CapabilitySettingsSnapshot {
448
- const next = this.readSnapshot();
449
- this.publish(next);
450
- return this.lastSnapshot;
451
- }
452
-
453
- /** Drop the register() watcher and every listener. Further writes fail. */
454
- dispose(): void {
455
- if (this.disposed) return;
456
- this.disposed = true;
457
- const releaseScope = this.scopeDisposer;
458
- this.scopeDisposer = undefined;
459
- this.scope = undefined;
460
- this.listeners.clear();
461
- try {
462
- releaseScope?.();
463
- } catch (error: unknown) {
464
- this.onListenerError(error);
465
- }
466
- }
467
-
468
- private attachScope(): void {
469
- const register = this.settings?.register;
470
- if (register === undefined) return;
471
- this.scope = register.call(this.settings, CAPABILITY_SETTINGS_NAMESPACE, CapabilitySettingsSchema, {
472
- base: this.base,
473
- applies: "live",
474
- validate: assertServiceableCapabilitySettings,
475
- });
476
- this.scopeDisposer = this.scope.watch(() => {
477
- if (this.disposed) return;
478
- this.reconcile();
479
- });
480
- }
481
-
482
- private writeReason(): "absent" | "read-only" | "disposed" | undefined {
483
- if (this.disposed) return "disposed";
484
- if (this.settings === undefined) return "absent";
485
- if (this.settings.writable === false) return "read-only";
486
- const canWrite =
487
- typeof this.settings.update === "function" ||
488
- typeof this.settings.replace === "function" ||
489
- this.scope !== undefined;
490
- if (!canWrite) return "read-only";
491
- return undefined;
492
- }
493
-
494
- private isWritable(): boolean {
495
- return this.writeReason() === undefined;
496
- }
497
-
498
- private async write(
499
- mode: "update" | "replace",
500
- input: CapabilitySettingsPatch,
501
- expectedRevision: number,
502
- ): Promise<CapabilitySettingsSnapshot> {
503
- const reason = this.writeReason();
504
- if (reason !== undefined) throw new CapabilitySettingsReadOnlyError(reason);
505
- assertCapabilitySettingsPatch(input, `capability settings ${mode}`);
506
- const current = this.readSnapshot();
507
- if (expectedRevision !== current.revision) {
508
- throw new CapabilitySettingsConflictError(expectedRevision, current.revision);
509
- }
510
- const normalized = normalizeCapabilitySettingsPatch(input);
511
- if (mode === "update" && !hasOwnKeys(normalized)) return current;
512
- const settings = this.settings!;
513
- try {
514
- if (mode === "update") {
515
- if (typeof settings.update === "function") {
516
- await settings.update(CAPABILITY_SETTINGS_NAMESPACE, { ...normalized }, expectedRevision);
517
- } else {
518
- await this.scope!.update({ ...normalized });
519
- }
520
- } else if (typeof settings.replace === "function") {
521
- await settings.replace(CAPABILITY_SETTINGS_NAMESPACE, { ...normalized }, expectedRevision);
522
- } else {
523
- await this.scope!.replace({ ...normalized });
524
- }
525
- } catch (error) {
526
- throw toConflictError(error) ?? error;
527
- }
528
- this.localRevision = current.revision + 1;
529
- const next = this.readSnapshot();
530
- this.publish(next);
531
- return this.lastSnapshot;
532
- }
533
-
534
- private readSnapshot(): CapabilitySettingsSnapshot {
535
- const writable = this.isWritable();
536
- const described = this.readDescribed();
537
- const base = described?.base !== undefined ? normalizeCapabilitySettingsPatch(described.base) : this.base;
538
- const user = described?.user !== undefined ? normalizeCapabilitySettingsPatch(described.user) : undefined;
539
- const resolved =
540
- described?.value !== undefined
541
- ? normalizeCapabilitySettings(described.value)
542
- : this.readResolvedFromService(base, user);
543
- const revision =
544
- typeof described?.revision === "number" && Number.isFinite(described.revision)
545
- ? described.revision
546
- : this.localRevision;
547
- return freezeSnapshot({
548
- ns: CAPABILITY_SETTINGS_NAMESPACE,
549
- value: resolved,
550
- revision,
551
- writable,
552
- applies: "live",
553
- secrets: [],
554
- ...(hasOwnKeys(base) ? { base } : {}),
555
- ...(user !== undefined && hasOwnKeys(user) ? { user } : {}),
556
- });
557
- }
558
-
559
- private readResolvedFromService(
560
- base: CapabilitySettingsPatch,
561
- user: CapabilitySettingsPatch | undefined,
562
- ): CapabilitySettings {
563
- const raw = this.scope?.get() ?? this.settings?.get?.(CAPABILITY_SETTINGS_NAMESPACE);
564
- if (raw !== undefined) return normalizeCapabilitySettings(raw);
565
- return resolveCapabilitySettings(base, user);
566
- }
567
-
568
- private readDescribed(): CapabilitySettingsDescriptor | undefined {
569
- const describe = this.settings?.describe;
570
- if (describe === undefined) return undefined;
571
- try {
572
- const descriptors = describe.call(this.settings, { redactSecrets: true });
573
- if (!Array.isArray(descriptors)) return undefined;
574
- return descriptors.find((entry) => entry?.ns === CAPABILITY_SETTINGS_NAMESPACE);
575
- } catch {
576
- return undefined;
577
- }
578
- }
579
-
580
- private publish(next: CapabilitySettingsSnapshot): void {
581
- if (sameSnapshot(this.lastSnapshot, next)) {
582
- this.lastSnapshot = next;
583
- return;
584
- }
585
- this.lastSnapshot = next;
586
- for (const listener of [...this.listeners]) {
587
- try {
588
- const result = listener(next);
589
- if (result !== undefined) void Promise.resolve(result).catch(this.onListenerError);
590
- } catch (error) {
591
- // One broken observer must not starve the rest or the write path.
592
- this.onListenerError(error);
593
- }
594
- }
595
- }
596
- }
597
-
598
- /** Construct a {@link CapabilitySettingsController}. */
599
- export function createCapabilitySettingsController(
600
- options: CapabilitySettingsControllerOptions = {},
601
- ): CapabilitySettingsController {
602
- return new CapabilitySettingsController(options);
603
- }
604
-
605
- function assignFlag<K extends CapabilityFlagKey>(target: { [P in K]?: boolean }, key: K, value: unknown): void {
606
- if (typeof value === "boolean") target[key] = value;
607
- }
608
-
609
- function assignLimit<K extends CapabilityLimitKey>(target: { [P in K]?: number }, key: K, value: unknown): void {
610
- if (typeof value !== "number" || !Number.isFinite(value)) return;
611
- const bounds = CAPABILITY_SETTINGS_BOUNDS[key];
612
- const integer = Math.trunc(value);
613
- target[key] = Math.min(bounds.max, Math.max(bounds.min, integer));
614
- }
615
-
616
- function isPlainObject(value: unknown): value is Record<string, unknown> {
617
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
618
- const proto = Object.getPrototypeOf(value);
619
- return proto === Object.prototype || proto === null;
620
- }
621
-
622
- function assertPlainObject(value: unknown, label: string): asserts value is Record<string, unknown> {
623
- if (!isPlainObject(value)) throw new TypeError(`${label} must be a plain object`);
624
- for (const key of Object.keys(value)) {
625
- if (RESERVED_KEYS.has(key) || SECRET_KEY.test(key)) {
626
- throw new TypeError(`${label} must be secret-free (rejected key ${key})`);
627
- }
628
- }
629
- }
630
-
631
- function hasOwnKeys(value: object): boolean {
632
- return Object.keys(value).length > 0;
633
- }
634
-
635
- function freezeSnapshot(snapshot: CapabilitySettingsSnapshot): CapabilitySettingsSnapshot {
636
- return Object.freeze(snapshot);
637
- }
638
-
639
- function sameSnapshot(left: CapabilitySettingsSnapshot | undefined, right: CapabilitySettingsSnapshot): boolean {
640
- if (left === undefined) return false;
641
- return (
642
- left.revision === right.revision &&
643
- left.writable === right.writable &&
644
- deepEqualJson(left.value, right.value) &&
645
- deepEqualJson(left.base, right.base) &&
646
- deepEqualJson(left.user, right.user)
647
- );
648
- }
649
-
650
- function deepEqualJson(a: unknown, b: unknown): boolean {
651
- if (a === b) return true;
652
- if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
653
- if (Array.isArray(a) || Array.isArray(b)) {
654
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
655
- return a.every((entry, index) => deepEqualJson(entry, b[index]));
656
- }
657
- const left = a as Record<string, unknown>;
658
- const right = b as Record<string, unknown>;
659
- const keys = Object.keys(left);
660
- if (keys.length !== Object.keys(right).length) return false;
661
- return keys.every((key) => key in right && deepEqualJson(left[key], right[key]));
662
- }
663
-
664
- function toConflictError(error: unknown): CapabilitySettingsConflictError | undefined {
665
- if (error instanceof CapabilitySettingsConflictError) return error;
666
- if (typeof error !== "object" || error === null) return undefined;
667
- const candidate = error as { code?: unknown; expected?: unknown; actual?: unknown };
668
- if (candidate.code !== "SETTINGS_CONFLICT") return undefined;
669
- if (typeof candidate.expected !== "number" || typeof candidate.actual !== "number") return undefined;
670
- return new CapabilitySettingsConflictError(candidate.expected, candidate.actual);
671
- }
1
+ import Schema from "@deepseek-ai/schemastery";
2
+ import { CAPABILITY_SETTINGS_NAMESPACE } from "./ids.ts";
3
+
4
+ /**
5
+ * Integration-ready capability settings controller for the
6
+ * `coding-subscription-oauth` namespace. Schema defaults sit under the
7
+ * composition/YAML `base`, and the user section layers on top. The controller
8
+ * talks to an injected structural settings service while registering a real
9
+ * Schemastery section that the Host settings service can render and validate.
10
+ * @module dsh-coding-subscription-oauth/capability-settings
11
+ */
12
+
13
+ /** Settings namespace owned by this plugin. */
14
+ export { CAPABILITY_SETTINGS_NAMESPACE } from "./ids.ts";
15
+
16
+ /** Default-off capability flags. Presence in the user section marks an override. */
17
+ export const CAPABILITY_FLAG_KEYS = [
18
+ "codexSearch",
19
+ "codexImages",
20
+ "codexImageEdits",
21
+ "codexImagesAnyModel",
22
+ "codexUsage",
23
+ "codexFast",
24
+ "grokImagineImage",
25
+ "grokImagineVideo",
26
+ ] as const;
27
+
28
+ /** Conservative numeric limits persisted beside the flags. */
29
+ export const CAPABILITY_LIMIT_KEYS = ["searchResults", "imageCount", "videoArtifactTtlMs"] as const;
30
+
31
+ /** Every key the controller admits into secret-free state. */
32
+ export const CAPABILITY_SETTINGS_KEYS = [...CAPABILITY_FLAG_KEYS, ...CAPABILITY_LIMIT_KEYS] as const;
33
+
34
+ export type CapabilityFlagKey = (typeof CAPABILITY_FLAG_KEYS)[number];
35
+ export type CapabilityLimitKey = (typeof CAPABILITY_LIMIT_KEYS)[number];
36
+ export type CapabilitySettingsKey = (typeof CAPABILITY_SETTINGS_KEYS)[number];
37
+
38
+ /** Resolved, secret-free capability section. */
39
+ export interface CapabilitySettings {
40
+ readonly codexSearch: boolean;
41
+ readonly codexImages: boolean;
42
+ readonly codexImageEdits: boolean;
43
+ /** Allow non-Codex-route models to use the Codex image generate/edit tools. */
44
+ readonly codexImagesAnyModel: boolean;
45
+ readonly codexUsage: boolean;
46
+ readonly codexFast: boolean;
47
+ readonly grokImagineImage: boolean;
48
+ readonly grokImagineVideo: boolean;
49
+ readonly searchResults: number;
50
+ readonly imageCount: number;
51
+ readonly videoArtifactTtlMs: number;
52
+ }
53
+
54
+ /** Sparse overlay used for YAML/composition `base` and the user section. */
55
+ export type CapabilitySettingsPatch = Partial<CapabilitySettings>;
56
+
57
+ /** Inclusive bounds and schema defaults for each numeric limit. */
58
+ export const CAPABILITY_SETTINGS_BOUNDS = {
59
+ searchResults: { min: 1, max: 20, default: 5 },
60
+ imageCount: { min: 1, max: 4, default: 1 },
61
+ videoArtifactTtlMs: {
62
+ min: 60 * 60 * 1000,
63
+ max: 7 * 24 * 60 * 60 * 1000,
64
+ default: 7 * 24 * 60 * 60 * 1000,
65
+ },
66
+ } as const;
67
+
68
+ /** Schema defaults: every flag off, every limit at its conservative default. */
69
+ export const DEFAULT_CAPABILITY_SETTINGS: CapabilitySettings = Object.freeze({
70
+ codexSearch: false,
71
+ codexImages: false,
72
+ codexImageEdits: false,
73
+ codexImagesAnyModel: false,
74
+ codexUsage: false,
75
+ codexFast: false,
76
+ grokImagineImage: false,
77
+ grokImagineVideo: false,
78
+ searchResults: CAPABILITY_SETTINGS_BOUNDS.searchResults.default,
79
+ imageCount: CAPABILITY_SETTINGS_BOUNDS.imageCount.default,
80
+ videoArtifactTtlMs: CAPABILITY_SETTINGS_BOUNDS.videoArtifactTtlMs.default,
81
+ });
82
+
83
+ /**
84
+ * Real Schemastery schema registered with the Host settings service. Defaults
85
+ * remain conservative, and bounds are enforced before a user document commits.
86
+ */
87
+ export const CapabilitySettingsSchema = Schema.object({
88
+ codexSearch: Schema.boolean().default(false),
89
+ codexImages: Schema.boolean().default(false),
90
+ codexImageEdits: Schema.boolean().default(false),
91
+ codexImagesAnyModel: Schema.boolean().default(false),
92
+ codexUsage: Schema.boolean().default(false),
93
+ codexFast: Schema.boolean().default(false),
94
+ grokImagineImage: Schema.boolean().default(false),
95
+ grokImagineVideo: Schema.boolean().default(false),
96
+ searchResults: Schema.number()
97
+ .step(1)
98
+ .min(CAPABILITY_SETTINGS_BOUNDS.searchResults.min)
99
+ .max(CAPABILITY_SETTINGS_BOUNDS.searchResults.max)
100
+ .default(CAPABILITY_SETTINGS_BOUNDS.searchResults.default),
101
+ imageCount: Schema.number()
102
+ .step(1)
103
+ .min(CAPABILITY_SETTINGS_BOUNDS.imageCount.min)
104
+ .max(CAPABILITY_SETTINGS_BOUNDS.imageCount.max)
105
+ .default(CAPABILITY_SETTINGS_BOUNDS.imageCount.default),
106
+ videoArtifactTtlMs: Schema.number()
107
+ .step(1)
108
+ .min(CAPABILITY_SETTINGS_BOUNDS.videoArtifactTtlMs.min)
109
+ .max(CAPABILITY_SETTINGS_BOUNDS.videoArtifactTtlMs.max)
110
+ .default(CAPABILITY_SETTINGS_BOUNDS.videoArtifactTtlMs.default),
111
+ });
112
+
113
+ /** Serialized schema metadata consumed by Settings UI tests and diagnostics. */
114
+ export const CAPABILITY_SETTINGS_SCHEMA_JSON = CapabilitySettingsSchema.toJSON();
115
+
116
+ export type CapabilitySettingsSchemaType = typeof CapabilitySettingsSchema;
117
+
118
+ /** Revision-bearing, secret-free snapshot used for CAS writes and UI. */
119
+ export interface CapabilitySettingsSnapshot {
120
+ readonly ns: typeof CAPABILITY_SETTINGS_NAMESPACE;
121
+ readonly value: CapabilitySettings;
122
+ readonly base?: CapabilitySettingsPatch;
123
+ readonly user?: CapabilitySettingsPatch;
124
+ readonly revision: number;
125
+ readonly writable: boolean;
126
+ readonly applies: "live";
127
+ readonly secrets: readonly [];
128
+ }
129
+
130
+ /** Owner-facing subset of `ctx.settings.register()` used when the parent injects a provider. */
131
+ export interface CapabilitySettingsScope {
132
+ get(): unknown;
133
+ watch(callback: (next: unknown, prev: unknown) => void | Promise<void>): () => void;
134
+ update(patch: object): Promise<void>;
135
+ replace(section: object): Promise<void>;
136
+ }
137
+
138
+ /** One namespace descriptor as returned by a structural `describe()`. */
139
+ export interface CapabilitySettingsDescriptor {
140
+ readonly ns: string;
141
+ readonly value?: unknown;
142
+ readonly base?: unknown;
143
+ readonly user?: unknown;
144
+ readonly revision?: number;
145
+ readonly applies?: "live" | "restart";
146
+ readonly secrets?: readonly { readonly path?: readonly string[]; readonly set?: boolean }[];
147
+ }
148
+
149
+ /**
150
+ * Duck-typed settings service. A real `ctx.settings` satisfies this without a
151
+ * compile-time dependency on `@deepseek-ai/dsh-settings`.
152
+ */
153
+ export interface CapabilitySettingsService {
154
+ readonly writable?: boolean;
155
+ describe?(options?: { readonly redactSecrets?: boolean }): readonly CapabilitySettingsDescriptor[];
156
+ get?(ns: string): unknown;
157
+ update?(ns: string, patch: object, expectedRevision?: number): Promise<void>;
158
+ replace?(ns: string, section: object, expectedRevision?: number): Promise<void>;
159
+ register?(
160
+ ns: string,
161
+ schema: CapabilitySettingsSchemaType,
162
+ options?: {
163
+ readonly base?: CapabilitySettingsPatch;
164
+ readonly applies?: "live" | "restart";
165
+ readonly validate?: (value: CapabilitySettings) => void;
166
+ },
167
+ ): CapabilitySettingsScope;
168
+ }
169
+
170
+ /** Construction options. `base` is the YAML / composition entry layered under the user section. */
171
+ export interface CapabilitySettingsControllerOptions {
172
+ readonly settings?: CapabilitySettingsService | undefined;
173
+ readonly base?: CapabilitySettingsPatch | undefined;
174
+ /** Contain both synchronous and asynchronous observer failures. */
175
+ readonly onListenerError?: ((error: unknown) => void) | undefined;
176
+ }
177
+
178
+ /** Listener invoked after a committed snapshot change. */
179
+ export type CapabilitySettingsListener = (snapshot: CapabilitySettingsSnapshot) => void | Promise<void>;
180
+
181
+ const SECRET_KEY = /secret|token|password|passphrase|apikey|api_key|authorization|credential|cookie|private[_-]?key/iu;
182
+ const RESERVED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
183
+ const KNOWN_KEYS = new Set<string>(CAPABILITY_SETTINGS_KEYS);
184
+
185
+ /**
186
+ * A write refused because the namespace moved since the caller read it.
187
+ * `code` matches the Host settings seam so a later wire layer can map it.
188
+ */
189
+ export class CapabilitySettingsConflictError extends Error {
190
+ readonly code = "SETTINGS_CONFLICT";
191
+ readonly ns = CAPABILITY_SETTINGS_NAMESPACE;
192
+ readonly expected: number;
193
+ readonly actual: number;
194
+
195
+ constructor(expected: number, actual: number) {
196
+ super(
197
+ `settings namespace "${CAPABILITY_SETTINGS_NAMESPACE}" changed since it was read ` +
198
+ `(expected revision ${String(expected)}, now ${String(actual)})`,
199
+ );
200
+ this.name = "CapabilitySettingsConflictError";
201
+ this.expected = expected;
202
+ this.actual = actual;
203
+ }
204
+ }
205
+
206
+ /** A write refused because no writable settings provider is attached. */
207
+ export class CapabilitySettingsReadOnlyError extends Error {
208
+ readonly code: "SETTINGS_PROVIDER_ABSENT" | "SETTINGS_READ_ONLY" | "SETTINGS_DISPOSED";
209
+ readonly ns = CAPABILITY_SETTINGS_NAMESPACE;
210
+ readonly reason: "absent" | "read-only" | "disposed";
211
+
212
+ constructor(reason: "absent" | "read-only" | "disposed") {
213
+ const code =
214
+ reason === "absent"
215
+ ? "SETTINGS_PROVIDER_ABSENT"
216
+ : reason === "disposed"
217
+ ? "SETTINGS_DISPOSED"
218
+ : "SETTINGS_READ_ONLY";
219
+ const detail =
220
+ reason === "absent"
221
+ ? `settings provider is absent: "${CAPABILITY_SETTINGS_NAMESPACE}" cannot be updated`
222
+ : reason === "disposed"
223
+ ? `settings controller is disposed: "${CAPABILITY_SETTINGS_NAMESPACE}" cannot be updated`
224
+ : `settings provider is read-only: "${CAPABILITY_SETTINGS_NAMESPACE}" cannot be updated in-process`;
225
+ super(detail);
226
+ this.name = "CapabilitySettingsReadOnlyError";
227
+ this.code = code;
228
+ this.reason = reason;
229
+ }
230
+ }
231
+
232
+ export function isCapabilitySettingsConflictError(error: unknown): error is CapabilitySettingsConflictError {
233
+ return error instanceof CapabilitySettingsConflictError;
234
+ }
235
+
236
+ export function isCapabilitySettingsReadOnlyError(error: unknown): error is CapabilitySettingsReadOnlyError {
237
+ return error instanceof CapabilitySettingsReadOnlyError;
238
+ }
239
+
240
+ /** Pick every independently default-off flag from a resolved section. */
241
+ export function capabilityFlags(settings: CapabilitySettings): Pick<CapabilitySettings, CapabilityFlagKey> {
242
+ return {
243
+ codexSearch: settings.codexSearch,
244
+ codexImages: settings.codexImages,
245
+ codexImageEdits: settings.codexImageEdits,
246
+ codexImagesAnyModel: settings.codexImagesAnyModel,
247
+ codexUsage: settings.codexUsage,
248
+ codexFast: settings.codexFast,
249
+ grokImagineImage: settings.grokImagineImage,
250
+ grokImagineVideo: settings.grokImagineVideo,
251
+ };
252
+ }
253
+
254
+ /** Pick the conservative numeric limits from a resolved section. */
255
+ export function capabilityLimits(settings: CapabilitySettings): Pick<CapabilitySettings, CapabilityLimitKey> {
256
+ return {
257
+ searchResults: settings.searchResults,
258
+ imageCount: settings.imageCount,
259
+ videoArtifactTtlMs: settings.videoArtifactTtlMs,
260
+ };
261
+ }
262
+
263
+ /** Layer schema defaults, then YAML/composition `base`, then the user section. */
264
+ export function resolveCapabilitySettings(
265
+ base?: CapabilitySettingsPatch | undefined,
266
+ user?: CapabilitySettingsPatch | undefined,
267
+ ): CapabilitySettings {
268
+ return normalizeCapabilitySettings({
269
+ ...DEFAULT_CAPABILITY_SETTINGS,
270
+ ...normalizeCapabilitySettingsPatch(base),
271
+ ...normalizeCapabilitySettingsPatch(user),
272
+ });
273
+ }
274
+
275
+ /**
276
+ * Admit a candidate section: known keys only, flags default off, limits clamped,
277
+ * secret-shaped keys dropped. Used for both reads and the structural schema.
278
+ */
279
+ export function normalizeCapabilitySettings(input?: unknown): CapabilitySettings {
280
+ const patch = normalizeCapabilitySettingsPatch(input);
281
+ const codexImages = patch.codexImages ?? DEFAULT_CAPABILITY_SETTINGS.codexImages;
282
+ return Object.freeze({
283
+ codexSearch: patch.codexSearch ?? DEFAULT_CAPABILITY_SETTINGS.codexSearch,
284
+ codexImages,
285
+ codexImageEdits: patch.codexImageEdits ?? DEFAULT_CAPABILITY_SETTINGS.codexImageEdits,
286
+ // any-model is meaningless without images; clear at admit so UI cascade alone cannot leave it on
287
+ codexImagesAnyModel: codexImages
288
+ ? (patch.codexImagesAnyModel ?? DEFAULT_CAPABILITY_SETTINGS.codexImagesAnyModel)
289
+ : false,
290
+ codexUsage: patch.codexUsage ?? DEFAULT_CAPABILITY_SETTINGS.codexUsage,
291
+ codexFast: patch.codexFast ?? DEFAULT_CAPABILITY_SETTINGS.codexFast,
292
+ grokImagineImage: patch.grokImagineImage ?? DEFAULT_CAPABILITY_SETTINGS.grokImagineImage,
293
+ grokImagineVideo: patch.grokImagineVideo ?? DEFAULT_CAPABILITY_SETTINGS.grokImagineVideo,
294
+ searchResults: patch.searchResults ?? DEFAULT_CAPABILITY_SETTINGS.searchResults,
295
+ imageCount: patch.imageCount ?? DEFAULT_CAPABILITY_SETTINGS.imageCount,
296
+ videoArtifactTtlMs: patch.videoArtifactTtlMs ?? DEFAULT_CAPABILITY_SETTINGS.videoArtifactTtlMs,
297
+ });
298
+ }
299
+
300
+ /**
301
+ * Normalize a sparse overlay. Invalid or secret fields are omitted so a lower
302
+ * layer (YAML base / schema default) remains authoritative for that key.
303
+ */
304
+ export function normalizeCapabilitySettingsPatch(input?: unknown): CapabilitySettingsPatch {
305
+ if (!isPlainObject(input)) return {};
306
+ const patch: {
307
+ codexSearch?: boolean;
308
+ codexImages?: boolean;
309
+ codexImageEdits?: boolean;
310
+ codexImagesAnyModel?: boolean;
311
+ codexUsage?: boolean;
312
+ codexFast?: boolean;
313
+ grokImagineImage?: boolean;
314
+ grokImagineVideo?: boolean;
315
+ searchResults?: number;
316
+ imageCount?: number;
317
+ videoArtifactTtlMs?: number;
318
+ } = {};
319
+ const flags = input as Record<string, unknown>;
320
+ assignFlag(patch, "codexSearch", flags["codexSearch"]);
321
+ assignFlag(patch, "codexImages", flags["codexImages"]);
322
+ assignFlag(patch, "codexImageEdits", flags["codexImageEdits"]);
323
+ assignFlag(patch, "codexImagesAnyModel", flags["codexImagesAnyModel"]);
324
+ assignFlag(patch, "codexUsage", flags["codexUsage"]);
325
+ assignFlag(patch, "codexFast", flags["codexFast"]);
326
+ assignFlag(patch, "grokImagineImage", flags["grokImagineImage"]);
327
+ assignFlag(patch, "grokImagineVideo", flags["grokImagineVideo"]);
328
+ assignLimit(patch, "searchResults", flags["searchResults"]);
329
+ assignLimit(patch, "imageCount", flags["imageCount"]);
330
+ assignLimit(patch, "videoArtifactTtlMs", flags["videoArtifactTtlMs"]);
331
+ return Object.freeze(patch);
332
+ }
333
+
334
+ /**
335
+ * Strictly admit a caller-authored sparse section before normalizing it. Reads
336
+ * remain compatibility-tolerant, but writes must never silently drop unknown
337
+ * fields, coerce types, truncate decimals, or clamp out-of-range limits.
338
+ */
339
+ export function assertCapabilitySettingsPatch(
340
+ input: unknown,
341
+ label = "capability settings",
342
+ ): asserts input is CapabilitySettingsPatch {
343
+ assertPlainObject(input, label);
344
+ for (const [key, value] of Object.entries(input)) {
345
+ if (!KNOWN_KEYS.has(key)) throw new TypeError(`${label} contains unknown key ${key}`);
346
+ if ((CAPABILITY_FLAG_KEYS as readonly string[]).includes(key)) {
347
+ if (typeof value !== "boolean") throw new TypeError(`${label}.${key} must be a boolean`);
348
+ continue;
349
+ }
350
+ const limitKey = key as CapabilityLimitKey;
351
+ const bounds = CAPABILITY_SETTINGS_BOUNDS[limitKey];
352
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) {
353
+ throw new TypeError(`${label}.${key} must be an integer`);
354
+ }
355
+ if (value < bounds.min || value > bounds.max) {
356
+ throw new TypeError(`${label}.${key} must be in [${String(bounds.min)}, ${String(bounds.max)}]`);
357
+ }
358
+ }
359
+ }
360
+
361
+ /** Reject a resolved section the owner could not act on. Schema-valid by construction after normalize. */
362
+ export function assertServiceableCapabilitySettings(value: CapabilitySettings): void {
363
+ for (const key of CAPABILITY_FLAG_KEYS) {
364
+ if (typeof value[key] !== "boolean") {
365
+ throw new TypeError(`capability settings: ${key} must be a boolean`);
366
+ }
367
+ }
368
+ for (const key of CAPABILITY_LIMIT_KEYS) {
369
+ const bounds = CAPABILITY_SETTINGS_BOUNDS[key];
370
+ const numeric = value[key];
371
+ if (!Number.isFinite(numeric) || !Number.isInteger(numeric) || numeric < bounds.min || numeric > bounds.max) {
372
+ throw new TypeError(
373
+ `capability settings: ${key} must be an integer in [${String(bounds.min)}, ${String(bounds.max)}]`,
374
+ );
375
+ }
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Live capability-settings controller. Without an injected provider the
381
+ * resolved state is the YAML/default layer and every write fails explicitly.
382
+ */
383
+ export class CapabilitySettingsController {
384
+ readonly ns = CAPABILITY_SETTINGS_NAMESPACE;
385
+ private readonly settings: CapabilitySettingsService | undefined;
386
+ private readonly base: CapabilitySettingsPatch;
387
+ private readonly onListenerError: (error: unknown) => void;
388
+ private readonly listeners = new Set<CapabilitySettingsListener>();
389
+ private scope: CapabilitySettingsScope | undefined;
390
+ private scopeDisposer: (() => void) | undefined;
391
+ private localRevision = 0;
392
+ private lastSnapshot: CapabilitySettingsSnapshot;
393
+ private disposed = false;
394
+
395
+ constructor(options: CapabilitySettingsControllerOptions = {}) {
396
+ this.settings = options.settings;
397
+ this.base = normalizeCapabilitySettingsPatch(options.base);
398
+ this.onListenerError = options.onListenerError ?? (() => undefined);
399
+ this.attachScope();
400
+ this.lastSnapshot = this.readSnapshot();
401
+ }
402
+
403
+ /** Current revision-bearing snapshot. Re-reads the injected provider when present. */
404
+ snapshot(): CapabilitySettingsSnapshot {
405
+ const next = this.readSnapshot();
406
+ this.lastSnapshot = next;
407
+ return next;
408
+ }
409
+
410
+ /** Resolved capability section (schema defaults ← YAML base ← user). */
411
+ current(): CapabilitySettings {
412
+ return this.snapshot().value;
413
+ }
414
+
415
+ /**
416
+ * Merge a secret-free patch into the user layer using compare-and-swap on
417
+ * `expectedRevision` from a previously read {@link snapshot}.
418
+ */
419
+ async patch(patch: CapabilitySettingsPatch, expectedRevision: number): Promise<CapabilitySettingsSnapshot> {
420
+ return this.write("update", patch, expectedRevision);
421
+ }
422
+
423
+ /**
424
+ * Replace the user section wholesale (`{}` re-inherits YAML base and defaults).
425
+ * Compare-and-swap uses the same revision token as {@link patch}.
426
+ */
427
+ async replace(section: CapabilitySettingsPatch, expectedRevision: number): Promise<CapabilitySettingsSnapshot> {
428
+ return this.write("replace", section, expectedRevision);
429
+ }
430
+
431
+ /**
432
+ * Observe committed snapshot changes. The disposer removes this listener;
433
+ * an invocation already running still settles.
434
+ */
435
+ subscribe(listener: CapabilitySettingsListener): () => void {
436
+ if (this.disposed) return () => undefined;
437
+ this.listeners.add(listener);
438
+ return () => {
439
+ this.listeners.delete(listener);
440
+ };
441
+ }
442
+
443
+ /**
444
+ * Re-read the injected provider (or the local YAML/default layer) and notify
445
+ * listeners when the secret-free snapshot moved.
446
+ */
447
+ reconcile(): CapabilitySettingsSnapshot {
448
+ const next = this.readSnapshot();
449
+ this.publish(next);
450
+ return this.lastSnapshot;
451
+ }
452
+
453
+ /** Drop the register() watcher and every listener. Further writes fail. */
454
+ dispose(): void {
455
+ if (this.disposed) return;
456
+ this.disposed = true;
457
+ const releaseScope = this.scopeDisposer;
458
+ this.scopeDisposer = undefined;
459
+ this.scope = undefined;
460
+ this.listeners.clear();
461
+ try {
462
+ releaseScope?.();
463
+ } catch (error: unknown) {
464
+ this.onListenerError(error);
465
+ }
466
+ }
467
+
468
+ private attachScope(): void {
469
+ const register = this.settings?.register;
470
+ if (register === undefined) return;
471
+ this.scope = register.call(this.settings, CAPABILITY_SETTINGS_NAMESPACE, CapabilitySettingsSchema, {
472
+ base: this.base,
473
+ applies: "live",
474
+ validate: assertServiceableCapabilitySettings,
475
+ });
476
+ this.scopeDisposer = this.scope.watch(() => {
477
+ if (this.disposed) return;
478
+ this.reconcile();
479
+ });
480
+ }
481
+
482
+ private writeReason(): "absent" | "read-only" | "disposed" | undefined {
483
+ if (this.disposed) return "disposed";
484
+ if (this.settings === undefined) return "absent";
485
+ if (this.settings.writable === false) return "read-only";
486
+ const canWrite =
487
+ typeof this.settings.update === "function" ||
488
+ typeof this.settings.replace === "function" ||
489
+ this.scope !== undefined;
490
+ if (!canWrite) return "read-only";
491
+ return undefined;
492
+ }
493
+
494
+ private isWritable(): boolean {
495
+ return this.writeReason() === undefined;
496
+ }
497
+
498
+ private async write(
499
+ mode: "update" | "replace",
500
+ input: CapabilitySettingsPatch,
501
+ expectedRevision: number,
502
+ ): Promise<CapabilitySettingsSnapshot> {
503
+ const reason = this.writeReason();
504
+ if (reason !== undefined) throw new CapabilitySettingsReadOnlyError(reason);
505
+ assertCapabilitySettingsPatch(input, `capability settings ${mode}`);
506
+ const current = this.readSnapshot();
507
+ if (expectedRevision !== current.revision) {
508
+ throw new CapabilitySettingsConflictError(expectedRevision, current.revision);
509
+ }
510
+ const normalized = normalizeCapabilitySettingsPatch(input);
511
+ if (mode === "update" && !hasOwnKeys(normalized)) return current;
512
+ const settings = this.settings!;
513
+ try {
514
+ if (mode === "update") {
515
+ if (typeof settings.update === "function") {
516
+ await settings.update(CAPABILITY_SETTINGS_NAMESPACE, { ...normalized }, expectedRevision);
517
+ } else {
518
+ await this.scope!.update({ ...normalized });
519
+ }
520
+ } else if (typeof settings.replace === "function") {
521
+ await settings.replace(CAPABILITY_SETTINGS_NAMESPACE, { ...normalized }, expectedRevision);
522
+ } else {
523
+ await this.scope!.replace({ ...normalized });
524
+ }
525
+ } catch (error) {
526
+ throw toConflictError(error) ?? error;
527
+ }
528
+ this.localRevision = current.revision + 1;
529
+ const next = this.readSnapshot();
530
+ this.publish(next);
531
+ return this.lastSnapshot;
532
+ }
533
+
534
+ private readSnapshot(): CapabilitySettingsSnapshot {
535
+ const writable = this.isWritable();
536
+ const described = this.readDescribed();
537
+ const base = described?.base !== undefined ? normalizeCapabilitySettingsPatch(described.base) : this.base;
538
+ const user = described?.user !== undefined ? normalizeCapabilitySettingsPatch(described.user) : undefined;
539
+ const resolved =
540
+ described?.value !== undefined
541
+ ? normalizeCapabilitySettings(described.value)
542
+ : this.readResolvedFromService(base, user);
543
+ const revision =
544
+ typeof described?.revision === "number" && Number.isFinite(described.revision)
545
+ ? described.revision
546
+ : this.localRevision;
547
+ return freezeSnapshot({
548
+ ns: CAPABILITY_SETTINGS_NAMESPACE,
549
+ value: resolved,
550
+ revision,
551
+ writable,
552
+ applies: "live",
553
+ secrets: [],
554
+ ...(hasOwnKeys(base) ? { base } : {}),
555
+ ...(user !== undefined && hasOwnKeys(user) ? { user } : {}),
556
+ });
557
+ }
558
+
559
+ private readResolvedFromService(
560
+ base: CapabilitySettingsPatch,
561
+ user: CapabilitySettingsPatch | undefined,
562
+ ): CapabilitySettings {
563
+ const raw = this.scope?.get() ?? this.settings?.get?.(CAPABILITY_SETTINGS_NAMESPACE);
564
+ if (raw !== undefined) return normalizeCapabilitySettings(raw);
565
+ return resolveCapabilitySettings(base, user);
566
+ }
567
+
568
+ private readDescribed(): CapabilitySettingsDescriptor | undefined {
569
+ const describe = this.settings?.describe;
570
+ if (describe === undefined) return undefined;
571
+ try {
572
+ const descriptors = describe.call(this.settings, { redactSecrets: true });
573
+ if (!Array.isArray(descriptors)) return undefined;
574
+ return descriptors.find((entry) => entry?.ns === CAPABILITY_SETTINGS_NAMESPACE);
575
+ } catch {
576
+ return undefined;
577
+ }
578
+ }
579
+
580
+ private publish(next: CapabilitySettingsSnapshot): void {
581
+ if (sameSnapshot(this.lastSnapshot, next)) {
582
+ this.lastSnapshot = next;
583
+ return;
584
+ }
585
+ this.lastSnapshot = next;
586
+ for (const listener of [...this.listeners]) {
587
+ try {
588
+ const result = listener(next);
589
+ if (result !== undefined) void Promise.resolve(result).catch(this.onListenerError);
590
+ } catch (error) {
591
+ // One broken observer must not starve the rest or the write path.
592
+ this.onListenerError(error);
593
+ }
594
+ }
595
+ }
596
+ }
597
+
598
+ /** Construct a {@link CapabilitySettingsController}. */
599
+ export function createCapabilitySettingsController(
600
+ options: CapabilitySettingsControllerOptions = {},
601
+ ): CapabilitySettingsController {
602
+ return new CapabilitySettingsController(options);
603
+ }
604
+
605
+ function assignFlag<K extends CapabilityFlagKey>(target: { [P in K]?: boolean }, key: K, value: unknown): void {
606
+ if (typeof value === "boolean") target[key] = value;
607
+ }
608
+
609
+ function assignLimit<K extends CapabilityLimitKey>(target: { [P in K]?: number }, key: K, value: unknown): void {
610
+ if (typeof value !== "number" || !Number.isFinite(value)) return;
611
+ const bounds = CAPABILITY_SETTINGS_BOUNDS[key];
612
+ const integer = Math.trunc(value);
613
+ target[key] = Math.min(bounds.max, Math.max(bounds.min, integer));
614
+ }
615
+
616
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
617
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
618
+ const proto = Object.getPrototypeOf(value);
619
+ return proto === Object.prototype || proto === null;
620
+ }
621
+
622
+ function assertPlainObject(value: unknown, label: string): asserts value is Record<string, unknown> {
623
+ if (!isPlainObject(value)) throw new TypeError(`${label} must be a plain object`);
624
+ for (const key of Object.keys(value)) {
625
+ if (RESERVED_KEYS.has(key) || SECRET_KEY.test(key)) {
626
+ throw new TypeError(`${label} must be secret-free (rejected key ${key})`);
627
+ }
628
+ }
629
+ }
630
+
631
+ function hasOwnKeys(value: object): boolean {
632
+ return Object.keys(value).length > 0;
633
+ }
634
+
635
+ function freezeSnapshot(snapshot: CapabilitySettingsSnapshot): CapabilitySettingsSnapshot {
636
+ return Object.freeze(snapshot);
637
+ }
638
+
639
+ function sameSnapshot(left: CapabilitySettingsSnapshot | undefined, right: CapabilitySettingsSnapshot): boolean {
640
+ if (left === undefined) return false;
641
+ return (
642
+ left.revision === right.revision &&
643
+ left.writable === right.writable &&
644
+ deepEqualJson(left.value, right.value) &&
645
+ deepEqualJson(left.base, right.base) &&
646
+ deepEqualJson(left.user, right.user)
647
+ );
648
+ }
649
+
650
+ function deepEqualJson(a: unknown, b: unknown): boolean {
651
+ if (a === b) return true;
652
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
653
+ if (Array.isArray(a) || Array.isArray(b)) {
654
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
655
+ return a.every((entry, index) => deepEqualJson(entry, b[index]));
656
+ }
657
+ const left = a as Record<string, unknown>;
658
+ const right = b as Record<string, unknown>;
659
+ const keys = Object.keys(left);
660
+ if (keys.length !== Object.keys(right).length) return false;
661
+ return keys.every((key) => key in right && deepEqualJson(left[key], right[key]));
662
+ }
663
+
664
+ function toConflictError(error: unknown): CapabilitySettingsConflictError | undefined {
665
+ if (error instanceof CapabilitySettingsConflictError) return error;
666
+ if (typeof error !== "object" || error === null) return undefined;
667
+ const candidate = error as { code?: unknown; expected?: unknown; actual?: unknown };
668
+ if (candidate.code !== "SETTINGS_CONFLICT") return undefined;
669
+ if (typeof candidate.expected !== "number" || typeof candidate.actual !== "number") return undefined;
670
+ return new CapabilitySettingsConflictError(candidate.expected, candidate.actual);
671
+ }