@omnicross/daemon 0.1.0

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.
@@ -0,0 +1,1375 @@
1
+ import { ApiKeyPoolService } from '@omnicross/core/completion/ApiKeyPoolService';
2
+ import { OutboundKeyDb, OutboundApiServer } from '@omnicross/core/outbound-api';
3
+ import { ProviderProxy } from '@omnicross/core/provider-proxy';
4
+ import { SubscriptionCredentialStore, FetchLike, SubscriptionProviderRegistry, SubscriptionAccountService } from '@omnicross/subscriptions';
5
+ import { OutboundApiServerConfig, ProviderConfigSource, TransformerService, Transformer, ResolvedTransformerChain, ApiServerSettingsStore, Logger, OutboundKeyDb as OutboundKeyDb$1, OutboundKeyDbRow } from '@omnicross/core';
6
+ import http from 'node:http';
7
+ import { LLMProvider, AgentDefaultModels, GlobalModelParameters } from '@omnicross/contracts/llm-config';
8
+ import { OpenCodeGoTokenConfig, SubscriptionProviderId } from '@omnicross/contracts/subscription-types';
9
+ import { ClaudeTokenConfig, CodexTokenConfig, GeminiTokenConfig, AccountTokensConfig, SubscriptionAccountSanitized } from '@omnicross/contracts/account-tokens-types';
10
+
11
+ /**
12
+ * SecretBox.ts — a 32-byte master key wrapped with the tri-state secret rules
13
+ * (secrets design D2/D7).
14
+ *
15
+ * `SecretBox` holds the resolved master key (never exposed) and applies the
16
+ * tri-state discrimination on top of the pure `envelope.ts` codec:
17
+ *
18
+ * `$`-prefix → ENV indirection: ALWAYS plaintext, never encrypted (resolved
19
+ * later by the pool's `resolveEnvKey`). Passthrough both ways.
20
+ * `enc:` → ciphertext: decrypted on read, left as-is on write.
21
+ * else → legacy plaintext: passed through on read, encrypted on write.
22
+ *
23
+ * `decryptMaybe` / `encryptMaybe` are the idempotent seam every read/write
24
+ * accessor calls — applying either repeatedly never changes a correctly-typed
25
+ * value (no `enc:enc:` nesting, no double-decrypt). `decrypt` wraps a GCM
26
+ * auth-tag failure (wrong key / tampered envelope) into an actionable,
27
+ * secret-free error — it NEVER echoes the key or the ciphertext.
28
+ *
29
+ * LAZY KEY (secrets design D3 — "首次需要时自动生成"): the constructor accepts
30
+ * either a resolved 32-byte Buffer OR a `() => Buffer` resolver. The key is only
31
+ * resolved on the FIRST `encrypt`/`decrypt` (then cached). The tri-state
32
+ * passthroughs (`$ENV` / empty / non-envelope-on-read) return BEFORE the key is
33
+ * touched — so a pure legacy-plaintext load that only passes values through
34
+ * NEVER triggers key resolution, and therefore never auto-generates a keyfile.
35
+ * A keyfile is materialized only when an `enc:` value must be decrypted or a
36
+ * write must encrypt (the resolver does the lazy auto-gen).
37
+ *
38
+ * @module @omnicross/daemon/secrets/SecretBox
39
+ */
40
+ /** A resolved master key, or a lazy resolver that produces one on first use. */
41
+ type MasterKeyInput = Buffer | (() => Buffer);
42
+ declare class SecretBox {
43
+ /** The raw 32-byte master key (resolved lazily). Held privately; never logged. */
44
+ private key;
45
+ /** The lazy resolver (used once, then nulled after caching the key). */
46
+ private resolver;
47
+ constructor(key: MasterKeyInput);
48
+ /** Resolve (and cache) the master key on first crypto use. Validates length. */
49
+ private getKey;
50
+ /** Encrypt a plaintext value into a fresh `enc:v1:...` envelope (unconditional). */
51
+ encrypt(plain: string): string;
52
+ /**
53
+ * Decrypt an `enc:v1:...` envelope to plaintext. Wraps a GCM verification
54
+ * failure (wrong master key or a tampered envelope) into a clear, actionable
55
+ * error — the original crypto error (which carries no secret material) is
56
+ * intentionally NOT re-surfaced verbatim and the ciphertext/key are never
57
+ * placed in the message.
58
+ */
59
+ decrypt(envelope: string): string;
60
+ /**
61
+ * READ-direction tri-state: decrypt an `enc:` envelope; pass a `$ENV`
62
+ * reference or legacy plaintext through unchanged. Idempotent on any
63
+ * non-envelope value.
64
+ */
65
+ decryptMaybe(value: string): string;
66
+ /**
67
+ * WRITE-direction tri-state: encrypt legacy plaintext; pass a `$ENV` reference
68
+ * (never encrypt indirection) or an already-`enc:` envelope (no `enc:enc:`
69
+ * nesting) through unchanged. Idempotent — re-applying never re-encrypts.
70
+ */
71
+ encryptMaybe(value: string): string;
72
+ }
73
+
74
+ /**
75
+ * config.ts — the daemon's `config.json` schema + load/save (design D9).
76
+ *
77
+ * The daemon is BYO-key and factory-less: its `config.json` provider rows ARE
78
+ * the provider catalog the `ConfigFileProviderConfigSource` serves. The `server`
79
+ * field is the same `OutboundApiServerConfig` shape `loadServerConfig`
80
+ * normalizes (persisted via `JsonApiServerSettingsStore` under the single
81
+ * `'outboundApiServer.config'` key).
82
+ *
83
+ * `loadConfig(path)` reads + shape-guards; `saveConfig(path, cfg)` writes pretty
84
+ * JSON. The loader is defensive (best-effort, never throws on a partial file) so
85
+ * the CLI surfaces a clear error rather than a stack trace.
86
+ *
87
+ * AT-REST ENCRYPTION (secrets design D5/D7): a MODULE-LEVEL `SecretBox` is
88
+ * injected via `setSecretBox` (by bootstrap + each offline CLI command). When
89
+ * set, `loadConfig` decrypts the secret fields (`apiKey`/`apiKeys[].apiKey`/
90
+ * `admin.token`) AFTER the shape-guard (envelopes are strings → shape-guard
91
+ * passes), and `saveConfig` encrypts them before writing. When NOT set (box =
92
+ * null), both are a no-op passthrough — so the existing pure tests that call
93
+ * `loadConfig`/`saveConfig`/`validateConfig` without a box are byte-unchanged.
94
+ * `$ENV` references are never encrypted (the box's tri-state passthrough).
95
+ *
96
+ * @module @omnicross/daemon/config
97
+ */
98
+
99
+ /** The wire formats the daemon's BYO providers can speak. */
100
+ type DaemonApiFormat = 'openai' | 'anthropic' | 'gemini';
101
+ /**
102
+ * One pool key on a provider row (design D1). Structurally compatible with
103
+ * core's `ApiKeyEntry` (`@omnicross/contracts/llm-config`) — a hand-authored SUBSET: only
104
+ * `id` + `apiKey` are required, the rest carry sensible defaults applied at
105
+ * load time (`pool/loadPoolKeys.ts` normalizes a `DaemonApiKeyEntry` up to the
106
+ * full `ApiKeyEntry` core consumes, filling `providerId`/`label`/`weight`/
107
+ * `enabled`/`sortOrder`). config.json should not have to hand-write core's
108
+ * DB/UI fields (`providerId`/`sortOrder`/`hasKey`/`disabledReason`/…).
109
+ */
110
+ interface DaemonApiKeyEntry {
111
+ /** Stable id — the pool's selection / cooldown / auto-disable key. */
112
+ id: string;
113
+ /** The BYO key (literal, or a `$ENV_VAR` reference resolved at call time). */
114
+ apiKey: string;
115
+ /** Display name (defaults to `id`). */
116
+ label?: string;
117
+ /** Whether this key is selectable (defaults to `true`). */
118
+ enabled?: boolean;
119
+ /** Weighted round-robin weight (defaults to `1`). */
120
+ weight?: number;
121
+ }
122
+ /**
123
+ * Per-model metadata subset (app-parity child 2). A hand-authored SUBSET of the
124
+ * app's `ModelConfig` (`app/src/shared-types/llm-config.ts`), carrying ONLY the
125
+ * named-five fields the daemon stores + round-trips, keyed by the model `id`:
126
+ * `name` (display name), `enabled`, `group`, `vision`, `reasoning`. The wider
127
+ * `ModelConfig` fields the discovery flow may send (`category`/`contextLength`/
128
+ * `maxTokens`/`functionCall`/`webSearch`/`completionSettings`/`openRouterProvider`/
129
+ * `thinkingLevels`/…) are NOT in this allowlist — they are DROPPED by
130
+ * deny-by-default (`validateModelConfigs`/`parseModelConfigsInput`).
131
+ *
132
+ * ENFORCEMENT (app-parity-2 child 2): `enabled` is now a DISCOVERY/advertisement
133
+ * gate — `toLLMProvider` drops a `enabled: false` model from the routed provider's
134
+ * `models[]`, so the served catalog no longer lists it. HONEST SCOPE: this gates
135
+ * advertisement, NOT a hard per-request block (core does not validate a requested
136
+ * model against `models[]`, so a hardcoded disabled model id still reaches the
137
+ * upstream, which rejects it). The admin management view (`toProviderView`) still
138
+ * lists ALL models. The other fields (`name`/`group`/`vision`/`reasoning`) remain
139
+ * display-only metadata (no core per-model capability binding on the BYO path).
140
+ */
141
+ interface DaemonModelConfig {
142
+ /** Model id — the metadata key (parallels an entry in the flat `models[]`). */
143
+ id: string;
144
+ /** Display name (display/management only; not consumed by routing). */
145
+ name?: string;
146
+ /** Enable flag — DISCOVERY GATE (parity-2 child 2): `false` drops the model from
147
+ * the routed/advertised catalog; not a hard per-request block. */
148
+ enabled?: boolean;
149
+ /** Display group label (app derives groups from this — no separate daemon array). */
150
+ group?: string;
151
+ /** Vision-capable hint (display only; not consumed by routing). */
152
+ vision?: boolean;
153
+ /** Reasoning-capable hint (display only; not consumed by routing). */
154
+ reasoning?: boolean;
155
+ }
156
+ /**
157
+ * One transformer chain entry (app-parity child 5). Mirrors the app's
158
+ * `TransformerEntry`: either a bare transform-rule NAME (`string`), or a
159
+ * `[name, options]` tuple carrying that rule's options. NON-SECRET (rule names +
160
+ * options — no key material).
161
+ */
162
+ type DaemonTransformerEntry = string | [string, Record<string, unknown>];
163
+ /**
164
+ * Provider transformer config subset (app-parity child 5). Mirrors the app's
165
+ * `TransformerConfig` PROVIDER-LEVEL portion: `use[]` is the provider-level
166
+ * transform chain. The index signature preserves any per-model transformer keys
167
+ * (`[modelName]`) VERBATIM as an opaque value so a round-trip is non-lossy — the
168
+ * minimal editor only edits `use[]`, but stored per-model keys are not dropped.
169
+ *
170
+ * ENFORCED (app-parity-2 child 2): the daemon APPLIES this `use[]` chain in the
171
+ * request pipeline. `ConfigFileProviderConfigSource.resolveTransformerChain`
172
+ * resolves the custom `use[]` into the provider chain, composed FORMAT-FIRST — the
173
+ * format transformer (anthropic/gemini, supplied by `getMainTransformer` and
174
+ * prepended by core's `resolveProviderChain`) runs before the custom transformers,
175
+ * preserving the load-bearing wire-format conversion. An unknown transformer name
176
+ * is warned + skipped (lenient). Additive + back-compat: absent (or empty `use[]`)
177
+ * reads as undefined and resolves to the format transformer alone — byte-identical
178
+ * to before. NON-SECRET — round-trips verbatim on GET (no masking).
179
+ */
180
+ interface DaemonTransformerConfig {
181
+ /** Provider-level transform chain (the UI count + the minimal editor surface). */
182
+ use?: DaemonTransformerEntry[];
183
+ /** Per-model transformer keys preserved verbatim (opaque — not edited here). */
184
+ [modelName: string]: unknown;
185
+ }
186
+ /**
187
+ * Coding-plan endpoint config (app-parity-2 child 3). The provider's "coding-plan"
188
+ * subscription endpoint (e.g. domestic providers' 编程套餐 — Zhipu GLM Coding Plan,
189
+ * DashScope, DeepSeek, Kimi …): an OPTIONAL alternate endpoint with its OWN
190
+ * `baseUrl` + `apiKey`, distinct from the pay-as-you-go API key. Structurally
191
+ * identical to the contracts `CodingPlanConfig` (`@omnicross/contracts/provider-presets`)
192
+ * so a daemon row's `codingPlan` assigns straight onto `LLMProvider.codingPlan`.
193
+ *
194
+ * SECRET-BEARING: `apiKey` is encrypted at rest (registered in
195
+ * `secretFields.transformProvider`) and NEVER serialized back out — the masked GET
196
+ * view returns only a `hasApiKey` boolean.
197
+ *
198
+ * ENFORCED BY CORE, NOT THE DAEMON: the daemon does NOT contain its own endpoint
199
+ * resolver. It only POPULATES `LLMProvider.codingPlan` in `toLLMProvider`; the
200
+ * shared `resolveProviderEndpoint` (`@omnicross/contracts/endpoint-resolver`, layer
201
+ * 2: `apiModes > codingPlan > plain`) — already wired into core's `buildProviderApiUrl`
202
+ * (URL) and the BYO proxy key path — does the actual routing (when `enabled` AND a
203
+ * `baseUrl` is set, the request uses this `baseUrl` + `apiKey`, key falling back to
204
+ * the provider's main key when empty). Additive + back-compat: absent → undefined.
205
+ */
206
+ interface DaemonCodingPlanConfig {
207
+ /** Whether the coding-plan endpoint is active (core routes via baseUrl/apiKey below). */
208
+ enabled: boolean;
209
+ /** Dedicated base URL — when set AND enabled, core overrides the provider's baseUrl. */
210
+ baseUrl?: string;
211
+ /** Dedicated key (SECRET; literal or `$ENV`). Empty → core falls back to the main key. */
212
+ apiKey?: string;
213
+ /** Free-text plan note (display/management only). */
214
+ note?: string;
215
+ }
216
+ /**
217
+ * One API mode (app-parity-2 child 4). The provider's selectable endpoint "mode"
218
+ * (e.g. `standard` / `coding-plan` / `token-plan` for domestic providers): each
219
+ * carries its own `baseUrl` + an OPTIONAL `apiKey`. Structurally identical to the
220
+ * contracts `ApiMode` so a daemon row's `apiModes` assigns straight onto
221
+ * `LLMProvider.apiModes`. SECRET-BEARING: `apiKey` is encrypted at rest
222
+ * (`secretFields`) + masked on GET (`hasApiKey` only) — never serialized out.
223
+ * ENFORCED BY CORE: `toLLMProvider` populates `apiModes`/`selectedApiModeId`; the
224
+ * shared `resolveProviderEndpoint` (layer 1) reports `source:'api-mode'` and uses
225
+ * `api_base_url || mode.baseUrl`. The row's `baseUrl`/`apiKey` hold the EFFECTIVE
226
+ * endpoint (synced on switch — baseUrl app-side, the secret key server-side).
227
+ */
228
+ interface DaemonApiMode {
229
+ /** Stable mode id within the provider. */
230
+ id: string;
231
+ /** i18n key or display label. */
232
+ label: string;
233
+ /** This mode's endpoint base URL. */
234
+ baseUrl: string;
235
+ /** This mode's OPTIONAL key (SECRET; literal or `$ENV`). */
236
+ apiKey?: string;
237
+ /** Optional API-key prefix hint (e.g. `sk-tp-`). Non-secret. */
238
+ apiKeyPrefix?: string;
239
+ /** Optional note (i18n key). Non-secret. */
240
+ note?: string;
241
+ }
242
+ /** One BYO provider row — the unit the `ProviderConfigSource` serves. */
243
+ interface DaemonProviderConfig {
244
+ /** Stable id referenced by the per-endpoint `"<id>,<model>"` model refs. */
245
+ id: string;
246
+ /**
247
+ * OPTIONAL mutable display name (app-parity-2 child 1), SEPARATE from the
248
+ * immutable `id`. The `id` stays the identity key (model refs, pool, accounts);
249
+ * `name` is a free-text label the rename UI edits. Additive + back-compat:
250
+ * absent reads as undefined and the app falls back to displaying the `id`.
251
+ * NON-SECRET — round-trips verbatim on GET (no masking).
252
+ */
253
+ name?: string;
254
+ /** The provider's wire format (drives the transformer chain). */
255
+ apiFormat: DaemonApiFormat;
256
+ /** The upstream base URL (e.g. `https://api.openai.com/v1`). */
257
+ baseUrl: string;
258
+ /** The BYO API key (literal, or a `$ENV_VAR` reference resolved at call time). */
259
+ apiKey: string;
260
+ /** Optional advertised model list (informational; routing uses the model ref). */
261
+ models?: string[];
262
+ /**
263
+ * OPTIONAL per-model metadata (app-parity child 2), keyed by model id, PARALLEL
264
+ * to the flat `models[]`. Additive + back-compat: a row with only `models[]`
265
+ * (no `modelConfigs`) loads unchanged and reads as undefined metadata; the flat
266
+ * `models[]` stays AUTHORITATIVE for the model catalog. ENFORCEMENT (parity-2
267
+ * child 2): `enabled` is a DISCOVERY GATE — `toLLMProvider` drops a
268
+ * `enabled: false` model from the routed `models[]` (advertisement-scoped, not a
269
+ * hard per-request block; the admin view still lists all). `name`/`group`/
270
+ * `vision`/`reasoning` stay display-only. Group lives here as `modelConfigs[].group`
271
+ * (no separate daemon `modelGroups[]`). None of the fields are secrets — verbatim on GET.
272
+ */
273
+ modelConfigs?: DaemonModelConfig[];
274
+ /**
275
+ * OPTIONAL multi-key pool (design D1, key-pool change). When present, the
276
+ * `ApiKeyPoolService` loads these for observable health + (future) failover.
277
+ * **Absent `apiKeys` = single-key behavior byte-identical to before** — the
278
+ * pool synthesizes a 1-key pool from `apiKey` and the outbound take-key path
279
+ * is unchanged. The single `apiKey` field is RETAINED as the fallback.
280
+ */
281
+ apiKeys?: DaemonApiKeyEntry[];
282
+ /**
283
+ * OPTIONAL enable flag (app-foundation D8). When absent, the provider reads as
284
+ * ENABLED (back-compat: existing `config.json` rows with no `enabled` field are
285
+ * treated as enabled). Surfaced on the admin GET DTO (`enabled: row.enabled !==
286
+ * false`) and accepted by the provider write path (PUT). Purely a management-UI
287
+ * concern — the routing/outbound paths do not consume it yet.
288
+ */
289
+ enabled?: boolean;
290
+ /**
291
+ * OPTIONAL "official provider" management flag (app-parity child 1). Additive +
292
+ * back-compat: absent reads as the prior default (undefined). NON-SECRET —
293
+ * round-trips verbatim on GET (no masking). Management-UI only; the
294
+ * routing/outbound paths do not consume it.
295
+ */
296
+ isOfficial?: boolean;
297
+ /**
298
+ * OPTIONAL API version (e.g. an Azure `api-version`) (app-parity child 1).
299
+ * Additive + back-compat: absent reads as the prior default (undefined).
300
+ * NON-SECRET — round-trips verbatim on GET. Management-UI only (not yet consumed
301
+ * by the outbound path).
302
+ */
303
+ apiVersion?: string;
304
+ /**
305
+ * OPTIONAL max-concurrency hint (app-parity child 1). Additive + back-compat:
306
+ * absent reads as the prior default (undefined). NON-SECRET — round-trips
307
+ * verbatim on GET. Management-UI only (not yet enforced by the routing path).
308
+ */
309
+ maxConcurrency?: number;
310
+ /**
311
+ * OPTIONAL custom models endpoint URL (app-parity child 1). Additive +
312
+ * back-compat: absent reads as the prior default (undefined). NON-SECRET —
313
+ * round-trips verbatim on GET. Management-UI only.
314
+ */
315
+ modelsEndpoint?: string;
316
+ /**
317
+ * OPTIONAL provider transformer config (app-parity child 5). Additive +
318
+ * back-compat: absent reads as the prior default (undefined). NON-SECRET —
319
+ * round-trips verbatim on GET (no masking). ENFORCED (parity-2 child 2): the
320
+ * daemon APPLIES the custom `use[]` chain in the request pipeline, FORMAT-FIRST
321
+ * (see `DaemonTransformerConfig`); absent/empty resolves to the format
322
+ * transformer alone (byte-identical to before).
323
+ */
324
+ transformer?: DaemonTransformerConfig;
325
+ /**
326
+ * OPTIONAL coding-plan endpoint (app-parity-2 child 3). SECRET-BEARING (its
327
+ * `apiKey` is encrypted at rest + masked on GET). Populated onto
328
+ * `LLMProvider.codingPlan` by `toLLMProvider`; ENFORCED by core's shared
329
+ * `resolveProviderEndpoint` (the daemon does not resolve endpoints itself).
330
+ * Additive + back-compat: absent reads as undefined.
331
+ */
332
+ codingPlan?: DaemonCodingPlanConfig;
333
+ /**
334
+ * OPTIONAL API modes (app-parity-2 child 4) — selectable endpoint modes. Each
335
+ * mode's `apiKey` is SECRET (encrypted at rest, masked on GET). Populated onto
336
+ * `LLMProvider.apiModes` by `toLLMProvider`; the SELECTED mode drives core's
337
+ * `resolveProviderEndpoint` (layer 1). Additive + back-compat: absent → undefined.
338
+ */
339
+ apiModes?: DaemonApiMode[];
340
+ /** OPTIONAL id of the active API mode (app-parity-2 child 4). */
341
+ selectedApiModeId?: string;
342
+ }
343
+ /**
344
+ * The admin-dashboard config block (RT3 design D8). All fields optional;
345
+ * defaults are applied at read time by `resolveAdminConfig`.
346
+ *
347
+ * SECURITY: `token` is a SEPARATE secret (NOT a named outbound key). It is never
348
+ * serialized back out by any management-API GET — the masking spine treats it
349
+ * like every other secret (in, never out).
350
+ */
351
+ interface DaemonAdminConfig {
352
+ /** Dashboard on by default; `false` opts out (same as `--no-dashboard`). */
353
+ enabled?: boolean;
354
+ /** Admin server port (default 8766; distinct from the 8765 outbound server). */
355
+ port?: number;
356
+ /** Bind `0.0.0.0` (LAN) instead of `127.0.0.1`. Requires `token` (fail-closed). */
357
+ networkBinding?: boolean;
358
+ /** Optional bearer secret; when set every `/admin/*` request must carry it. */
359
+ token?: string;
360
+ }
361
+ /** Resolved admin config with defaults applied (read-time view). */
362
+ interface ResolvedAdminConfig {
363
+ enabled: boolean;
364
+ port: number;
365
+ networkBinding: boolean;
366
+ token: string | undefined;
367
+ }
368
+ /** The default admin port (distinct from the 8765 outbound server). */
369
+ declare const DEFAULT_ADMIN_PORT = 8766;
370
+ /** The full daemon config. */
371
+ interface DaemonConfig {
372
+ providers: DaemonProviderConfig[];
373
+ /** Persisted outbound-API server config (same shape `loadServerConfig` normalizes). */
374
+ server?: OutboundApiServerConfig;
375
+ /** Optional admin-dashboard config (RT3). */
376
+ admin?: DaemonAdminConfig;
377
+ }
378
+ /** Apply defaults to a (possibly absent) admin block: enabled, port 8766,
379
+ * loopback, no token. NOTE: an EXPLICIT `port: 0` is honored as "bind an
380
+ * ephemeral port" (it is NOT coerced to the default); only an absent/undefined
381
+ * port falls back to `DEFAULT_ADMIN_PORT`. */
382
+ declare function resolveAdminConfig(admin: DaemonAdminConfig | undefined): ResolvedAdminConfig;
383
+ /** Validate a parsed config object into a typed `DaemonConfig`. */
384
+ declare function validateConfig(raw: unknown): DaemonConfig;
385
+ /** Read + validate the config.json at `path`. When a `SecretBox` is set, the
386
+ * secret fields are decrypted AFTER shape-guarding (envelopes are strings, so
387
+ * the guard passes); the returned config carries DECRYPTED values. */
388
+ declare function loadConfig(path: string): DaemonConfig;
389
+ /** Write `cfg` to `path` as pretty JSON. When a `SecretBox` is set, the secret
390
+ * fields are encrypted-on-write (legacy plaintext → `enc:v1:`; `$ENV`/already-
391
+ * `enc:` untouched) before serializing. */
392
+ declare function saveConfig(path: string, cfg: DaemonConfig): void;
393
+
394
+ /**
395
+ * JsonSubscriptionCredentialStore — the daemon's file-backed
396
+ * `SubscriptionCredentialStore` port impl (design D1).
397
+ *
398
+ * Implements `@omnicross/subscriptions`' narrow six-method credential surface
399
+ * over a sibling `tokens.json` holding an `AccountTokensConfig`-shaped object
400
+ * (`{ claude?, codex?, gemini?, opencodego?, updatedAt }`). Modeled on
401
+ * `JsonOutboundKeyDb`: the constructor takes the path; reads are
402
+ * `existsSync` → `readFileSync` → `JSON.parse`, tolerating a missing/corrupt
403
+ * file by returning a minimal `{ updatedAt }` config (the strategies already
404
+ * guard `?.accessToken`, so a partial/empty config never crashes dispatch).
405
+ *
406
+ * The PORT surface is read-only by design: the codex / gemini strategies pull
407
+ * their access token via `getFullConfig().<provider>.accessToken`; only claude /
408
+ * opencodego have dedicated getters. No OAuth login flow is initiated here
409
+ * (strategies only consume + refresh, never log in).
410
+ *
411
+ * DAEMON-ONLY WRITE PATH (token-paste, design D1): `writeProviderTokens` /
412
+ * `clearProvider` are CONCRETE-CLASS methods — NOT part of the
413
+ * `SubscriptionCredentialStore` port. The registry / auth strategies / account
414
+ * service never see them (they hold the port type), so a mutation can never leak
415
+ * into the subscription block. Only the daemon admin API (which holds the
416
+ * concrete instance via `Daemon.credentialStore`) calls them. They read-merge a
417
+ * single provider block into `tokens.json` and re-persist; since `readConfig`
418
+ * re-reads on every call (NO cache), the next read immediately sees the write.
419
+ *
420
+ * AT-REST ENCRYPTION (secrets design D6/D7): the constructor takes a `SecretBox`.
421
+ * `readConfig` decrypts the token-material fields on read (so every getter +
422
+ * `getFullConfig` returns PLAINTEXT tokens — the subscription bearer path is
423
+ * byte-identical), and `persist` encrypts them before writing. Because EVERY
424
+ * write funnels through `persist`, the OAuth-refresh writes below are encrypted
425
+ * at-rest with NO extra work (the store API guarantees it). The "re-read on every
426
+ * call, no cache" semantics are unchanged.
427
+ *
428
+ * REAL TOKEN REFRESH (oauth design D4): `refresh{Claude,Codex,Gemini}Token` mint
429
+ * a new access token via the shared host-clean OAuth refresh functions
430
+ * (`@omnicross/subscriptions/oauth`, injected `FetchLike` — default global
431
+ * `fetch`), then read-merge the refreshed fields into the provider block and
432
+ * write back through `persist` (→ encrypted). Field-writes:
433
+ * claude/codex write access+refresh(+codex idToken)
434
+ * +expiresAt+status:authorized+lastRefreshedAt; gemini writes ONLY access+
435
+ * expiresAt (its refresh response omits refresh_token → the OLD value is reused,
436
+ * never overwritten). On any failure the block is marked `status:'expired'` +
437
+ * errorMessage and `false` is returned. When the block has NO refresh_token
438
+ * (claude setup-token, manual token), it is an HONEST `false` BEFORE any upstream
439
+ * call — the block is not touched and no refresh_token is invented.
440
+ *
441
+ * @module @omnicross/daemon/ports/JsonSubscriptionCredentialStore
442
+ */
443
+
444
+ /**
445
+ * The per-provider token block accepted by `writeProviderTokens`. Mirrors the
446
+ * `AccountTokensConfig` per-provider field types (one of the four contract token
447
+ * shapes), keyed by `SubscriptionProviderId` — the daemon admin layer validates
448
+ * the wire body to one of these before calling the writer.
449
+ */
450
+ type SubscriptionTokenBlock = ClaudeTokenConfig | CodexTokenConfig | GeminiTokenConfig | OpenCodeGoTokenConfig;
451
+ declare class JsonSubscriptionCredentialStore implements SubscriptionCredentialStore {
452
+ private readonly tokensPath;
453
+ private readonly box;
454
+ private readonly fetchImpl;
455
+ /**
456
+ * @param tokensPath on-disk `tokens.json` location.
457
+ * @param box at-rest `SecretBox` (encrypt-on-write / decrypt-on-read).
458
+ * @param fetchImpl injectable HTTP port for the OAuth refresh round-trips
459
+ * (oauth design D4). Defaults to the global `fetch` so boot
460
+ * is unchanged; tests inject a mock fetch. NOT used by any
461
+ * read/write path — only by `refresh*Token`.
462
+ */
463
+ constructor(tokensPath: string, box: SecretBox, fetchImpl?: FetchLike);
464
+ /** Full parsed account-tokens config (or a minimal `{ updatedAt }` when the
465
+ * file is absent/corrupt). This is the hot read — the codex / gemini auth
466
+ * strategies pull `accessToken` / `expiresAt` / `status` from it. */
467
+ getFullConfig(): Promise<AccountTokensConfig>;
468
+ /** Current Claude OAuth access token, or `null` when none is stored. No inline
469
+ * refresh here — the lead-window / 401-retry refresh is driven by the
470
+ * subscription auth strategy, which calls `refreshClaudeToken` (now real). */
471
+ getValidClaudeAccessToken(): Promise<string | null>;
472
+ /** Current OpenCodeGo static API key, or `null` when none is stored. */
473
+ getValidOpenCodeGoApiKey(): Promise<string | null>;
474
+ /**
475
+ * DAEMON-ONLY sanitized accounts list (design D8, NOT on the port). Projects
476
+ * each provider's accounts to the secret-free `SubscriptionAccountSanitized`
477
+ * shape (id/label/status/expiresAt/hasAccessToken/isActive) — NEVER a token.
478
+ * Used by the admin accounts GET (secret-IN-never-OUT).
479
+ */
480
+ listSanitizedAccounts(): Promise<Record<string, SubscriptionAccountSanitized[]>>;
481
+ /**
482
+ * Refresh the Claude OAuth access token (oauth design D4). HONEST `false` when
483
+ * the block has no refresh_token (setup-token / manual) — no upstream call, the
484
+ * block is untouched. Otherwise mint via the shared claude refresh flow and
485
+ * write back access+refresh+expiresAt+status:authorized+lastRefreshedAt.
486
+ * On failure → status:expired +
487
+ * errorMessage → `false`.
488
+ */
489
+ refreshClaudeToken(): Promise<boolean>;
490
+ /**
491
+ * Refresh the Codex (ChatGPT) OAuth access token. Same shape
492
+ * as claude, additionally writing back the refreshed `idToken`.
493
+ * HONEST `false` when no refresh_token.
494
+ */
495
+ refreshCodexToken(): Promise<boolean>;
496
+ /**
497
+ * Refresh the Gemini (Google) OAuth access token. The Google
498
+ * refresh response does NOT return a refresh_token, so this writes ONLY
499
+ * access+expiresAt (+status/lastRefreshedAt) and DELIBERATELY leaves the
500
+ * existing `refreshToken` untouched (overwriting it with `undefined` would
501
+ * destroy the ability to refresh again). HONEST `false` when no refresh_token.
502
+ */
503
+ refreshGeminiToken(): Promise<boolean>;
504
+ /**
505
+ * Materialize a lazily-synthesized account id to disk (design D3). On a legacy
506
+ * single-slot file, `readConfig` synthesizes a NON-deterministic account id
507
+ * per read; without persisting it, the later write-back (which re-reads) would
508
+ * synthesize a DIFFERENT id and miss the captured account. Persisting the
509
+ * migrated config here makes the id durable so the write-back keys correctly.
510
+ * Idempotent: a config whose ids are already on disk re-persists byte-equal.
511
+ */
512
+ private materializeMigration;
513
+ /**
514
+ * Write refreshed tokens back to the captured account by id (oauth design D4),
515
+ * re-derive the mirror from the CURRENT active id, re-stamp + persist. A switch
516
+ * mid-refresh leaves the refreshed tokens in the captured (now non-active)
517
+ * account and keeps the CURRENT active account's tokens in the mirror.
518
+ */
519
+ private writeBackById;
520
+ /**
521
+ * Mark the captured account `status:'expired'` + errorMessage on a refresh
522
+ * failure, keyed by id, then re-derive the mirror.
523
+ */
524
+ private markExpiredById;
525
+ /**
526
+ * DAEMON-ONLY WRITE (design D1, NOT on the port). Read-merge the given
527
+ * provider's token block into the current `AccountTokensConfig`, stamp a fresh
528
+ * `updatedAt`, and re-persist `tokens.json` as pretty JSON. Preserves every
529
+ * OTHER provider's existing block (read-merge-write, not overwrite). Reuses the
530
+ * tolerate-on-read base (`{ updatedAt: '' }` when the file is absent/corrupt),
531
+ * so a first-ever write still produces a valid config. No cache → the next read
532
+ * sees this write.
533
+ */
534
+ writeProviderTokens(providerId: SubscriptionProviderId, config: SubscriptionTokenBlock): Promise<void>;
535
+ /**
536
+ * DAEMON-ONLY login append (design D5, NOT on the port). Append a NEW account
537
+ * (optional label) and set it active, then re-derive the mirror — used by
538
+ * `omnicross login <provider> --label` to add an account instead of overwriting.
539
+ */
540
+ appendProviderAccount(providerId: SubscriptionProviderId, config: SubscriptionTokenBlock, label?: string): Promise<{
541
+ id: string;
542
+ }>;
543
+ /**
544
+ * DAEMON-ONLY active switch (design D5, NOT on the port). Switch the active
545
+ * account for a provider; rejects an unknown id. Re-derives the mirror.
546
+ */
547
+ setActiveAccount(providerId: SubscriptionProviderId, id: string): Promise<{
548
+ ok: boolean;
549
+ }>;
550
+ /**
551
+ * DAEMON-ONLY per-account remove (design D5, NOT on the port). Remove one
552
+ * account; promote the most-recent remaining on active-removal (or clear the
553
+ * mirror when none remain). Re-derives the mirror.
554
+ */
555
+ removeAccount(providerId: SubscriptionProviderId, id: string): Promise<{
556
+ removed: boolean;
557
+ }>;
558
+ /**
559
+ * DAEMON-ONLY CLEAR (design D1/D3, NOT on the port). Remove a single provider's
560
+ * block from `tokens.json` and re-persist (the strategies already tolerate an
561
+ * absent block). Stamps a fresh `updatedAt`. A no-op-shaped write when the
562
+ * provider was already absent (still re-stamps + persists).
563
+ */
564
+ clearProvider(providerId: SubscriptionProviderId): Promise<void>;
565
+ /** Write the merged config to disk as pretty JSON (mkdir parent if needed).
566
+ * Encrypt-on-write: the token-material fields are encrypted (legacy plaintext
567
+ * → `enc:v1:`; already-`enc:`/`$ENV` untouched) before serializing, so any
568
+ * write — incl. child 4's future refresh writes — lands encrypted. */
569
+ private persist;
570
+ /**
571
+ * Read + parse `tokens.json`, tolerating a missing/corrupt file, then DECRYPT
572
+ * the token-material fields so every getter returns plaintext (the
573
+ * subscription bearer path is byte-identical).
574
+ *
575
+ * The fs-read + JSON-parse tolerance is INSIDE the try (a missing or corrupt
576
+ * file → empty `{ updatedAt: '' }`). The DECRYPT runs OUTSIDE the try, so a
577
+ * wrong/missing master key or a tampered `enc:` envelope FAILS FAST with the
578
+ * box's clear, secret-free error (secrets spec "错误密钥 / 篡改的解密失败 UX":
579
+ * SHALL fail-fast, SHALL NOT 静默降级 — a swallowed decrypt would report "no
580
+ * tokens" and silently send the WRONG bearer upstream → 401). Mirrors
581
+ * `config.ts loadConfig`, which decrypts outside its parse try.
582
+ */
583
+ private readConfig;
584
+ }
585
+
586
+ /**
587
+ * accountsWrite — the daemon admin API's subscription-token WRITE path
588
+ * (`PUT|POST|DELETE /admin/api/accounts/:providerId`, design D1/D3/D4/D5).
589
+ *
590
+ * Extracted from `adminApi.ts` (file-size discipline, design D6) so the write
591
+ * handler + the per-provider body validators live in one focused module.
592
+ *
593
+ * SECRET SPINE (the load-bearing invariant): the token flows IN via the POST/PUT
594
+ * body and NEVER OUT. The write/clear response is STATUS-ONLY — the token-free
595
+ * `SubscriptionListEntry` for that provider (or a `{ ok: true }` ack). The handler
596
+ * NEVER serializes the request body (or any token field) back. The least-authority
597
+ * `SubscriptionTokenWriter` dep exposes ONLY `writeProviderTokens` / `clearProvider`,
598
+ * so this layer is structurally unable to read a stored token.
599
+ *
600
+ * @module @omnicross/daemon/admin/accountsWrite
601
+ */
602
+
603
+ /**
604
+ * Least-authority writer handle (design D4): the admin write path sees ONLY the
605
+ * mutation methods of the credential store, never a token-returning read. Wired
606
+ * in `bootstrap.ts` from the concrete `daemon.credentialStore`.
607
+ */
608
+ interface SubscriptionTokenWriter {
609
+ writeProviderTokens(providerId: SubscriptionProviderId, config: SubscriptionTokenBlock): Promise<void>;
610
+ clearProvider(providerId: SubscriptionProviderId): Promise<void>;
611
+ setActiveAccount(providerId: SubscriptionProviderId, id: string): Promise<{
612
+ ok: boolean;
613
+ }>;
614
+ removeAccount(providerId: SubscriptionProviderId, id: string): Promise<{
615
+ removed: boolean;
616
+ }>;
617
+ listSanitizedAccounts(): Promise<Record<string, SubscriptionAccountSanitized[]>>;
618
+ }
619
+
620
+ /**
621
+ * oauthSessions — the in-memory pending-OAuth-session store for the admin HTTP
622
+ * two-phase login (`POST /accounts/:providerId/oauth/{start,complete}`,
623
+ * app-parity child 4, design D1).
624
+ *
625
+ * `start` mints a crypto-random `sessionId` and stashes the per-session PKCE
626
+ * `{ providerId, codeVerifier, state }` here; `complete` does a SINGLE-USE
627
+ * `take(sessionId)` (returns + deletes) and exchanges the code. The map is
628
+ * NEVER serialized to the client — only the opaque `sessionId` + the public
629
+ * `authUrl` cross the wire. Sessions are short-lived (OQ3 = 10-min TTL); a sweep
630
+ * reaps abandoned sessions, and `take` re-checks the TTL so an expired-but-not-
631
+ * yet-swept session is still rejected. A daemon restart simply drops in-flight
632
+ * logins (correct fail-safe — no partial token is ever written).
633
+ *
634
+ * SECRET SPINE: the `codeVerifier` is a PKCE secret-ish value (useless without
635
+ * the matching `code`); it never leaves this module. No token is ever stored
636
+ * here — the exchanged token lands ONLY through the encrypted credential store.
637
+ *
638
+ * @module @omnicross/daemon/admin/oauthSessions
639
+ */
640
+
641
+ /** One pending OAuth session (NEVER serialized to the client). */
642
+ interface PendingOAuthSession {
643
+ readonly providerId: SubscriptionProviderId;
644
+ /** PKCE verifier — secret-ish; stays daemon-side, never echoed. */
645
+ readonly codeVerifier: string;
646
+ /** CSRF state minted with the auth params (validated on complete). */
647
+ readonly state: string;
648
+ /** Epoch ms the session was created (for the TTL sweep + take re-check). */
649
+ readonly createdAt: number;
650
+ }
651
+ /**
652
+ * Module-scoped store for pending OAuth sessions. A single instance is created
653
+ * per daemon (in `bootstrap.ts`) and wired through `AdminApiDeps`. The TTL sweep
654
+ * runs lazily on each `put`/`take` (no background timer to leak across tests).
655
+ */
656
+ declare class OAuthSessionStore {
657
+ private readonly ttlMs;
658
+ private readonly sessions;
659
+ constructor(ttlMs?: number);
660
+ /**
661
+ * Mint a fresh opaque `sessionId`, stash the pending session, and return the
662
+ * id. Sweeps expired entries first so the map never grows unbounded.
663
+ */
664
+ put(session: Omit<PendingOAuthSession, 'createdAt'>): string;
665
+ /**
666
+ * SINGLE-USE consume: return + delete the session for `sessionId`, or `null`
667
+ * when it is unknown, already used, or past its TTL (in which case it is
668
+ * dropped). A `null` return means the completer must reject (no exchange, no
669
+ * write).
670
+ */
671
+ take(sessionId: string): PendingOAuthSession | null;
672
+ /** Drop every session past its TTL. Called on each put/take. */
673
+ private sweep;
674
+ }
675
+
676
+ /**
677
+ * accountsOAuth — the daemon admin API's INTERACTIVE OAuth login path
678
+ * (`POST /admin/api/accounts/:providerId/oauth/{start,complete}`, app-parity
679
+ * child 4, design D1/D2/D4).
680
+ *
681
+ * EXPOSES the existing `@omnicross/subscriptions/oauth` flow over admin HTTP as a
682
+ * two-phase pair — it does NOT rebuild PKCE / token-exchange. `start` builds the
683
+ * provider's authorize params and stashes the per-session `{ codeVerifier, state }`
684
+ * in the `OAuthSessionStore` keyed by a minted opaque `sessionId`, returning ONLY
685
+ * `{ authUrl, sessionId }` (the `authUrl` carries client_id + PKCE challenge +
686
+ * state — all public). `complete` does a SINGLE-USE `take(sessionId)`, validates
687
+ * state (claude's `code#state`), `exchangeCodeForTokens(...)`, persists the minted
688
+ * token through the encrypted credential store (`appendProviderAccount`) + marks
689
+ * it active, and responds ONLY the sanitized `SubscriptionListEntry`.
690
+ *
691
+ * SECRET SPINE (the load-bearing invariant): the minted access/refresh token
692
+ * NEVER appears in any response body or log; the `codeVerifier` / session map is
693
+ * never serialized; error messages reference the session/provider, never a token.
694
+ * At-rest encryption is inherited (the store's `SecretBox` → `enc:` envelope).
695
+ *
696
+ * OAuth-capable here = `claude` / `gemini` (code-paste). `codex` (loopback) is
697
+ * DEFERRED (OQ1) and `opencodego` is manual-only — both are rejected by `start`
698
+ * as oauth-unsupported (defensive; their app Sign-in stays `<Unbacked>`).
699
+ *
700
+ * @module @omnicross/daemon/admin/accountsOAuth
701
+ */
702
+
703
+ /**
704
+ * NARROW append handle (design D2-a): the OAuth complete handler legitimately
705
+ * needs `appendProviderAccount` (multi-account append + activate) — a method NOT
706
+ * on the least-authority `SubscriptionTokenWriter`. Rather than widen that
707
+ * deliberate security boundary, `AdminApiDeps` carries this minimal interface
708
+ * (NOT the full read-capable store, so no token-returning read is reachable).
709
+ * Structurally satisfied by the concrete `JsonSubscriptionCredentialStore`.
710
+ */
711
+ interface SubscriptionAccountAppender {
712
+ appendProviderAccount(providerId: SubscriptionProviderId, config: SubscriptionTokenBlock, label?: string): Promise<{
713
+ id: string;
714
+ }>;
715
+ }
716
+
717
+ /**
718
+ * accountsCodexOAuth — the daemon admin API's CODEX interactive OAuth path
719
+ * (`POST /accounts/codex/oauth/start` + `GET /accounts/codex/oauth/:sessionId/status`,
720
+ * app-parity-2 child 5).
721
+ *
722
+ * Codex differs from claude/gemini (which are CODE-PASTE, handled by
723
+ * `accountsOAuth.ts`): its redirect is a LOOPBACK to `http://localhost:1455/auth/callback`,
724
+ * so there is no code to paste — the browser hits the daemon's loopback listener
725
+ * directly. This makes the flow ASYNC + POLLED rather than two-phase paste:
726
+ * - `start` builds the codex authorize params, ARMS the one-shot loopback
727
+ * listener (`awaitLoopbackCode`, injected for tests), kicks the
728
+ * capture→exchange→persist off ASYNC (fire-and-forget), and returns ONLY
729
+ * `{ authUrl, sessionId }` (public — client_id + PKCE challenge + state).
730
+ * - the app opens `authUrl`; the browser redirects to the loopback; the daemon
731
+ * captures the `code`, validates `state`, `exchangeCodeForTokens`, and persists
732
+ * the minted token through the encrypted credential store (`appendProviderAccount`).
733
+ * - the app POLLS `status` until `done` / `error`, then refreshes `/accounts`.
734
+ *
735
+ * SECRET SPINE (the load-bearing invariant): the minted access/refresh/id token
736
+ * NEVER crosses to the client — it lands ONLY in the encrypted store. The poll
737
+ * `status` body is TOKEN-FREE (`{ state, message? }`); error messages reference the
738
+ * loopback/exchange failure, never a token. The PKCE `codeVerifier` stays in this
739
+ * module's closure (never serialized). Port 1455 is a single resource → only ONE
740
+ * codex sign-in may be in flight at a time (a second `start` → 409).
741
+ *
742
+ * REUSES the existing `@omnicross/subscriptions` codex flow + the CLI's
743
+ * `awaitLoopbackCode` listener — it does NOT rebuild PKCE / token-exchange / the
744
+ * loopback server.
745
+ *
746
+ * @module @omnicross/daemon/admin/accountsCodexOAuth
747
+ */
748
+
749
+ /** The loopback-listener fn (injected so tests need not bind a real port). */
750
+ type CodexLoopbackFn = (state: string, timeoutMs?: number) => Promise<string>;
751
+ /** One codex sign-in flow's polled status (NEVER carries a token). */
752
+ interface CodexFlowState {
753
+ status: 'pending' | 'done' | 'error';
754
+ /** Loopback/exchange failure reason (NEVER a token). Present only on 'error'. */
755
+ error?: string;
756
+ createdAt: number;
757
+ }
758
+ /**
759
+ * In-memory store for the async codex sign-in flows. A single instance per daemon
760
+ * (wired in `bootstrap.ts`). Tracks per-session status for the poll + a single
761
+ * `activeSessionId` (port 1455 is one resource → one in-flight login at a time).
762
+ * Never serialized; a daemon restart drops in-flight logins (fail-safe).
763
+ */
764
+ declare class CodexOAuthSessionStore {
765
+ private readonly ttlMs;
766
+ private readonly sessions;
767
+ private activeSessionId;
768
+ constructor(ttlMs?: number);
769
+ /** Whether a codex sign-in is currently in flight (port 1455 held). */
770
+ isBusy(): boolean;
771
+ /** Mint a fresh sessionId, mark it pending + active, return the id. */
772
+ begin(): string;
773
+ /** Settle a flow (done/error) + free the active slot. */
774
+ settle(sessionId: string, status: 'done' | 'error', error?: string): void;
775
+ /** Read a flow's status (token-free), or null when unknown/expired. */
776
+ get(sessionId: string): CodexFlowState | null;
777
+ /** Drop expired flows; free the active slot if the active flow expired. */
778
+ private sweep;
779
+ }
780
+
781
+ /**
782
+ * autoDisableStore.ts — the daemon's PROCESS-IN-MEMORY auto-disable store.
783
+ *
784
+ * A DB-backed embedder can persist 401/403 auto-disable durably so a UI can
785
+ * render per-key health. The daemon has no DB, and its only
786
+ * durable layer is `config.json` — but writing auto-disable back there in v1
787
+ * would (1) cause write amplification under a 401 storm and (2) collide with
788
+ * the at-rest encryption schema that owns the `apiKeys[]` on-disk
789
+ * format. So v1 records auto-disable IN MEMORY only:
790
+ * - `markAutoDisabled(keyId, status, at)` records `{ status, at, reason }`,
791
+ * - `isDisabled(keyId)` / `get(keyId)` read it back,
792
+ * - `loadPoolKeys` reads this store and flips a flagged key's `enabled` to
793
+ * `false`, so `getAvailableKeys` skips it within this process lifetime.
794
+ *
795
+ * Restart resets the store (the honest v1 boundary — see spec). Persistence
796
+ * (encrypted write-back) is a child-3 follow-up.
797
+ *
798
+ * @module @omnicross/daemon/pool/autoDisableStore
799
+ */
800
+ /** One in-memory auto-disable record for a pool key. */
801
+ interface AutoDisableRecord {
802
+ /** The HTTP status that triggered the disable (401/403). */
803
+ status: number;
804
+ /** Epoch-ms when the disable was recorded. */
805
+ at: number;
806
+ /** Always `'auth_failure'` in v1 (the only auto-disable trigger). */
807
+ reason: 'auth_failure';
808
+ }
809
+ /**
810
+ * A process-lifetime in-memory store of auto-disabled pool keys, keyed by the
811
+ * pool key id. Constructed once in `buildDaemon` and injected as the pool's
812
+ * `disableKey` / `markAutoDisabled` sinks AND read by `loadPoolKeys`.
813
+ */
814
+ declare class AutoDisableStore {
815
+ private readonly records;
816
+ /** Record (or overwrite) an auth-failure auto-disable for `keyId`. */
817
+ markAutoDisabled(keyId: string, status: number, at: number): void;
818
+ /** Whether `keyId` is currently auto-disabled in this process. */
819
+ isDisabled(keyId: string): boolean;
820
+ /** Read the auto-disable record for `keyId`, or `undefined` when healthy. */
821
+ get(keyId: string): AutoDisableRecord | undefined;
822
+ /** Clear all records (tests / teardown). */
823
+ clear(): void;
824
+ }
825
+
826
+ /**
827
+ * ConfigFileProviderConfigSource — the daemon's file-backed `ProviderConfigSource`
828
+ * port impl: an embedder of `@omnicross/core`'s provider catalog port.
829
+ *
830
+ * The daemon's `config.json` provider rows ARE the catalog. Of the port's ten
831
+ * methods, FOUR are real (the ones the BYO proxy/outbound path actually hits):
832
+ * - `getProvider(id)` — map a `DaemonProviderConfig` row to an `LLMProvider`.
833
+ * - `getTransformerService()` — a single `TransformerService` seeded by
834
+ * `registerBuiltinTransformers` in the ctor.
835
+ * - `getMainTransformer(id)` — the transformer for the provider's target wire
836
+ * format (anthropic → AnthropicTransformer, gemini → GeminiTransformer,
837
+ * openai → null/identity), mirroring the host `AgentModelsManager` switch.
838
+ * `resolveProviderChain` unshifts this FORMAT-FIRST into the provider chain.
839
+ * - `resolveTransformerChain(id, model)` — the provider's CUSTOM
840
+ * `transformer.use[]` chain (app-parity-2 child 2: ENFORCED). The format
841
+ * transformer is NOT included here (getMainTransformer supplies it,
842
+ * format-first); no `transformer.use[]` → empty chain.
843
+ *
844
+ * The remaining SIX are minimal sensible stubs (never hit on the BYO single-key
845
+ * path — the boot smoke test is the proof).
846
+ *
847
+ * @module @omnicross/daemon/ports/ConfigFileProviderConfigSource
848
+ */
849
+
850
+ declare class ConfigFileProviderConfigSource implements ProviderConfigSource {
851
+ private readonly providers;
852
+ private readonly transformerService;
853
+ /**
854
+ * Optional reload-hook (key-pool change, design D4). A no-type-coupling
855
+ * callback invoked at the END of `reload(...)`. `buildDaemon` injects
856
+ * `() => pool.invalidateCache()` so the `ApiKeyPoolService.keyCache` is
857
+ * flushed after a hot-reload swaps the catalog — WITHOUT this port ever
858
+ * importing/depending on `ApiKeyPoolService`. Absent = no-op (single-key
859
+ * boots that never construct a pool stay byte-identical).
860
+ */
861
+ private reloadHook;
862
+ constructor(config: DaemonConfig);
863
+ /**
864
+ * Register a callback fired after every `reload(...)`. Used by `buildDaemon`
865
+ * to invalidate the pool's keyCache on a hot-reload. The port stays ignorant
866
+ * of what the callback does (no pool type dependency).
867
+ */
868
+ setReloadHook(fn: () => void): void;
869
+ /**
870
+ * Read the live (post-reload) provider row for `providerId`, or `undefined`.
871
+ * Exposed so the pool's `loadKeys` reads the SAME live catalog Map this port
872
+ * serves (so a hot-reload is observed on the next load after `invalidateCache`).
873
+ */
874
+ getProviderRow(providerId: string): DaemonProviderConfig | undefined;
875
+ /** Await the built-in transformer registration (tests await this before dispatch). */
876
+ ready(): Promise<void>;
877
+ /**
878
+ * Replace the live provider catalog in place (additive — does NOT touch the
879
+ * ten port methods, the seeded `TransformerService`, or `ready()`). Called by
880
+ * the admin API after a provider POST/PUT/DELETE persists `config.json`, so the
881
+ * next outbound request sees the new catalog WITHOUT a daemon restart. The Map
882
+ * swap is synchronous; an in-flight request keeps its already-resolved
883
+ * provider (no locking needed for a single-operator local daemon).
884
+ */
885
+ reload(config: DaemonConfig): void;
886
+ /** Clear + repopulate the private providers Map from a fresh provider list. */
887
+ setProviders(providers: readonly DaemonProviderConfig[]): void;
888
+ getProvider(id: string): Promise<LLMProvider | null>;
889
+ getTransformerService(): TransformerService | undefined;
890
+ getMainTransformer(providerId: string): Promise<Transformer | null>;
891
+ resolveTransformerChain(providerId: string, _model?: string): Promise<ResolvedTransformerChain>;
892
+ resolveRoutedModel(): Promise<null>;
893
+ resolveEffectiveModels(): Promise<{
894
+ background?: string;
895
+ vision?: string;
896
+ }>;
897
+ getAgentDefaultModels(): Promise<AgentDefaultModels>;
898
+ hasVisionCapability(): Promise<boolean>;
899
+ getGlobalModelParameters(): Promise<GlobalModelParameters>;
900
+ getDiscoveredModelMaxTokens(): Promise<number | undefined>;
901
+ }
902
+
903
+ /**
904
+ * JsonApiServerSettingsStore — the daemon's file-backed `ApiServerSettingsStore`
905
+ * port impl.
906
+ *
907
+ * The serving core persists the outbound-API server config (`{ enabled,
908
+ * networkBinding, endpoints, port }`) under a SINGLE settings key
909
+ * (`OUTBOUND_API_SERVER_CONFIG_KEY === 'outboundApiServer.config'`). Here that
910
+ * store is the daemon's `config.json` `server`
911
+ * field. `loadServerConfig(store)` / `saveServerConfig(store, cfg)` (core)
912
+ * normalize + persist through this 2-method surface.
913
+ *
914
+ * Only the one outbound-API key is ever read/written — any other key is a no-op
915
+ * miss (returns `undefined`) so the surface stays honest about what it backs.
916
+ *
917
+ * @module @omnicross/daemon/ports/JsonApiServerSettingsStore
918
+ */
919
+
920
+ declare class JsonApiServerSettingsStore implements ApiServerSettingsStore {
921
+ private readonly configPath;
922
+ constructor(configPath: string);
923
+ get<T = unknown>(key: string): Promise<T | undefined>;
924
+ set<T = unknown>(key: string, value: T): Promise<void>;
925
+ /** Read the config.json, tolerating a missing/corrupt file (→ empty shape). */
926
+ private readFile;
927
+ }
928
+
929
+ /**
930
+ * migration.ts — the export gather + import apply logic for the passphrase pack
931
+ * (app-parity child 6, design D2/D3/D5).
932
+ *
933
+ * EXPORT (`gatherExport`): read the FULL local state DECRYPTED in-memory — every
934
+ * provider row (scalars / modelConfigs / single apiKey / pool apiKeys /
935
+ * transformer) via `loadConfig` (the at-rest box decrypts on read) AND the
936
+ * subscription tokens via `credentialStore.getFullConfig()` (also decrypted) —
937
+ * serialize to ONE bundle JSON, and `sealPack` it under the passphrase-derived
938
+ * key. The caller returns ONLY the opaque pack; the decrypted bundle + the
939
+ * passphrase live only in local variables and are never logged.
940
+ *
941
+ * IMPORT (`applyImport`): `openPack` decrypts + authenticates (a wrong passphrase
942
+ * or a tampered pack fails the GCM auth-tag BEFORE any write — atomic). Every
943
+ * provider is re-validated through `parseProviderInput` and every token block
944
+ * through `validateTokenBody` (deny-by-default — a malicious blob cannot inject
945
+ * unknown fields or escape the allowlist). Validation collects ALL rows BEFORE
946
+ * applying, so a structurally invalid pack does not leave a half-applied state.
947
+ * Apply merges by provider id (additive default): a new id is added; a colliding
948
+ * id is skipped (or overwritten with `mode:'overwrite'`). Writes go through the
949
+ * EXISTING paths (`saveConfig` re-encrypts at-rest under the LOCAL box +
950
+ * `writeProviderTokens`/`appendProviderAccount`), so imported secrets land
951
+ * `enc:`-encrypted under the TARGET machine's key — the passphrase key is used
952
+ * ONLY for transport.
953
+ *
954
+ * SECRET SPINE: the export RESPONSE is the opaque pack ONLY; the import RESPONSE
955
+ * is status-only counts. No decrypted secret + no passphrase ever reaches a
956
+ * response body or a log here.
957
+ *
958
+ * @module @omnicross/daemon/migration/migration
959
+ */
960
+
961
+ /**
962
+ * The credential-store surface the migration paths need: the full DECRYPTED read
963
+ * (export) + the multi-account append (import re-encrypts at-rest). One shape so
964
+ * `ExportDeps` + `ImportDeps` can be unified into `MigrationDeps` without a
965
+ * `credentialStore` type conflict.
966
+ */
967
+ interface MigrationCredentialStore extends SubscriptionAccountAppender {
968
+ getFullConfig(): Promise<AccountTokensConfig>;
969
+ }
970
+
971
+ /**
972
+ * adminApi — the daemon admin dashboard's management API router (`/admin/api/*`,
973
+ * JSON) (RT3, design D3/D4/D5).
974
+ *
975
+ * A small method+path router over the LIVE daemon handles + exported core fns.
976
+ * Resources: providers (CRUD + hot-reload), keys (CRUD + one-time plaintext),
977
+ * server config (live apply), accounts (read-only status), status, playground
978
+ * (same-origin proxy to `/v1/*`).
979
+ *
980
+ * SECRET SPINE (design D4 — the load-bearing invariant): secrets flow IN
981
+ * (POST/PUT) and NEVER OUT (GET). The provider mask (`maskProviderApiKey`) and
982
+ * the key DTO map (`toKeyInfo`) are the single places the masking is applied so
983
+ * the invariant lives in one spot.
984
+ *
985
+ * @module @omnicross/daemon/admin/adminApi
986
+ */
987
+
988
+ /** Token-free subscription account list entry (passthrough from core's service). */
989
+ interface AdminAccountsLister {
990
+ listAll(): Promise<unknown[]>;
991
+ }
992
+ /** One key's live cooldown health (mirrors core `KeyHealthEntry`; read-only). */
993
+ interface PoolKeyHealth {
994
+ until: number;
995
+ errors: number;
996
+ lastStatus: number | null;
997
+ }
998
+ /**
999
+ * The READ-ONLY pool-health surface the admin view needs (key-pool design D7).
1000
+ * Structurally satisfied by core's `ApiKeyPoolService.getKeyHealth`; typed as a
1001
+ * minimal reader so `adminApi` carries no class coupling and can never reach a
1002
+ * key value through it (cooldown health only).
1003
+ */
1004
+ interface PoolHealthReader {
1005
+ getKeyHealth(providerId: string): Promise<Record<string, PoolKeyHealth>>;
1006
+ }
1007
+ /** The live daemon handles the management API operates over. */
1008
+ interface AdminApiDeps {
1009
+ /** Path to the daemon's `config.json` (provider catalog + `server` field). */
1010
+ readonly configPath: string;
1011
+ /** Live provider catalog (hot-reload target). */
1012
+ readonly llmConfig: ConfigFileProviderConfigSource;
1013
+ /** Named outbound-key store. */
1014
+ readonly keyDb: OutboundKeyDb;
1015
+ /** Outbound server settings store (server config persistence). */
1016
+ readonly settingsStore: JsonApiServerSettingsStore;
1017
+ /** The running outbound server (status + live applyConfig). */
1018
+ readonly outboundApiServer: OutboundApiServer;
1019
+ /** Subscription accounts (token-free `listAll`). */
1020
+ readonly subscriptionAccounts: AdminAccountsLister;
1021
+ /**
1022
+ * Least-authority subscription-token WRITER (design D4) — ONLY the mutation
1023
+ * methods (`writeProviderTokens` / `clearProvider`), never a token-returning
1024
+ * read. The token-free `subscriptionAccounts` lister stays separate so a GET
1025
+ * handler can never reach a token through this dep.
1026
+ */
1027
+ readonly subscriptionTokenWriter: SubscriptionTokenWriter;
1028
+ /**
1029
+ * Read-only pool-health reader (key-pool design D7) — cooldown health only,
1030
+ * never a key value. Drives `GET /admin/api/providers/:id/keys`.
1031
+ */
1032
+ readonly apiKeyPool: PoolHealthReader;
1033
+ /** In-memory auto-disable store (design D5) — read-only for the health view. */
1034
+ readonly autoDisableStore: AutoDisableStore;
1035
+ /**
1036
+ * Pending interactive-OAuth sessions (app-parity child 4, design D1) — the
1037
+ * in-memory `{ codeVerifier, state }` map keyed by a minted `sessionId`,
1038
+ * NEVER serialized to the client.
1039
+ */
1040
+ readonly oauthSessions: OAuthSessionStore;
1041
+ /**
1042
+ * Injected token-exchange `FetchLike` (oauth design D2-a) — defaults to global
1043
+ * `fetch` in `bootstrap.ts`; tests inject a mock so no real token endpoint is
1044
+ * hit. Mirrors how `login.ts` injects its exchange fetch.
1045
+ */
1046
+ readonly oauthExchangeFetch: FetchLike;
1047
+ /**
1048
+ * NARROW append handle (oauth design D2-a) — the OAuth complete handler needs
1049
+ * `appendProviderAccount` (NOT on the least-authority `SubscriptionTokenWriter`).
1050
+ * A minimal interface, NOT the full read-capable store, so no token-returning
1051
+ * read is reachable. Wired from the concrete `credentialStore` in `bootstrap.ts`.
1052
+ */
1053
+ readonly subscriptionAccountAppender: SubscriptionAccountAppender;
1054
+ /**
1055
+ * Codex interactive-OAuth flow store (app-parity-2 child 5). Tracks the async
1056
+ * loopback sign-in's polled status (token-free); only ONE codex login may be in
1057
+ * flight (port 1455 is one resource). Wired in `bootstrap.ts`.
1058
+ */
1059
+ readonly codexSessions: CodexOAuthSessionStore;
1060
+ /**
1061
+ * Codex loopback listener (app-parity-2 child 5) — defaults to `awaitLoopbackCode`
1062
+ * (binds 127.0.0.1:1455) in `bootstrap.ts`; tests inject a mock so no real port
1063
+ * is bound. The captured code crosses to the daemon ONLY (never the client).
1064
+ */
1065
+ readonly codexAwaitLoopback: CodexLoopbackFn;
1066
+ /**
1067
+ * Migration credential-store handle (app-parity child 6, design D2/D3). The
1068
+ * export gather needs `getFullConfig()` (full DECRYPTED tokens, in-memory only —
1069
+ * the pack is the only thing that leaves) and import needs `appendProviderAccount`
1070
+ * (multi-account append + re-encrypt at-rest). Confined to the migration
1071
+ * handlers (which seal/validate everything); never reached by a GET handler.
1072
+ * Wired from the concrete `credentialStore` in `bootstrap.ts`.
1073
+ */
1074
+ readonly migrationCredentialStore: MigrationCredentialStore;
1075
+ }
1076
+ /**
1077
+ * Dispatch one `/admin/api/*` request. `path` is the already-extracted pathname
1078
+ * (no query). The auth gate has already run in `AdminServer`.
1079
+ */
1080
+ declare function handleAdminApi(req: http.IncomingMessage, res: http.ServerResponse, path: string, deps: AdminApiDeps): Promise<void>;
1081
+
1082
+ /**
1083
+ * AdminServer — the daemon's localhost admin/dashboard HTTP listener (RT3,
1084
+ * design D1/D2).
1085
+ *
1086
+ * A SEPARATE `node:http` listener distinct from core's outbound `/v1/*` server
1087
+ * (port 8765, untouched). Mirrors `OutboundApiServer`'s proven shape:
1088
+ * - `http.createServer`, default bind `127.0.0.1` (or `0.0.0.0` when
1089
+ * `networkBinding`), `listen` with `EADDRINUSE`→ephemeral(port 0) fallback,
1090
+ * - `getStatus()` (running / bound port / dashboard URL), `start` / `stop`.
1091
+ * Default admin port 8766.
1092
+ *
1093
+ * Auth (design D2):
1094
+ * - Baseline = localhost bind, no token → reachable only from the machine.
1095
+ * - Optional `admin.token` → every `/admin/*` request (incl. `GET /`) must
1096
+ * carry `Authorization: Bearer <token>` or `X-Admin-Token: <token>`; compared
1097
+ * server-side with a constant-time equality → `401` on a miss.
1098
+ * - HARD SAFETY GATE: `networkBinding` (LAN/`0.0.0.0`) without a non-empty
1099
+ * `admin.token` → `start` REFUSES to bind (logs + stays down, fail closed).
1100
+ *
1101
+ * Routing: `GET /` (and `GET /admin`) → `DASHBOARD_HTML`; `* /admin/api/*` → the
1102
+ * management API (`handleAdminApi`); everything else → `404`.
1103
+ *
1104
+ * @module @omnicross/daemon/admin/AdminServer
1105
+ */
1106
+
1107
+ /** The dependencies the admin server + its API need (live daemon handles). */
1108
+ interface AdminServerDeps extends AdminApiDeps {
1109
+ /** Read the resolved admin config (enabled/port/networkBinding/token). */
1110
+ getAdminConfig: () => ResolvedAdminConfig;
1111
+ }
1112
+ /** A live status snapshot for the admin listener. */
1113
+ interface AdminServerStatus {
1114
+ running: boolean;
1115
+ /** Actual bound port (0 when not running). */
1116
+ port: number;
1117
+ /** The dashboard URL (loopback or LAN base), or null when not running. */
1118
+ url: string | null;
1119
+ }
1120
+ declare class AdminServer {
1121
+ private readonly deps;
1122
+ private server;
1123
+ private boundPort;
1124
+ private boundAddr;
1125
+ constructor(deps: AdminServerDeps);
1126
+ /**
1127
+ * Start the admin listener honoring the resolved admin config. Returns the
1128
+ * actual bound port, or `0` when it refuses/declines to bind (disabled or the
1129
+ * LAN fail-closed gate). Idempotent: a second call returns the bound port.
1130
+ */
1131
+ start(): Promise<number>;
1132
+ /** Bind once; on EADDRINUSE retry with an ephemeral port (port 0). */
1133
+ private listen;
1134
+ /** Per-request handler: auth gate (when a token is set) → routing. */
1135
+ private onRequest;
1136
+ private dispatch;
1137
+ /** Constant-time bearer/header check against the configured token. */
1138
+ private isAuthorized;
1139
+ /** Stop the listener and release the port. */
1140
+ stop(): Promise<void>;
1141
+ /** A live status snapshot. */
1142
+ getStatus(): AdminServerStatus;
1143
+ }
1144
+
1145
+ /**
1146
+ * ConsoleLogger — the daemon's file-less default `Logger` port impl (design D5).
1147
+ *
1148
+ * A thin `console.*` wrapper. The serving core depends on the `Logger` port
1149
+ * (never a host class), so this trivial implementation is the only logger the
1150
+ * standalone daemon needs. `error` uses the WIDEST `(message, error?, meta?)`
1151
+ * signature so every core call site stays assignable.
1152
+ *
1153
+ * @module @omnicross/daemon/ports/ConsoleLogger
1154
+ */
1155
+
1156
+ declare class ConsoleLogger implements Logger {
1157
+ info(message: string, meta?: Record<string, unknown> | Error | object): void;
1158
+ warn(message: string, meta?: Record<string, unknown> | Error | object): void;
1159
+ error(message: string, error?: unknown, meta?: Record<string, unknown> | object): void;
1160
+ debug(message: string, meta?: Record<string, unknown> | Error | object): void;
1161
+ }
1162
+
1163
+ /**
1164
+ * JsonOutboundKeyDb — the daemon's file-backed `OutboundKeyDb` port impl
1165
+ * (design D3).
1166
+ *
1167
+ * Durable storage for named outbound API keys, backed by a json file (a sibling
1168
+ * of `config.json`, e.g. `keys.json`) holding an `OutboundKeyDbRow[]`. This port
1169
+ * provides ONLY storage — it never generates secrets nor hashes. Core's
1170
+ * `createNamedKey(db, name)` calls `outboundApiKeysCreate` with the sha256
1171
+ * `keyHash` + display `keyPrefix` and returns the one-time plaintext; the hot
1172
+ * auth path uses core's `hashKey(presented)` + `outboundApiKeysGetByHash`.
1173
+ *
1174
+ * @module @omnicross/daemon/ports/JsonOutboundKeyDb
1175
+ */
1176
+
1177
+ declare class JsonOutboundKeyDb implements OutboundKeyDb$1 {
1178
+ private readonly keysPath;
1179
+ constructor(keysPath: string);
1180
+ outboundApiKeysList(): Promise<OutboundKeyDbRow[]>;
1181
+ outboundApiKeysGetByHash(hash: string): Promise<OutboundKeyDbRow | null>;
1182
+ outboundApiKeysCreate(input: {
1183
+ id: string;
1184
+ name: string;
1185
+ keyHash: string;
1186
+ keyPrefix: string;
1187
+ createdAt?: number;
1188
+ }): Promise<OutboundKeyDbRow>;
1189
+ outboundApiKeysRevoke(id: string): Promise<boolean>;
1190
+ outboundApiKeysTouchLastUsed(id: string): Promise<boolean>;
1191
+ outboundApiKeysSetEnabled(id: string, enabled: boolean): Promise<boolean>;
1192
+ /** Apply `fn` to the row with `id`, persisting when it returns true. */
1193
+ private mutateRow;
1194
+ /** Read the key rows, tolerating a missing/corrupt file (→ empty list). */
1195
+ private readRows;
1196
+ private writeRows;
1197
+ }
1198
+
1199
+ /**
1200
+ * bootstrap.ts — `buildDaemon` wires `@omnicross/core`'s `ProviderProxy` +
1201
+ * `OutboundApiServer` STANDALONE (design D6).
1202
+ *
1203
+ * A DB-backed embedder wires the same `@omnicross/core` surface differently;
1204
+ * this standalone wiring makes three SUBSTITUTIONS (file-backed ports replace
1205
+ * DB-backed ones) and three SUBTRACTIONS:
1206
+ * - no `CompletionService` (the BYO proxy path doesn't need it),
1207
+ * - no `apiKeyPool` / `usageRecorder` (optional `ProviderProxyDeps`),
1208
+ * - no `anthropicIngressHandlerFactory` (→ `/v1/messages` returns 502 by core's
1209
+ * existing contract — no daemon code needed).
1210
+ *
1211
+ * `getProviderProxy` / `getOutboundApiServer` are module singletons, so the boot
1212
+ * smoke test calls `__resetProviderProxyForTests` / `__resetOutboundApiServerForTests`
1213
+ * (re-exported here) in `beforeEach`.
1214
+ *
1215
+ * @module @omnicross/daemon/bootstrap
1216
+ */
1217
+
1218
+ /** On-disk locations the file-backed ports persist to. */
1219
+ interface DaemonPaths {
1220
+ /** The config.json path (provider catalog + persisted `server` field). */
1221
+ configPath: string;
1222
+ /** The named-key json store path (sibling of config.json by convention). */
1223
+ keysPath: string;
1224
+ /** The subscription `tokens.json` store path (sibling of config.json by convention). */
1225
+ tokensPath: string;
1226
+ /**
1227
+ * OPTIONAL `--master-key-file` override for the at-rest master key (secrets
1228
+ * design D3). Absent → the default `~/.omnicross/master.key`. The
1229
+ * `OMNICROSS_MASTER_KEY` env still beats this when set.
1230
+ */
1231
+ masterKeyFilePath?: string;
1232
+ /**
1233
+ * TEST SEAM (optional, app-parity-2 child 5): override the codex loopback listener
1234
+ * so tests need not bind `127.0.0.1:1455`. Absent → the real `awaitLoopbackCode`.
1235
+ */
1236
+ codexAwaitLoopback?: CodexLoopbackFn;
1237
+ /**
1238
+ * TEST SEAM (optional): override the OAuth token-exchange fetch so tests need not
1239
+ * hit a real token endpoint. Absent → the global `fetch`.
1240
+ */
1241
+ oauthExchangeFetch?: FetchLike;
1242
+ }
1243
+ /** The constructed daemon handles the CLI commands operate on. */
1244
+ interface Daemon {
1245
+ readonly logger: ConsoleLogger;
1246
+ readonly llmConfig: ConfigFileProviderConfigSource;
1247
+ readonly keyDb: JsonOutboundKeyDb;
1248
+ readonly settingsStore: JsonApiServerSettingsStore;
1249
+ readonly providerProxy: ProviderProxy;
1250
+ readonly outboundApiServer: OutboundApiServer;
1251
+ /**
1252
+ * Multi-key load balancer. Wired into the proxy deps slot
1253
+ * AND exposed here so the admin read-only key-health view can
1254
+ * read `getKeyHealth`. NOTE: outbound failover does NOT fire on
1255
+ * the daemon's null-session outbound path — v1 is cold-standby + observable.
1256
+ */
1257
+ readonly apiKeyPool: ApiKeyPoolService;
1258
+ /** In-memory 401/403 auto-disable store (design D5; read by the admin view). */
1259
+ readonly autoDisableStore: AutoDisableStore;
1260
+ /** File-backed subscription credential store (reads `tokens.json`). */
1261
+ readonly credentialStore: JsonSubscriptionCredentialStore;
1262
+ /** Subscription dispatch-profile registry (mirrored into core's outbound slot). */
1263
+ readonly subscriptionRegistry: SubscriptionProviderRegistry;
1264
+ /** Subscription account service (token-free `listAll`) — now exposed for the
1265
+ * admin dashboard's read-only accounts panel (RT3). */
1266
+ readonly subscriptionAccounts: SubscriptionAccountService;
1267
+ /** The localhost admin/dashboard HTTP listener (RT3). Started by `start.ts`. */
1268
+ readonly adminServer: AdminServer;
1269
+ }
1270
+ /**
1271
+ * Construct the standalone daemon from a loaded config + on-disk paths. Does NOT
1272
+ * start the listeners — the `start` command awaits `providerProxy.start()` then
1273
+ * `outboundApiServer.applyConfig(...)`.
1274
+ */
1275
+ declare function buildDaemon(config: DaemonConfig, paths: DaemonPaths): Daemon;
1276
+ /** Reset the core singletons (tests / teardown only). Re-exported for the suite.
1277
+ *
1278
+ * Also clears BOTH subscription singletons (design D4). This is mandatory: the
1279
+ * `setSubscriptionProviderRegistry` setter mirrors into core's outbound slot, so
1280
+ * without it a prior test's registry would leak into a BYO-only boot and
1281
+ * mis-route. We clear the core outbound slot directly via
1282
+ * `setSubscriptionRegistryForOutbound(null)` (which accepts `null`), and null
1283
+ * the `@omnicross/subscriptions` module singletons through their setters (the
1284
+ * setters assign verbatim — passing `null` is a no-throw runtime clear; the
1285
+ * `as never` keeps the call within the package's non-nullable type without
1286
+ * modifying its behavior). It also nulls the core Gemini Code-Assist resolver
1287
+ * slot so a wired resolver does not leak across boots.
1288
+ *
1289
+ * NOTE: the `AdminServer` is INSTANCE-scoped on the returned `Daemon` (not a
1290
+ * module singleton), so it needs no reset here — the test stops it in `afterEach`
1291
+ * via `daemon.adminServer.stop()`. */
1292
+ declare function resetDaemonSingletonsForTests(): void;
1293
+
1294
+ /**
1295
+ * html.ts — the embedded vanilla-JS admin dashboard (RT3, design D7).
1296
+ *
1297
+ * A SINGLE `text/html` template literal served by `AdminServer` on `GET /`. No
1298
+ * framework, no bundler, no new dependency — vanilla `fetch` + DOM. Panels:
1299
+ * providers (table + add/edit form), keys (table + create modal showing the
1300
+ * one-time plaintext with a copy button + a "shown once" warning, never
1301
+ * persisted), server config, read-only accounts status, and a playground
1302
+ * (endpoint select + key + request textarea + Send + response area).
1303
+ *
1304
+ * SECURITY: the create-key plaintext is held only in a local variable inside the
1305
+ * modal flow and cleared on dismiss — never written to a field, list, or storage.
1306
+ *
1307
+ * @module @omnicross/daemon/admin/html
1308
+ */
1309
+ /** The full dashboard document (style + body + the vanilla client script). */
1310
+ declare const DASHBOARD_HTML: string;
1311
+
1312
+ /**
1313
+ * ccr-import.ts — translate a `claude-code-router` (CCR) `config.json` into an
1314
+ * omnicross daemon config (design D9). Pure + testable: `parseCcrConfig(raw)` +
1315
+ * `mapCcrToOmnicross(ccr) → { config, notes }`.
1316
+ *
1317
+ * Provider mapping: CCR `Providers[{name, api_base_url, api_key, models}]` →
1318
+ * omnicross provider rows. `apiFormat` is inferred heuristically (default
1319
+ * `openai`), with a `notes[]` entry whenever the inference is ambiguous.
1320
+ *
1321
+ * Router-role mapping (per doc 03 §4.1 / 04 §4.1, user decision 2026-06-03):
1322
+ * default → default
1323
+ * background → background
1324
+ * think → default (omnicross has no think slot)
1325
+ * longContext → default (no longContext slot; `longContextThreshold` dropped)
1326
+ * image → vision (CCR's `forceUseImageAgent` dropped)
1327
+ * webSearch → DROPPED (philosophically different; recorded as a note)
1328
+ *
1329
+ * @module @omnicross/daemon/ccr-import
1330
+ */
1331
+
1332
+ /** A CCR provider entry (defensive — all fields optional). */
1333
+ interface CcrProvider {
1334
+ name?: string;
1335
+ api_base_url?: string;
1336
+ api_key?: string;
1337
+ models?: string[];
1338
+ transformer?: unknown;
1339
+ }
1340
+ /** The CCR `Router` block (a subset; unknown roles are ignored). */
1341
+ interface CcrRouter {
1342
+ default?: string;
1343
+ background?: string;
1344
+ think?: string;
1345
+ longContext?: string;
1346
+ longContextThreshold?: number;
1347
+ image?: string;
1348
+ webSearch?: string;
1349
+ forceUseImageAgent?: boolean;
1350
+ }
1351
+ /** A parsed CCR config. */
1352
+ interface CcrConfig {
1353
+ Providers?: CcrProvider[];
1354
+ Router?: CcrRouter;
1355
+ }
1356
+ /** Parse + shape-guard a raw CCR config object. */
1357
+ declare function parseCcrConfig(raw: unknown): CcrConfig;
1358
+ /**
1359
+ * Infer the wire format for a CCR provider from its base URL / name. Returns the
1360
+ * inferred format and whether it was an ambiguous (heuristic) guess.
1361
+ */
1362
+ declare function inferApiFormat(provider: CcrProvider): {
1363
+ format: DaemonApiFormat;
1364
+ ambiguous: boolean;
1365
+ };
1366
+ /**
1367
+ * Translate a parsed CCR config into a `DaemonConfig` + the list of human-readable
1368
+ * notes describing every folded/dropped field. Pure — directly unit-tested.
1369
+ */
1370
+ declare function mapCcrToOmnicross(ccr: CcrConfig): {
1371
+ config: DaemonConfig;
1372
+ notes: string[];
1373
+ };
1374
+
1375
+ export { type AdminApiDeps, AdminServer, type AdminServerDeps, type AdminServerStatus, type CcrConfig, type CcrProvider, type CcrRouter, ConfigFileProviderConfigSource, ConsoleLogger, DASHBOARD_HTML, DEFAULT_ADMIN_PORT, type Daemon, type DaemonAdminConfig, type DaemonApiFormat, type DaemonConfig, type DaemonPaths, type DaemonProviderConfig, JsonApiServerSettingsStore, JsonOutboundKeyDb, JsonSubscriptionCredentialStore, type ResolvedAdminConfig, buildDaemon, handleAdminApi, inferApiFormat, loadConfig, mapCcrToOmnicross, parseCcrConfig, resetDaemonSingletonsForTests, resolveAdminConfig, saveConfig, validateConfig };