@omnicross/daemon 0.1.0 → 0.1.3
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.
- package/dist/cli.cjs +4941 -975
- package/dist/cli.js +4946 -958
- package/dist/index.cjs +4854 -946
- package/dist/index.d.cts +1359 -48
- package/dist/index.d.ts +1359 -48
- package/dist/index.js +4854 -925
- package/package.json +3 -2
package/dist/index.d.cts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
+
import { OutboundApiServerConfig, Logger, ProviderConfigSource, TransformerService, Transformer, ResolvedTransformerChain, ApiServerSettingsStore, PricingStore, OutboundKeyDb as OutboundKeyDb$1, OutboundKeyDbRow, OutboundKeyPolicy } from '@omnicross/core';
|
|
1
2
|
import { ApiKeyPoolService } from '@omnicross/core/completion/ApiKeyPoolService';
|
|
2
|
-
import { OutboundKeyDb, OutboundApiServer } from '@omnicross/core/outbound-api';
|
|
3
|
+
import { AccountProbeConfig, OutboundKeyDb, VoucherDb, KeySpendReader, OutboundApiServer } from '@omnicross/core/outbound-api';
|
|
3
4
|
import { ProviderProxy } from '@omnicross/core/provider-proxy';
|
|
5
|
+
import { UsageRecorder, PricingEngine } from '@omnicross/core/usage';
|
|
4
6
|
import { SubscriptionCredentialStore, FetchLike, SubscriptionProviderRegistry, SubscriptionAccountService } from '@omnicross/subscriptions';
|
|
5
|
-
import {
|
|
7
|
+
import { LoggingConfig, HealthReport } from '@omnicross/contracts/health-logging-types';
|
|
8
|
+
import { ClaudeTokenConfig, CodexTokenConfig, GeminiTokenConfig, AccountTokensConfig, ProxyConfig, SubscriptionAccountSanitized, AccountClientIdentity } from '@omnicross/contracts/account-tokens-types';
|
|
9
|
+
import { OpenCodeGoTokenConfig, SubscriptionProviderId } from '@omnicross/contracts/subscription-types';
|
|
10
|
+
import { SubscriptionAccountHealth } from '@omnicross/core/pipeline/SubscriptionAccountHealth';
|
|
11
|
+
import { fetchUpstream } from '@omnicross/core/pipeline/upstreamFetch';
|
|
12
|
+
import { AuditRecord, AuditConfig } from '@omnicross/contracts/audit-types';
|
|
13
|
+
import { BillingDeliveryStatus, BillingConfig, BillingEvent } from '@omnicross/contracts/billing-types';
|
|
6
14
|
import http from 'node:http';
|
|
7
15
|
import { LLMProvider, AgentDefaultModels, GlobalModelParameters } from '@omnicross/contracts/llm-config';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
16
|
+
import { PricingEntry, PricingEntryInput, PricingResolution } from '@omnicross/contracts/pricing-types';
|
|
17
|
+
import { WebhookConfig, WebhookEvent } from '@omnicross/contracts/webhook-types';
|
|
10
18
|
|
|
11
19
|
/**
|
|
12
20
|
* SecretBox.ts — a 32-byte master key wrapped with the tri-state secret rules
|
|
@@ -374,6 +382,12 @@ interface DaemonConfig {
|
|
|
374
382
|
server?: OutboundApiServerConfig;
|
|
375
383
|
/** Optional admin-dashboard config (RT3). */
|
|
376
384
|
admin?: DaemonAdminConfig;
|
|
385
|
+
/**
|
|
386
|
+
* Optional logging config (configurable-logging). Absent ⇒ the zero-regression
|
|
387
|
+
* default (console + all levels + text). `file` is a PLAIN value — NOT a secret,
|
|
388
|
+
* so it is never walked by `decryptConfigSecrets`/`encryptConfigSecrets`.
|
|
389
|
+
*/
|
|
390
|
+
logging?: LoggingConfig;
|
|
377
391
|
}
|
|
378
392
|
/** Apply defaults to a (possibly absent) admin block: enabled, port 8766,
|
|
379
393
|
* loopback, no token. NOTE: an EXPLICIT `port: 0` is honored as "bind an
|
|
@@ -391,6 +405,96 @@ declare function loadConfig(path: string): DaemonConfig;
|
|
|
391
405
|
* `enc:` untouched) before serializing. */
|
|
392
406
|
declare function saveConfig(path: string, cfg: DaemonConfig): void;
|
|
393
407
|
|
|
408
|
+
/**
|
|
409
|
+
* external-cli-credentials — read-only access to the external CLI native
|
|
410
|
+
* credential stores (external-cli-sync).
|
|
411
|
+
*
|
|
412
|
+
* The daemon never WRITES these files (they belong to the CLIs); it only reads
|
|
413
|
+
* them back to (a) recover from the rotating-refresh-token race — when e.g.
|
|
414
|
+
* Claude Code refreshes `~/.claude/.credentials.json` it rotates OUR stored
|
|
415
|
+
* refresh token out from under us, and the external file then holds the only
|
|
416
|
+
* live credential — and (b) detect divergence for the account-list warning.
|
|
417
|
+
*
|
|
418
|
+
* File shapes (mirrors the shapes the CLIs themselves write):
|
|
419
|
+
* claude `~/.claude/.credentials.json`
|
|
420
|
+
* → `{ claudeAiOauth: { accessToken, refreshToken?, expiresAt(number ms),
|
|
421
|
+
* scopes? } }`
|
|
422
|
+
* codex `~/.codex/auth.json`
|
|
423
|
+
* → `{ tokens: { id_token?, access_token, refresh_token? } }` — no explicit
|
|
424
|
+
* expiry; the access token's JWT `exp` claim is the only expiry signal.
|
|
425
|
+
*
|
|
426
|
+
* Gemini is deliberately excluded: the gemini CLI's oauth store is not a
|
|
427
|
+
* supported import source (parity with the host app's external-sync scope).
|
|
428
|
+
*
|
|
429
|
+
* @module @omnicross/daemon/ports/external-cli-credentials
|
|
430
|
+
*/
|
|
431
|
+
/** The two external CLI providers with a readable native store. */
|
|
432
|
+
type ExternalCliProvider = 'claude' | 'codex';
|
|
433
|
+
/**
|
|
434
|
+
* External credentials parsed OUT of a native store file. Field axes match the
|
|
435
|
+
* internal `*TokenConfig` shapes: `expiresAt` is an ISO STRING here (converted
|
|
436
|
+
* from the native ms number / codex JWT `exp`).
|
|
437
|
+
*/
|
|
438
|
+
interface ExternalCliCredentials {
|
|
439
|
+
accessToken?: string;
|
|
440
|
+
refreshToken?: string;
|
|
441
|
+
/** ISO string. */
|
|
442
|
+
expiresAt?: string;
|
|
443
|
+
/** codex only. */
|
|
444
|
+
idToken?: string;
|
|
445
|
+
/** claude only. */
|
|
446
|
+
scopes?: string[];
|
|
447
|
+
}
|
|
448
|
+
/** Reader port — injectable so tests never touch the real home directory. */
|
|
449
|
+
type ExternalCliReader = (provider: ExternalCliProvider) => ExternalCliCredentials | null;
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* external-cli-store — WRITE side of the external CLI native credential stores
|
|
453
|
+
* (external-cli-sync, import + write-back).
|
|
454
|
+
*
|
|
455
|
+
* Counterpart of `external-cli-credentials` (the read side). The daemon only
|
|
456
|
+
* ever writes a file it MANAGES: an explicit "import existing CLI login" puts
|
|
457
|
+
* an `.omnicross-managed` marker (recording the owning account id) next to the
|
|
458
|
+
* native store, and every subsequent successful refresh of THAT account writes
|
|
459
|
+
* the rotated credential back into the file. Without the write-back, the
|
|
460
|
+
* daemon's refresh would rotate the single-use refresh token and silently log
|
|
461
|
+
* the user's bare CLI out — the write-back keeps both sides on the same live
|
|
462
|
+
* credential.
|
|
463
|
+
*
|
|
464
|
+
* Safety properties:
|
|
465
|
+
* - NEVER writes without a matching marker (an unmanaged / foreign-account
|
|
466
|
+
* file is untouched);
|
|
467
|
+
* - read-then-merge: unrelated top-level keys in the native file (e.g.
|
|
468
|
+
* claude `email`, codex `OPENAI_API_KEY`) are preserved;
|
|
469
|
+
* - one-time `.omnicross-backup` of the original file before the FIRST
|
|
470
|
+
* overwrite (restorable by hand if the user wants the daemon out);
|
|
471
|
+
* - atomic write (temp file → rename) so a crash never leaves a torn file.
|
|
472
|
+
*
|
|
473
|
+
* The envelope shapes mirror what the CLIs themselves write — the round-trip
|
|
474
|
+
* test parses a written file back through `external-cli-credentials` to keep
|
|
475
|
+
* the two sides from drifting.
|
|
476
|
+
*
|
|
477
|
+
* @module @omnicross/daemon/ports/external-cli-store
|
|
478
|
+
*/
|
|
479
|
+
|
|
480
|
+
/** Token blocks the write-back accepts (the two external-store providers). */
|
|
481
|
+
type ExternalWritableTokens = ClaudeTokenConfig | CodexTokenConfig;
|
|
482
|
+
/**
|
|
483
|
+
* Injectable port over the external store writes (tests use an in-memory fake;
|
|
484
|
+
* the store wires `realExternalCliStore`).
|
|
485
|
+
*/
|
|
486
|
+
interface ExternalCliStorePort {
|
|
487
|
+
/** The owning account id recorded by the marker, or undefined when unmanaged. */
|
|
488
|
+
readMarkerAccountId(provider: ExternalCliProvider): string | undefined;
|
|
489
|
+
/** Record (or move) ownership of the provider's native store to an account. */
|
|
490
|
+
writeMarker(provider: ExternalCliProvider, accountId: string): void;
|
|
491
|
+
/**
|
|
492
|
+
* Write the refreshed tokens back into the native store — ONLY when the
|
|
493
|
+
* marker names `accountId`. Returns true when a write happened.
|
|
494
|
+
*/
|
|
495
|
+
writeBack(provider: ExternalCliProvider, accountId: string, tokens: ExternalWritableTokens): boolean;
|
|
496
|
+
}
|
|
497
|
+
|
|
394
498
|
/**
|
|
395
499
|
* JsonSubscriptionCredentialStore — the daemon's file-backed
|
|
396
500
|
* `SubscriptionCredentialStore` port impl (design D1).
|
|
@@ -452,15 +556,43 @@ declare class JsonSubscriptionCredentialStore implements SubscriptionCredentialS
|
|
|
452
556
|
private readonly tokensPath;
|
|
453
557
|
private readonly box;
|
|
454
558
|
private readonly fetchImpl;
|
|
559
|
+
/** Injectable external CLI native-store reader (external-cli-sync). */
|
|
560
|
+
private readonly externalCliReader;
|
|
561
|
+
/** Injectable external CLI native-store WRITER (marker-gated write-back). */
|
|
562
|
+
private readonly externalCliStore;
|
|
455
563
|
/**
|
|
456
564
|
* @param tokensPath on-disk `tokens.json` location.
|
|
457
565
|
* @param box at-rest `SecretBox` (encrypt-on-write / decrypt-on-read).
|
|
458
|
-
* @param fetchImpl injectable HTTP port for the OAuth refresh
|
|
459
|
-
* (oauth design D4).
|
|
460
|
-
*
|
|
461
|
-
*
|
|
566
|
+
* @param fetchImpl OPTIONAL injectable HTTP port for the OAuth refresh
|
|
567
|
+
* round-trips (oauth design D4). A TEST-injected transport is
|
|
568
|
+
* used verbatim. When ABSENT (production), each refresh uses a
|
|
569
|
+
* proxy-aware {@link fetchUpstream} that threads the
|
|
570
|
+
* `{ providerId, accountId }` ctx (upstream-proxy M1) so a
|
|
571
|
+
* per-account/per-provider proxy is honored on refresh exactly
|
|
572
|
+
* as on relay — refresh egresses from the SAME proxy IP as the
|
|
573
|
+
* account's traffic. NOT used by any read/write path.
|
|
574
|
+
*/
|
|
575
|
+
constructor(tokensPath: string, box: SecretBox, fetchImpl?: FetchLike | undefined,
|
|
576
|
+
/** Injectable external CLI native-store reader (external-cli-sync). */
|
|
577
|
+
externalCliReader?: ExternalCliReader,
|
|
578
|
+
/** Injectable external CLI native-store WRITER (marker-gated write-back). */
|
|
579
|
+
externalCliStore?: ExternalCliStorePort);
|
|
580
|
+
/**
|
|
581
|
+
* The proxy-aware `FetchLike` for one refresh round-trip (upstream-proxy M1). A
|
|
582
|
+
* TEST-injected `fetchImpl` is returned verbatim; otherwise the refresh routes
|
|
583
|
+
* through {@link fetchUpstream} with the account's `{ providerId, accountId }`
|
|
584
|
+
* ctx so the per-account/provider proxy applies. `@internal` — also a test seam.
|
|
585
|
+
*/
|
|
586
|
+
buildRefreshFetch(providerId: string, accountId?: string): FetchLike;
|
|
587
|
+
/**
|
|
588
|
+
* In-flight refresh coalescing (external-cli-sync). OAuth refresh tokens are
|
|
589
|
+
* SINGLE-USE: two concurrent refreshes of one account each spend the same
|
|
590
|
+
* token and the loser bricks a healthy account. Every refresh entry point
|
|
591
|
+
* (auth-strategy lazy refresh, 401 retry, background scheduler) funnels
|
|
592
|
+
* through `coalesce`, so overlapping callers share ONE upstream round-trip.
|
|
462
593
|
*/
|
|
463
|
-
|
|
594
|
+
private readonly inFlightRefreshes;
|
|
595
|
+
private coalesce;
|
|
464
596
|
/** Full parsed account-tokens config (or a minimal `{ updatedAt }` when the
|
|
465
597
|
* file is absent/corrupt). This is the hot read — the codex / gemini auth
|
|
466
598
|
* strategies pull `accessToken` / `expiresAt` / `status` from it. */
|
|
@@ -471,6 +603,14 @@ declare class JsonSubscriptionCredentialStore implements SubscriptionCredentialS
|
|
|
471
603
|
getValidClaudeAccessToken(): Promise<string | null>;
|
|
472
604
|
/** Current OpenCodeGo static API key, or `null` when none is stored. */
|
|
473
605
|
getValidOpenCodeGoApiKey(): Promise<string | null>;
|
|
606
|
+
/**
|
|
607
|
+
* DAEMON-ONLY per-account proxy lookup by id (upstream-proxy). Returns the
|
|
608
|
+
* DECRYPTED `ProxyConfig` for the account (`readConfig` decrypts on read), or
|
|
609
|
+
* `undefined` for an unknown provider/account or no per-account proxy. Feeds the
|
|
610
|
+
* winning per-account layer of the upstream-proxy resolver. Synchronous like the
|
|
611
|
+
* other hot reads. Never returns token material.
|
|
612
|
+
*/
|
|
613
|
+
getAccountProxy(providerId: string, accountId: string): ProxyConfig | undefined;
|
|
474
614
|
/**
|
|
475
615
|
* DAEMON-ONLY sanitized accounts list (design D8, NOT on the port). Projects
|
|
476
616
|
* each provider's accounts to the secret-free `SubscriptionAccountSanitized`
|
|
@@ -478,6 +618,17 @@ declare class JsonSubscriptionCredentialStore implements SubscriptionCredentialS
|
|
|
478
618
|
* Used by the admin accounts GET (secret-IN-never-OUT).
|
|
479
619
|
*/
|
|
480
620
|
listSanitizedAccounts(): Promise<Record<string, SubscriptionAccountSanitized[]>>;
|
|
621
|
+
/**
|
|
622
|
+
* List-time credential-conflict warnings (external-cli-sync). Computed, not
|
|
623
|
+
* persisted: (a) `duplicate-token` when two accounts of one provider share a
|
|
624
|
+
* credential, (b) `external-divergent` when the external CLI native store has
|
|
625
|
+
* rotated PAST the ACTIVE account (claude/codex only). A warning persisted by
|
|
626
|
+
* a failed refresh (`external-not-rotated`) takes precedence — it is the most
|
|
627
|
+
* actionable state.
|
|
628
|
+
*/
|
|
629
|
+
private attachSyncWarnings;
|
|
630
|
+
/** Read the external CLI store, never letting an fs/parse error escape. */
|
|
631
|
+
private safeReadExternal;
|
|
481
632
|
/**
|
|
482
633
|
* Refresh the Claude OAuth access token (oauth design D4). HONEST `false` when
|
|
483
634
|
* the block has no refresh_token (setup-token / manual) — no upstream call, the
|
|
@@ -501,6 +652,115 @@ declare class JsonSubscriptionCredentialStore implements SubscriptionCredentialS
|
|
|
501
652
|
* destroy the ability to refresh again). HONEST `false` when no refresh_token.
|
|
502
653
|
*/
|
|
503
654
|
refreshGeminiToken(): Promise<boolean>;
|
|
655
|
+
/**
|
|
656
|
+
* Refresh a SPECIFIC account by id (background scheduler sweep,
|
|
657
|
+
* external-cli-sync). Unlike the active-account refreshers it does NOT
|
|
658
|
+
* attempt the external-import fallback — the external CLI file's lineage can
|
|
659
|
+
* only plausibly match the ACTIVE account. Coalesced per `provider:id`; on
|
|
660
|
+
* failure flags ONLY that account `expired`.
|
|
661
|
+
*/
|
|
662
|
+
refreshAccountById(provider: 'claude' | 'codex' | 'gemini', id: string): Promise<boolean>;
|
|
663
|
+
/**
|
|
664
|
+
* Resolve a SPECIFIC account's access token by id (design D6). Mirrors each
|
|
665
|
+
* provider's ACTIVE-getter policy, keyed by id: claude returns the stored token
|
|
666
|
+
* (refresh is 401-driven, like `getValidClaudeAccessToken`); codex/gemini refresh
|
|
667
|
+
* a near-expiry token via `refreshAccountById` (like `resolveAccessToken`);
|
|
668
|
+
* opencodego returns the account's static key. `null` when unknown/expired/
|
|
669
|
+
* tokenless.
|
|
670
|
+
*/
|
|
671
|
+
getAccessTokenForAccount(providerId: SubscriptionProviderId, accountId: string): Promise<string | null>;
|
|
672
|
+
/**
|
|
673
|
+
* Refresh a SPECIFIC account's OAuth token by id (design D6/D7). Delegates to
|
|
674
|
+
* `refreshAccountById` (coalesced per `provider:id`); opencodego is a static key
|
|
675
|
+
* → `false` (no refresh affordance).
|
|
676
|
+
*/
|
|
677
|
+
refreshAccountToken(providerId: SubscriptionProviderId, accountId: string): Promise<boolean>;
|
|
678
|
+
/**
|
|
679
|
+
* Best-effort record of a selection time onto the account's `lastUsedAt` by id
|
|
680
|
+
* (design D4). Entry-metadata only (the token mirror is untouched); a no-op for
|
|
681
|
+
* an unknown id. The selector throttles the call frequency, so this stays cheap.
|
|
682
|
+
*/
|
|
683
|
+
touchAccountLastUsed(providerId: SubscriptionProviderId, accountId: string, iso: string): Promise<void>;
|
|
684
|
+
/**
|
|
685
|
+
* Best-effort write-through of a per-account client `identity`
|
|
686
|
+
* (subscription-client-fingerprint #7, P2). Entry-metadata only (NON-secret
|
|
687
|
+
* whitelisted fingerprint headers; the token mirror is untouched); a no-op for
|
|
688
|
+
* an unknown id. Called by the identity store's persistence port on a first-seen
|
|
689
|
+
* freeze / TTL refresh, so it stays infrequent. Never throws to the caller — the
|
|
690
|
+
* store's port wrapper swallows a rejection so the relay hot path is unaffected.
|
|
691
|
+
*/
|
|
692
|
+
setAccountIdentity(providerId: SubscriptionProviderId, accountId: string, identity: AccountClientIdentity): Promise<void>;
|
|
693
|
+
/**
|
|
694
|
+
* DAEMON-ONLY set-priority (subscription-account-scheduling, admin write, NOT on
|
|
695
|
+
* the port). Set one account's scheduling `priority` by id. Secret-free
|
|
696
|
+
* (entry-metadata only; the mirror invariant is untouched). Rejects an unknown id.
|
|
697
|
+
*/
|
|
698
|
+
setAccountPriority(providerId: SubscriptionProviderId, accountId: string, priority: number): Promise<{
|
|
699
|
+
ok: boolean;
|
|
700
|
+
}>;
|
|
701
|
+
/**
|
|
702
|
+
* DAEMON-ONLY set/clear per-account proxy (upstream-proxy, admin write, NOT on
|
|
703
|
+
* the port). Passing `undefined` clears the override. Write-only password: when
|
|
704
|
+
* the incoming structured proxy omits the password but the account already had
|
|
705
|
+
* one, the current (decrypted) password is preserved — editing host/port never
|
|
706
|
+
* wipes the secret. Persist re-encrypts `proxy.password` via the tokens SecretBox.
|
|
707
|
+
*/
|
|
708
|
+
setAccountProxy(providerId: SubscriptionProviderId, accountId: string, proxy: ProxyConfig | undefined): Promise<{
|
|
709
|
+
ok: boolean;
|
|
710
|
+
}>;
|
|
711
|
+
/**
|
|
712
|
+
* DAEMON-ONLY set/clear per-account `supportedModels` (subscription-account-
|
|
713
|
+
* model-map, admin write, NOT on the port). Passing `undefined` clears it.
|
|
714
|
+
* Secret-free (model ids only; the mirror invariant is untouched). Rejects an
|
|
715
|
+
* unknown id.
|
|
716
|
+
*/
|
|
717
|
+
setAccountSupportedModels(providerId: SubscriptionProviderId, accountId: string, supportedModels: string[] | Record<string, string> | undefined): Promise<{
|
|
718
|
+
ok: boolean;
|
|
719
|
+
}>;
|
|
720
|
+
/** Dispatch one OAuth refresh round-trip to the provider's shared flow. */
|
|
721
|
+
private refreshUpstream;
|
|
722
|
+
/**
|
|
723
|
+
* External-import fallback for a FAILED active-account refresh
|
|
724
|
+
* (external-cli-sync). Reads the CLI native store; imports when the external
|
|
725
|
+
* lineage ROTATED (different refresh token) or its access token is still
|
|
726
|
+
* valid. When the imported access token is already expired it refreshes once
|
|
727
|
+
* with the rotated refresh token. A `not-rotated` outcome persists the
|
|
728
|
+
* `external-not-rotated` warning on the (about-to-be-expired) account so the
|
|
729
|
+
* UI can tell "genuine revocation" apart from a plain refresh failure.
|
|
730
|
+
*/
|
|
731
|
+
private tryExternalImport;
|
|
732
|
+
/**
|
|
733
|
+
* Marker-gated external write-back (external-cli-sync). After a successful
|
|
734
|
+
* refresh of the account that OWNS the provider's native CLI store (imported
|
|
735
|
+
* via `importExternalCliAccount`), push the rotated credential back into the
|
|
736
|
+
* file — otherwise the daemon's refresh invalidates the single-use refresh
|
|
737
|
+
* token and silently logs the bare CLI out. NON-FATAL: the internal store is
|
|
738
|
+
* already persisted; a failed external write only leaves the file stale,
|
|
739
|
+
* which the `external-divergent` warning surfaces.
|
|
740
|
+
*/
|
|
741
|
+
private resyncExternal;
|
|
742
|
+
/** Read the marker's owning account id, never letting an fs error escape. */
|
|
743
|
+
private safeReadMarker;
|
|
744
|
+
/**
|
|
745
|
+
* DAEMON-ONLY (admin import button): which providers have a usable external
|
|
746
|
+
* CLI credential on THIS machine. Pure detection — reads the native files,
|
|
747
|
+
* never mutates anything, never returns a token.
|
|
748
|
+
*/
|
|
749
|
+
listExternalCliAvailability(): Promise<Record<ExternalCliProvider, boolean>>;
|
|
750
|
+
/**
|
|
751
|
+
* DAEMON-ONLY (admin import button): import the external CLI's current login
|
|
752
|
+
* as a NEW account (+ activate), and take MANAGED ownership of the native
|
|
753
|
+
* store (marker) so subsequent refreshes write back — keeping the bare CLI
|
|
754
|
+
* and the daemon on the same live credential instead of silently killing one
|
|
755
|
+
* side's single-use refresh token.
|
|
756
|
+
*/
|
|
757
|
+
importExternalCliAccount(provider: ExternalCliProvider, label?: string): Promise<{
|
|
758
|
+
ok: true;
|
|
759
|
+
id: string;
|
|
760
|
+
} | {
|
|
761
|
+
ok: false;
|
|
762
|
+
reason: 'no-credential';
|
|
763
|
+
}>;
|
|
504
764
|
/**
|
|
505
765
|
* Materialize a lazily-synthesized account id to disk (design D3). On a legacy
|
|
506
766
|
* single-slot file, `readConfig` synthesizes a NON-deterministic account id
|
|
@@ -555,6 +815,14 @@ declare class JsonSubscriptionCredentialStore implements SubscriptionCredentialS
|
|
|
555
815
|
removeAccount(providerId: SubscriptionProviderId, id: string): Promise<{
|
|
556
816
|
removed: boolean;
|
|
557
817
|
}>;
|
|
818
|
+
/**
|
|
819
|
+
* DAEMON-ONLY per-account rename (NOT on the port). Update one account's label;
|
|
820
|
+
* rejects an unknown id. Label-only — no token material is read or written
|
|
821
|
+
* (the secret-free invariant holds).
|
|
822
|
+
*/
|
|
823
|
+
renameAccount(providerId: SubscriptionProviderId, id: string, label: string): Promise<{
|
|
824
|
+
ok: boolean;
|
|
825
|
+
}>;
|
|
558
826
|
/**
|
|
559
827
|
* DAEMON-ONLY CLEAR (design D1/D3, NOT on the port). Remove a single provider's
|
|
560
828
|
* block from `tokens.json` and re-persist (the strategies already tolerate an
|
|
@@ -614,7 +882,40 @@ interface SubscriptionTokenWriter {
|
|
|
614
882
|
removeAccount(providerId: SubscriptionProviderId, id: string): Promise<{
|
|
615
883
|
removed: boolean;
|
|
616
884
|
}>;
|
|
885
|
+
/** Rename one account's label (label-only; rejects an unknown id). */
|
|
886
|
+
renameAccount(providerId: SubscriptionProviderId, id: string, label: string): Promise<{
|
|
887
|
+
ok: boolean;
|
|
888
|
+
}>;
|
|
889
|
+
/** Set one account's scheduling priority (secret-free; rejects an unknown id).
|
|
890
|
+
* subscription-account-scheduling — lets an operator order a pool. */
|
|
891
|
+
setAccountPriority(providerId: SubscriptionProviderId, id: string, priority: number): Promise<{
|
|
892
|
+
ok: boolean;
|
|
893
|
+
}>;
|
|
894
|
+
/** Set (or CLEAR, with `undefined`) one account's per-account proxy override
|
|
895
|
+
* (upstream-proxy). The `proxy.password` is a secret (encrypted at rest, masked
|
|
896
|
+
* in the sanitized view). Rejects an unknown id. */
|
|
897
|
+
setAccountProxy(providerId: SubscriptionProviderId, id: string, proxy: ProxyConfig | undefined): Promise<{
|
|
898
|
+
ok: boolean;
|
|
899
|
+
}>;
|
|
900
|
+
/** Set (or CLEAR, with `undefined`) one account's `supportedModels`
|
|
901
|
+
* (subscription-account-model-map). Secret-free (model ids only). Rejects an
|
|
902
|
+
* unknown id. An array = allow-list (skip-only); an object = allow-list keys +
|
|
903
|
+
* logical→actual remap values. */
|
|
904
|
+
setAccountSupportedModels(providerId: SubscriptionProviderId, id: string, supportedModels: string[] | Record<string, string> | undefined): Promise<{
|
|
905
|
+
ok: boolean;
|
|
906
|
+
}>;
|
|
617
907
|
listSanitizedAccounts(): Promise<Record<string, SubscriptionAccountSanitized[]>>;
|
|
908
|
+
refreshClaudeToken(): Promise<boolean>;
|
|
909
|
+
refreshCodexToken(): Promise<boolean>;
|
|
910
|
+
refreshGeminiToken(): Promise<boolean>;
|
|
911
|
+
listExternalCliAvailability(): Promise<Record<'claude' | 'codex', boolean>>;
|
|
912
|
+
importExternalCliAccount(providerId: 'claude' | 'codex', label?: string): Promise<{
|
|
913
|
+
ok: true;
|
|
914
|
+
id: string;
|
|
915
|
+
} | {
|
|
916
|
+
ok: false;
|
|
917
|
+
reason: 'no-credential';
|
|
918
|
+
}>;
|
|
618
919
|
}
|
|
619
920
|
|
|
620
921
|
/**
|
|
@@ -778,6 +1079,239 @@ declare class CodexOAuthSessionStore {
|
|
|
778
1079
|
private sweep;
|
|
779
1080
|
}
|
|
780
1081
|
|
|
1082
|
+
/**
|
|
1083
|
+
* ProbeStrategy — the per-provider two-tier probe plan
|
|
1084
|
+
* (subscription-account-probe #8, design D1).
|
|
1085
|
+
*
|
|
1086
|
+
* A `ProbePlan` says HOW to probe one provider's account, cheapest signal first:
|
|
1087
|
+
* - `{ kind: 'local' }` — no upstream call. The scheduler's free local tier reads
|
|
1088
|
+
* the account's token via the credential store; a missing/expired-unrefreshable
|
|
1089
|
+
* token is a dead account (recorded as a synthesized 401). A provider is
|
|
1090
|
+
* local-only until a cheap authed GET endpoint is VERIFIED (the omnicross
|
|
1091
|
+
* `// UNVERIFIED` convention) — Phase 1 ships codex/gemini/opencodego local-only
|
|
1092
|
+
* (LEAD OQ1: never GUESS a billable/wrong endpoint).
|
|
1093
|
+
* - `{ kind: 'upstream'; url; buildInit(token) }` — a minimal AUTHED GET the
|
|
1094
|
+
* scheduler issues through #3's proxy-aware `fetchUpstream`. Phase 1 wires ONLY
|
|
1095
|
+
* claude → `GET https://api.anthropic.com/v1/models` (a verified free list;
|
|
1096
|
+
* NEVER a billable completion). The scheduler adds the timeout `signal` +
|
|
1097
|
+
* `{ providerId, accountId }` ctx; `buildInit` supplies method + auth header.
|
|
1098
|
+
*
|
|
1099
|
+
* NEVER put a body / max_tokens here — a probe must cost nothing (design D1).
|
|
1100
|
+
*
|
|
1101
|
+
* @module @omnicross/daemon/probe/ProbeStrategy
|
|
1102
|
+
*/
|
|
1103
|
+
|
|
1104
|
+
/** How to probe one provider's account (design D1). */
|
|
1105
|
+
type ProbePlan = {
|
|
1106
|
+
kind: 'local';
|
|
1107
|
+
} | {
|
|
1108
|
+
kind: 'upstream';
|
|
1109
|
+
/** The cheap, free, authed GET endpoint. */
|
|
1110
|
+
url: string;
|
|
1111
|
+
/** Build the minimal request init (method + Authorization only — no body). */
|
|
1112
|
+
buildInit(token: string): RequestInit;
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* AccountHealthProbeScheduler — the scheduled ACTIVE account-health probe
|
|
1117
|
+
* (subscription-account-probe #8, design D1–D6).
|
|
1118
|
+
*
|
|
1119
|
+
* #2's health machine is PASSIVE — an account is only found dead when a REAL
|
|
1120
|
+
* request hits it and fails. This scheduler is the active complement: on a plain
|
|
1121
|
+
* `unref()`ed interval (omnicross has no cron dep) it runs a CHEAP per-account
|
|
1122
|
+
* probe and feeds the outcome into #2's EXISTING shared tracker
|
|
1123
|
+
* (`recordUpstreamOutcome`) — inventing no new marking path. Modeled EXACTLY on
|
|
1124
|
+
* `AccountHealthSweeper`: `start()` arms the timer, `dispose()` clears it, a
|
|
1125
|
+
* single-sweep re-entrancy guard prevents overlap.
|
|
1126
|
+
*
|
|
1127
|
+
* TWO-TIER, cheapest-first (design D1):
|
|
1128
|
+
* 1. FREE local — read the account's token via the credential store; no usable
|
|
1129
|
+
* token ⇒ dead ⇒ a synthesized `401` outcome, NO upstream call.
|
|
1130
|
+
* 2. Minimal AUTHED upstream GET (only providers with a VERIFIED cheap endpoint)
|
|
1131
|
+
* via #3's proxy-aware `fetchUpstream` with a short timeout.
|
|
1132
|
+
*
|
|
1133
|
+
* SAFE tracker mapping (LEAD constraint, mirrors #2's bare-429 discipline):
|
|
1134
|
+
* **401/403 → mark; 2xx → clear transient; 429 / 5xx / thrown/timeout → NEVER a
|
|
1135
|
+
* mark** (a probe rate-limit or upstream blip must not blacklist a healthy
|
|
1136
|
+
* account). So only 401/403/2xx are forwarded to the tracker; the rest are
|
|
1137
|
+
* history-only. NEVER a billable probe — the upstream tier is a free authed GET.
|
|
1138
|
+
*
|
|
1139
|
+
* NEVER-STRAND: marking flows through #2, whose ≥2-account gate (in the strategy's
|
|
1140
|
+
* schedulable derivation) keeps a marked SOLE account schedulable — so a probe can
|
|
1141
|
+
* never strand a single-account user. `onlyMultiAccount` (default) additionally
|
|
1142
|
+
* skips single-account providers entirely.
|
|
1143
|
+
*
|
|
1144
|
+
* ZERO REGRESSION: default `enabled:false` ⇒ `start()` never arms + `sweep()`
|
|
1145
|
+
* early-returns ⇒ no probes, no `/health` boolean, byte-identical.
|
|
1146
|
+
*
|
|
1147
|
+
* @module @omnicross/daemon/AccountHealthProbeScheduler
|
|
1148
|
+
*/
|
|
1149
|
+
|
|
1150
|
+
/** One rolling probe result (design D4; in-memory, cleared on restart). */
|
|
1151
|
+
interface ProbeRecord {
|
|
1152
|
+
/** Epoch ms of the probe. */
|
|
1153
|
+
ts: number;
|
|
1154
|
+
/** Whether the probe observed a HEALTHY signal (2xx upstream / token-present local). */
|
|
1155
|
+
ok: boolean;
|
|
1156
|
+
/** The HTTP status (`null` = thrown/timeout); absent for a token-present local record. */
|
|
1157
|
+
status?: number | null;
|
|
1158
|
+
/** Upstream round-trip latency (ms); absent for a local-tier record. */
|
|
1159
|
+
latencyMs?: number;
|
|
1160
|
+
/** Which tier produced this record. */
|
|
1161
|
+
tier: 'local' | 'upstream';
|
|
1162
|
+
}
|
|
1163
|
+
/** Per-account probe history for the authed admin surface (names account ids). */
|
|
1164
|
+
interface AccountProbeHistorySnapshot {
|
|
1165
|
+
providerId: string;
|
|
1166
|
+
accountId: string;
|
|
1167
|
+
records: ProbeRecord[];
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* The read surface the AUTHED admin route consumes (subscription-account-probe,
|
|
1171
|
+
* design D5). Structurally satisfied by {@link AccountHealthProbeScheduler}; typed
|
|
1172
|
+
* narrow so the admin layer carries no scheduler coupling.
|
|
1173
|
+
*/
|
|
1174
|
+
interface AccountProbeHistoryReader {
|
|
1175
|
+
getAllHistory(): AccountProbeHistorySnapshot[];
|
|
1176
|
+
}
|
|
1177
|
+
/** The narrow credential-store surface the scheduler reads (#1 seams). */
|
|
1178
|
+
interface ProbeCredentialStore {
|
|
1179
|
+
getFullConfig(): Promise<AccountTokensConfig>;
|
|
1180
|
+
getAccessTokenForAccount(providerId: SubscriptionProviderId, accountId: string): Promise<string | null>;
|
|
1181
|
+
}
|
|
1182
|
+
/** Proxy-aware upstream fetch signature (#3 `fetchUpstream`). */
|
|
1183
|
+
type ProbeFetch = typeof fetchUpstream;
|
|
1184
|
+
/** Injectable test seams (all default to production behavior). */
|
|
1185
|
+
interface ProbeSchedulerOptions {
|
|
1186
|
+
/** Injectable clock (ms). Default `Date.now`. */
|
|
1187
|
+
now?: () => number;
|
|
1188
|
+
/** Injectable proxy-aware fetch (#3). Default `fetchUpstream`. */
|
|
1189
|
+
fetchImpl?: ProbeFetch;
|
|
1190
|
+
/** Injectable inter-probe delay (stagger). Default a real `setTimeout`. */
|
|
1191
|
+
sleep?: (ms: number) => Promise<void>;
|
|
1192
|
+
/** Injectable per-provider probe-plan resolver. Default {@link probePlanFor}. */
|
|
1193
|
+
planFor?: (providerId: string) => ProbePlan;
|
|
1194
|
+
}
|
|
1195
|
+
declare class AccountHealthProbeScheduler implements AccountProbeHistoryReader {
|
|
1196
|
+
private readonly store;
|
|
1197
|
+
private readonly health;
|
|
1198
|
+
private readonly logger;
|
|
1199
|
+
private config;
|
|
1200
|
+
private timer;
|
|
1201
|
+
private sweeping;
|
|
1202
|
+
private readonly history;
|
|
1203
|
+
private readonly now;
|
|
1204
|
+
private readonly fetchImpl;
|
|
1205
|
+
private readonly sleep;
|
|
1206
|
+
private readonly planFor;
|
|
1207
|
+
constructor(store: ProbeCredentialStore, health: SubscriptionAccountHealth, logger: Logger, config: AccountProbeConfig, opts?: ProbeSchedulerOptions);
|
|
1208
|
+
/** Whether probing is enabled by the current config. */
|
|
1209
|
+
get enabled(): boolean;
|
|
1210
|
+
/**
|
|
1211
|
+
* Re-apply config to the live instance (the async `start.ts` loads the persisted
|
|
1212
|
+
* `accountProbe` segment after `buildDaemon`). Call BEFORE `start()`.
|
|
1213
|
+
*/
|
|
1214
|
+
configure(config: AccountProbeConfig): void;
|
|
1215
|
+
/** Arm the probe interval. No-op when disabled (zero regression). Idempotent. */
|
|
1216
|
+
start(): void;
|
|
1217
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
1218
|
+
dispose(): void;
|
|
1219
|
+
/**
|
|
1220
|
+
* One sweep: probe every ELIGIBLE account SEQUENTIALLY with a `staggerMs` gap.
|
|
1221
|
+
* Disabled ⇒ no-op. `onlyMultiAccount` skips single-account providers. Exposed
|
|
1222
|
+
* for tests; never throws.
|
|
1223
|
+
*/
|
|
1224
|
+
sweep(): Promise<void>;
|
|
1225
|
+
/**
|
|
1226
|
+
* Probe ONE account (design D1). Local tier first (dead token → synthesized 401,
|
|
1227
|
+
* no upstream); else the upstream tier when a verified endpoint exists. Records
|
|
1228
|
+
* the rolling history entry either way; returns whether the tracker was MARKED.
|
|
1229
|
+
*/
|
|
1230
|
+
probeAccount(providerId: SubscriptionProviderId, accountId: string): Promise<{
|
|
1231
|
+
ok: boolean;
|
|
1232
|
+
marked: boolean;
|
|
1233
|
+
}>;
|
|
1234
|
+
/** Per-account rolling history for the authed admin surface (design D5). */
|
|
1235
|
+
getAllHistory(): AccountProbeHistorySnapshot[];
|
|
1236
|
+
/**
|
|
1237
|
+
* The coarse, account-ANONYMOUS `/health` signal (design D5): `true` when no
|
|
1238
|
+
* probed account is currently unhealthy (per #2's tracker). No ids, no counts —
|
|
1239
|
+
* safe for the unauthenticated `/health`. Vacuously `true` when nothing probed.
|
|
1240
|
+
*/
|
|
1241
|
+
probedAccountsHealthy(now?: number): boolean;
|
|
1242
|
+
/**
|
|
1243
|
+
* Feed ONLY the account/auth-decisive statuses to #2 (LEAD constraint):
|
|
1244
|
+
* 401/403 → mark; 2xx → clear transient; 429 / 5xx / other 4xx / null →
|
|
1245
|
+
* NOT forwarded (never a mark). Returns whether a NEGATIVE mark was applied.
|
|
1246
|
+
*/
|
|
1247
|
+
private applyOutcome;
|
|
1248
|
+
/** Append a record, capping the ring at `historySize` (drop oldest). */
|
|
1249
|
+
private record;
|
|
1250
|
+
/** Read a bounded slice of the response body for the 403-ban sniff (never throws). */
|
|
1251
|
+
private readBounded;
|
|
1252
|
+
private key;
|
|
1253
|
+
private parseKey;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* auditReader — read + filter the date-rotated audit store (request-audit-log,
|
|
1258
|
+
* design D4/D6). Backs the AUTHED admin query only (the records carry IP/UA +
|
|
1259
|
+
* possibly bodies). Reads the relevant `audit-*.jsonl` files, parses defensively
|
|
1260
|
+
* (a torn final line never poisons a query), filters by key id + time window, and
|
|
1261
|
+
* returns NEWEST-FIRST up to a bounded limit.
|
|
1262
|
+
*
|
|
1263
|
+
* @module @omnicross/daemon/audit/auditReader
|
|
1264
|
+
*/
|
|
1265
|
+
|
|
1266
|
+
/** Filters for an audit query (all optional). */
|
|
1267
|
+
interface AuditQuery {
|
|
1268
|
+
/** Restrict to one outbound key id. */
|
|
1269
|
+
keyId?: string;
|
|
1270
|
+
/** Inclusive lower bound (epoch ms). */
|
|
1271
|
+
from?: number;
|
|
1272
|
+
/** Inclusive upper bound (epoch ms). */
|
|
1273
|
+
to?: number;
|
|
1274
|
+
/** Max rows (default 200, capped 2000). */
|
|
1275
|
+
limit?: number;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* auditQueryApi — the AUTHED `GET /admin/api/audit?keyId=&from=&to=&limit=`
|
|
1280
|
+
* handler (request-audit-log, design D6).
|
|
1281
|
+
*
|
|
1282
|
+
* Audit records carry client IP / user-agent (PII) and, when body capture is on,
|
|
1283
|
+
* redacted bodies — so unlike the coarse `/health` boolean they are served ONLY
|
|
1284
|
+
* behind the admin auth gate. This lives in its OWN helper module (the
|
|
1285
|
+
* #4/#8/#10 helper-module convention) so `adminApi.ts` — at its line cap — is not
|
|
1286
|
+
* touched: `AdminServer.dispatch` routes the path here directly, AFTER its auth
|
|
1287
|
+
* gate. NEVER unauthenticated, NEVER surfaced on `/health`.
|
|
1288
|
+
*
|
|
1289
|
+
* SECRET-FREE by construction: it returns exactly the stored records, which never
|
|
1290
|
+
* hold key material / tokens / Authorization (headers are never captured).
|
|
1291
|
+
*
|
|
1292
|
+
* @module @omnicross/daemon/admin/auditQueryApi
|
|
1293
|
+
*/
|
|
1294
|
+
|
|
1295
|
+
/** The read surface the AdminServer consumes (bootstrap binds it to the store). */
|
|
1296
|
+
type AuditQueryReader = (query: AuditQuery) => AuditRecord[];
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* billingStatusApi — the AUTHED `GET /admin/api/billing-status` handler
|
|
1300
|
+
* (billing-event-stream, design D5/P2).
|
|
1301
|
+
*
|
|
1302
|
+
* Returns the SECRET-FREE aggregate delivery status of the durable billing ledger
|
|
1303
|
+
* (total / delivered / pending counts) so the admin UI can show a delivery
|
|
1304
|
+
* indicator. Lives in its OWN helper module (the #4/#8/#10/#13 convention) so
|
|
1305
|
+
* `adminApi.ts` — at its line cap — is not touched: `AdminServer.dispatch` routes
|
|
1306
|
+
* the path here directly, AFTER its auth gate. Carries no secret and no event
|
|
1307
|
+
* payload — only counts.
|
|
1308
|
+
*
|
|
1309
|
+
* @module @omnicross/daemon/admin/billingStatusApi
|
|
1310
|
+
*/
|
|
1311
|
+
|
|
1312
|
+
/** The read surface the AdminServer consumes (bootstrap binds it to the ledger dir). */
|
|
1313
|
+
type BillingStatusReader = () => BillingDeliveryStatus;
|
|
1314
|
+
|
|
781
1315
|
/**
|
|
782
1316
|
* autoDisableStore.ts — the daemon's PROCESS-IN-MEMORY auto-disable store.
|
|
783
1317
|
*
|
|
@@ -919,13 +1453,129 @@ declare class ConfigFileProviderConfigSource implements ProviderConfigSource {
|
|
|
919
1453
|
|
|
920
1454
|
declare class JsonApiServerSettingsStore implements ApiServerSettingsStore {
|
|
921
1455
|
private readonly configPath;
|
|
922
|
-
|
|
1456
|
+
private readonly box;
|
|
1457
|
+
/**
|
|
1458
|
+
* @param configPath the daemon config.json whose `server` field is backed.
|
|
1459
|
+
* @param box OPTIONAL at-rest `SecretBox` (upstream-proxy). When set, the
|
|
1460
|
+
* `server.proxy.*` passwords are encrypted-on-`set` /
|
|
1461
|
+
* decrypted-on-`get` (the settings-store path is otherwise not
|
|
1462
|
+
* secret-aware — every OTHER server field is non-secret). Null
|
|
1463
|
+
* ⇒ passthrough (legacy/pure tests unchanged).
|
|
1464
|
+
*/
|
|
1465
|
+
constructor(configPath: string, box?: SecretBox | null);
|
|
923
1466
|
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
924
1467
|
set<T = unknown>(key: string, value: T): Promise<void>;
|
|
1468
|
+
/** Encrypt the proxy passwords + webhook + billing secrets before persisting (no-op without a box). */
|
|
1469
|
+
private encryptSecrets;
|
|
1470
|
+
/** Decrypt the proxy passwords + webhook + billing secrets on read (no-op without a box). */
|
|
1471
|
+
private decryptSecrets;
|
|
925
1472
|
/** Read the config.json, tolerating a missing/corrupt file (→ empty shape). */
|
|
926
1473
|
private readFile;
|
|
927
1474
|
}
|
|
928
1475
|
|
|
1476
|
+
/**
|
|
1477
|
+
* JsonPricingStore — the daemon's file-backed `PricingStore` port impl.
|
|
1478
|
+
*
|
|
1479
|
+
* Durable storage for the model pricing table, backed by a pretty-printed json
|
|
1480
|
+
* file (a sibling of `config.json`, `pricing.json` by convention) holding a
|
|
1481
|
+
* `PricingEntry[]`. Reads tolerate a missing/corrupt file (→ empty table);
|
|
1482
|
+
* every mutation rewrites the full array (the table is at most a few thousand
|
|
1483
|
+
* rows — same trade-off as `JsonOutboundKeyDb`). The table starts EMPTY: no
|
|
1484
|
+
* seeding — the first pricing-source refresh (or a manual upsert) populates it.
|
|
1485
|
+
*
|
|
1486
|
+
* Beyond the core port, the store exposes a STORE-LOCAL `delete` (the port is
|
|
1487
|
+
* frozen; the admin DELETE route calls the concrete class and then invalidates
|
|
1488
|
+
* the engine cache).
|
|
1489
|
+
*
|
|
1490
|
+
* @module @omnicross/daemon/ports/JsonPricingStore
|
|
1491
|
+
*/
|
|
1492
|
+
|
|
1493
|
+
declare class JsonPricingStore implements PricingStore {
|
|
1494
|
+
private readonly pricingPath;
|
|
1495
|
+
constructor(pricingPath: string);
|
|
1496
|
+
getAll(): Promise<PricingEntry[]>;
|
|
1497
|
+
/**
|
|
1498
|
+
* Insert or update one row keyed (providerId, modelId). `asUserEdit` stamps
|
|
1499
|
+
* user provenance (source 'user', userEdited, editedAt now) so the row is
|
|
1500
|
+
* protected from auto-overwrite during source refreshes; a non-user upsert
|
|
1501
|
+
* stamps source 'litellm' and clears nothing it should not (a plain source
|
|
1502
|
+
* upsert through this method overwrites the row wholesale).
|
|
1503
|
+
*/
|
|
1504
|
+
upsert(input: PricingEntryInput, asUserEdit: boolean): Promise<PricingEntry>;
|
|
1505
|
+
/**
|
|
1506
|
+
* Apply a batch fetched from a pricing source. Rows whose local copy is
|
|
1507
|
+
* user-edited are NOT applied — they come back as `{ current, incoming }`
|
|
1508
|
+
* conflicts; everything else is upserted (source 'litellm'). ONE file write
|
|
1509
|
+
* for the whole batch.
|
|
1510
|
+
*/
|
|
1511
|
+
bulkApplyFromSource(entries: PricingEntryInput[]): Promise<{
|
|
1512
|
+
applied: PricingEntry[];
|
|
1513
|
+
conflicts: Array<{
|
|
1514
|
+
current: PricingEntry;
|
|
1515
|
+
incoming: PricingEntryInput;
|
|
1516
|
+
}>;
|
|
1517
|
+
}>;
|
|
1518
|
+
/**
|
|
1519
|
+
* Apply per-row conflict decisions: 'overwrite' replaces the local row with
|
|
1520
|
+
* the incoming values (clearing the user-edited mark), 'skip' counts only.
|
|
1521
|
+
*/
|
|
1522
|
+
applyResolutions(resolutions: Array<{
|
|
1523
|
+
incoming: PricingEntryInput;
|
|
1524
|
+
action: 'overwrite' | 'skip';
|
|
1525
|
+
}>): Promise<PricingResolution>;
|
|
1526
|
+
/**
|
|
1527
|
+
* STORE-LOCAL (not on the core port): remove one row. Returns whether a row
|
|
1528
|
+
* was actually removed. The admin DELETE handler calls this then invalidates
|
|
1529
|
+
* the engine cache.
|
|
1530
|
+
*/
|
|
1531
|
+
delete(providerId: string, modelId: string): Promise<boolean>;
|
|
1532
|
+
/** Upsert into `rows` IN PLACE (no write) and return the resulting entry. */
|
|
1533
|
+
private applyUpsert;
|
|
1534
|
+
/** Read the pricing rows, tolerating a missing/corrupt file (→ empty list). */
|
|
1535
|
+
private readRows;
|
|
1536
|
+
private writeRows;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* cliLaunch — the admin API's "launch a coding CLI in a terminal, pointed at the
|
|
1541
|
+
* daemon" surface (dashboard parity with the desktop app's Code CLI tab).
|
|
1542
|
+
*
|
|
1543
|
+
* This is the EXTERNAL-terminal analogue of `commands/launch.ts`: it reuses the
|
|
1544
|
+
* same `@omnicross/cli-launcher` builders (which register one route on the
|
|
1545
|
+
* RESIDENT `ProviderProxy` and return the redirect env — `ANTHROPIC_BASE_URL` +
|
|
1546
|
+
* a one-shot ROUTE token, codex's `-c base_url=…` overrides, etc.), then opens a
|
|
1547
|
+
* NEW terminal window running the CLI with that env injected. The route token —
|
|
1548
|
+
* NOT an upstream credential — is the only secret in the env; it is removed when
|
|
1549
|
+
* the session is stopped (`onSessionEnd`).
|
|
1550
|
+
*
|
|
1551
|
+
* SECRET DISCIPLINE: the env carries a route token (proxy-scoped, revocable),
|
|
1552
|
+
* never a provider key. On win32 the token rides the spawned process environment
|
|
1553
|
+
* (inherited by the terminal), never the command line / a file on disk.
|
|
1554
|
+
*
|
|
1555
|
+
* @module @omnicross/daemon/admin/cliLaunch
|
|
1556
|
+
*/
|
|
1557
|
+
|
|
1558
|
+
/** Injectable PATH probe (tests stub this; default scans `process.env.PATH`). */
|
|
1559
|
+
type PathProbe = (candidate: string) => string | null;
|
|
1560
|
+
/** Open a NEW terminal window running `command [extraArgs…]` with `env` injected. */
|
|
1561
|
+
type TerminalOpener = (input: {
|
|
1562
|
+
cli: string;
|
|
1563
|
+
command: string;
|
|
1564
|
+
extraArgs: string[];
|
|
1565
|
+
env: Record<string, string>;
|
|
1566
|
+
cwd?: string;
|
|
1567
|
+
platform: NodeJS.Platform;
|
|
1568
|
+
}) => void;
|
|
1569
|
+
/**
|
|
1570
|
+
* Injectable shell runner for `POST /cli/:cli/install` (tests stub this; the
|
|
1571
|
+
* default execs the install command with a bounded timeout). Returns the host's
|
|
1572
|
+
* honest install outcome — `error` carries stderr/the failure reason.
|
|
1573
|
+
*/
|
|
1574
|
+
type CommandRunner = (command: string) => Promise<{
|
|
1575
|
+
ok: boolean;
|
|
1576
|
+
error?: string;
|
|
1577
|
+
}>;
|
|
1578
|
+
|
|
929
1579
|
/**
|
|
930
1580
|
* migration.ts — the export gather + import apply logic for the passphrase pack
|
|
931
1581
|
* (app-parity child 6, design D2/D3/D5).
|
|
@@ -1012,6 +1662,18 @@ interface AdminApiDeps {
|
|
|
1012
1662
|
readonly llmConfig: ConfigFileProviderConfigSource;
|
|
1013
1663
|
/** Named outbound-key store. */
|
|
1014
1664
|
readonly keyDb: OutboundKeyDb;
|
|
1665
|
+
/**
|
|
1666
|
+
* OPTIONAL voucher (redemption-card) store (voucher-redemption #9). When wired,
|
|
1667
|
+
* the `/admin/api/voucher` surface can generate/list/revoke cards. Absent ⇒ the
|
|
1668
|
+
* surface returns 501 (feature not available in this build).
|
|
1669
|
+
*/
|
|
1670
|
+
readonly voucherDb?: VoucherDb;
|
|
1671
|
+
/**
|
|
1672
|
+
* OPTIONAL per-key spend reader (outbound-key-policy). When wired, the key list
|
|
1673
|
+
* surfaces each key's OWN accumulated spend (daily/weekly/total) so the admin
|
|
1674
|
+
* can see spend-vs-limit. Leak-safe: only the key's own numbers are exposed.
|
|
1675
|
+
*/
|
|
1676
|
+
readonly keySpendReader?: KeySpendReader;
|
|
1015
1677
|
/** Outbound server settings store (server config persistence). */
|
|
1016
1678
|
readonly settingsStore: JsonApiServerSettingsStore;
|
|
1017
1679
|
/** The running outbound server (status + live applyConfig). */
|
|
@@ -1072,6 +1734,32 @@ interface AdminApiDeps {
|
|
|
1072
1734
|
* Wired from the concrete `credentialStore` in `bootstrap.ts`.
|
|
1073
1735
|
*/
|
|
1074
1736
|
readonly migrationCredentialStore: MigrationCredentialStore;
|
|
1737
|
+
/**
|
|
1738
|
+
* Usage-stats query facade (usage-pricing child) — delegates to the JSONL
|
|
1739
|
+
* usage-event store. Aggregates only; carries no key material.
|
|
1740
|
+
*/
|
|
1741
|
+
readonly usageRecorder: UsageRecorder;
|
|
1742
|
+
/** Pricing engine (upsert / source refresh / conflict resolution). */
|
|
1743
|
+
readonly pricingEngine: PricingEngine;
|
|
1744
|
+
/**
|
|
1745
|
+
* CONCRETE pricing store — ONLY for the store-local row `delete` (the core
|
|
1746
|
+
* `PricingStore` port is frozen; delete is a daemon-local extra).
|
|
1747
|
+
*/
|
|
1748
|
+
readonly pricingStore: JsonPricingStore;
|
|
1749
|
+
/**
|
|
1750
|
+
* External-terminal opener for the Code CLI launch route (dashboard parity).
|
|
1751
|
+
* Optional — defaults to the real `defaultTerminalOpener`; tests inject a spy so
|
|
1752
|
+
* no terminal window is actually spawned.
|
|
1753
|
+
*/
|
|
1754
|
+
readonly cliTerminalOpener?: TerminalOpener;
|
|
1755
|
+
/** Injectable PATH probe for CLI detection (tests fake "installed"). */
|
|
1756
|
+
readonly cliPathProbe?: PathProbe;
|
|
1757
|
+
/**
|
|
1758
|
+
* Injectable shell runner for the Code CLI install route. Optional — defaults
|
|
1759
|
+
* to the real `exec`-based runner; tests inject a stub so no package manager
|
|
1760
|
+
* actually runs.
|
|
1761
|
+
*/
|
|
1762
|
+
readonly cliCommandRunner?: CommandRunner;
|
|
1075
1763
|
}
|
|
1076
1764
|
/**
|
|
1077
1765
|
* Dispatch one `/admin/api/*` request. `path` is the already-extracted pathname
|
|
@@ -1098,8 +1786,9 @@ declare function handleAdminApi(req: http.IncomingMessage, res: http.ServerRespo
|
|
|
1098
1786
|
* - HARD SAFETY GATE: `networkBinding` (LAN/`0.0.0.0`) without a non-empty
|
|
1099
1787
|
* `admin.token` → `start` REFUSES to bind (logs + stays down, fail closed).
|
|
1100
1788
|
*
|
|
1101
|
-
* Routing: `GET /` (and `GET /admin`) → `
|
|
1102
|
-
* management API (`handleAdminApi`);
|
|
1789
|
+
* Routing: `GET /` (and `GET /admin`) → `302 /ui/`; `* /admin/api/*` → the
|
|
1790
|
+
* management API (`handleAdminApi`); `GET /ui[/...]` → the Control Panel static
|
|
1791
|
+
* UI (`handleUiStatic`, from `@omnicross/ui`); everything else → `404`.
|
|
1103
1792
|
*
|
|
1104
1793
|
* @module @omnicross/daemon/admin/AdminServer
|
|
1105
1794
|
*/
|
|
@@ -1108,6 +1797,40 @@ declare function handleAdminApi(req: http.IncomingMessage, res: http.ServerRespo
|
|
|
1108
1797
|
interface AdminServerDeps extends AdminApiDeps {
|
|
1109
1798
|
/** Read the resolved admin config (enabled/port/networkBinding/token). */
|
|
1110
1799
|
getAdminConfig: () => ResolvedAdminConfig;
|
|
1800
|
+
/**
|
|
1801
|
+
* Build the coarse, secret-free `/health` report (daemon-health-endpoint). A
|
|
1802
|
+
* shared closure over live handles (bootstrap wires the SAME builder into the
|
|
1803
|
+
* outbound server), served UNAUTHENTICATED — before the admin auth gate.
|
|
1804
|
+
*/
|
|
1805
|
+
getHealthReport: () => HealthReport;
|
|
1806
|
+
/**
|
|
1807
|
+
* Injected logger (configurable-logging) — the admin listener's OWN lifecycle
|
|
1808
|
+
* lines (bind/refuse/error) route through it so they honor the configured
|
|
1809
|
+
* level / format / file sink.
|
|
1810
|
+
*/
|
|
1811
|
+
logger: Logger;
|
|
1812
|
+
/**
|
|
1813
|
+
* OPTIONAL per-account probe-history reader (subscription-account-probe #8,
|
|
1814
|
+
* design D5). When wired (bootstrap → the `AccountHealthProbeScheduler`), the
|
|
1815
|
+
* AUTHED `GET /admin/api/account-probes` returns per-account probe history.
|
|
1816
|
+
* Absent ⇒ the route serves an empty list (byte-safe for embedders/tests that
|
|
1817
|
+
* do not wire it). Read-only + secret-free (ids + status labels, no tokens).
|
|
1818
|
+
*/
|
|
1819
|
+
probeHistoryReader?: AccountProbeHistoryReader;
|
|
1820
|
+
/**
|
|
1821
|
+
* OPTIONAL audit query reader (request-audit-log, design D6). When wired
|
|
1822
|
+
* (bootstrap → the date-rotated store), the AUTHED `GET /admin/api/audit`
|
|
1823
|
+
* returns filtered records. Absent ⇒ the route serves an empty list. The
|
|
1824
|
+
* records carry IP/UA/bodies → this route is behind the auth gate ONLY, NEVER
|
|
1825
|
+
* unauthenticated, NEVER on `/health`.
|
|
1826
|
+
*/
|
|
1827
|
+
auditReader?: AuditQueryReader;
|
|
1828
|
+
/**
|
|
1829
|
+
* OPTIONAL billing delivery-status reader (billing-event-stream, design D5).
|
|
1830
|
+
* When wired (bootstrap → the ledger dir), the AUTHED `GET /admin/api/billing-status`
|
|
1831
|
+
* returns secret-free total/delivered/pending counts. Absent ⇒ zeroed counts.
|
|
1832
|
+
*/
|
|
1833
|
+
billingStatusReader?: BillingStatusReader;
|
|
1111
1834
|
}
|
|
1112
1835
|
/** A live status snapshot for the admin listener. */
|
|
1113
1836
|
interface AdminServerStatus {
|
|
@@ -1122,6 +1845,8 @@ declare class AdminServer {
|
|
|
1122
1845
|
private server;
|
|
1123
1846
|
private boundPort;
|
|
1124
1847
|
private boundAddr;
|
|
1848
|
+
/** Control Panel dist dir (resolved once at first request; null = no UI). */
|
|
1849
|
+
private uiDist;
|
|
1125
1850
|
constructor(deps: AdminServerDeps);
|
|
1126
1851
|
/**
|
|
1127
1852
|
* Start the admin listener honoring the resolved admin config. Returns the
|
|
@@ -1142,24 +1867,6 @@ declare class AdminServer {
|
|
|
1142
1867
|
getStatus(): AdminServerStatus;
|
|
1143
1868
|
}
|
|
1144
1869
|
|
|
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
1870
|
/**
|
|
1164
1871
|
* JsonOutboundKeyDb — the daemon's file-backed `OutboundKeyDb` port impl
|
|
1165
1872
|
* (design D3).
|
|
@@ -1189,6 +1896,9 @@ declare class JsonOutboundKeyDb implements OutboundKeyDb$1 {
|
|
|
1189
1896
|
outboundApiKeysRevoke(id: string): Promise<boolean>;
|
|
1190
1897
|
outboundApiKeysTouchLastUsed(id: string): Promise<boolean>;
|
|
1191
1898
|
outboundApiKeysSetEnabled(id: string, enabled: boolean): Promise<boolean>;
|
|
1899
|
+
outboundApiKeysSetMaxConcurrency(id: string, maxConcurrency: number | null): Promise<boolean>;
|
|
1900
|
+
outboundApiKeysSetPolicy(id: string, policy: OutboundKeyPolicy): Promise<boolean>;
|
|
1901
|
+
outboundApiKeysMarkActivated(id: string, activatedAt: number): Promise<boolean>;
|
|
1192
1902
|
/** Apply `fn` to the row with `id`, persisting when it returns true. */
|
|
1193
1903
|
private mutateRow;
|
|
1194
1904
|
/** Read the key rows, tolerating a missing/corrupt file (→ empty list). */
|
|
@@ -1196,17 +1906,428 @@ declare class JsonOutboundKeyDb implements OutboundKeyDb$1 {
|
|
|
1196
1906
|
private writeRows;
|
|
1197
1907
|
}
|
|
1198
1908
|
|
|
1909
|
+
/**
|
|
1910
|
+
* AccountHealthSweeper — proactive account-health recovery tick
|
|
1911
|
+
* (subscription-account-health, design D6).
|
|
1912
|
+
*
|
|
1913
|
+
* The health tracker (`@omnicross/core` `SubscriptionAccountHealth`) already
|
|
1914
|
+
* self-heals LAZILY: an elapsed cooldown restores an account on the next
|
|
1915
|
+
* `isSchedulable` read, so CORRECTNESS never depends on this sweeper. What the
|
|
1916
|
+
* tick adds is PROACTIVITY for IDLE accounts (no traffic to trigger a lazy read):
|
|
1917
|
+
* - it fires the tracker's recovery SIGNAL (the seam #5 webhooks + #8
|
|
1918
|
+
* health-cron consume — this child only emits it), and
|
|
1919
|
+
* - it optionally nudges a fresh token for a recovered OAuth account so it
|
|
1920
|
+
* resumes instantly instead of paying refresh latency on its first request.
|
|
1921
|
+
*
|
|
1922
|
+
* Modeled EXACTLY on `TokenRefreshScheduler`: `start()` arms an `unref()`ed 60s
|
|
1923
|
+
* timer, `dispose()` clears it, and a single-sweep re-entrancy guard means a
|
|
1924
|
+
* long sweep never overlaps the next tick.
|
|
1925
|
+
*
|
|
1926
|
+
* @module @omnicross/daemon/AccountHealthSweeper
|
|
1927
|
+
*/
|
|
1928
|
+
|
|
1929
|
+
declare class AccountHealthSweeper {
|
|
1930
|
+
private readonly store;
|
|
1931
|
+
private readonly health;
|
|
1932
|
+
private readonly logger;
|
|
1933
|
+
private readonly intervalMs;
|
|
1934
|
+
private readonly leadMs;
|
|
1935
|
+
private timer;
|
|
1936
|
+
private sweeping;
|
|
1937
|
+
constructor(store: JsonSubscriptionCredentialStore, health: SubscriptionAccountHealth, logger: Logger, intervalMs?: number, leadMs?: number);
|
|
1938
|
+
/** Arm the sweep interval. Idempotent. The timer never holds the loop open. */
|
|
1939
|
+
start(): void;
|
|
1940
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
1941
|
+
dispose(): void;
|
|
1942
|
+
/**
|
|
1943
|
+
* One sweep: surface accounts that just recovered (emits the recovery signal
|
|
1944
|
+
* through the tracker's hook) and nudge a fresh token for any recovered OAuth
|
|
1945
|
+
* account whose token is near expiry. Exposed for tests. Never throws.
|
|
1946
|
+
*/
|
|
1947
|
+
sweep(now?: number): Promise<void>;
|
|
1948
|
+
/** Expiring within the lead window, refreshable, and not already dead. */
|
|
1949
|
+
private needsRefresh;
|
|
1950
|
+
/** Refresh one recovered account by id; failures are logged, never thrown. */
|
|
1951
|
+
private refreshOne;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/**
|
|
1955
|
+
* AuditPruneSweeper — the TTL prune for the audit store (request-audit-log,
|
|
1956
|
+
* design D4). Deletes whole `audit-YYYY-MM-DD.jsonl` files whose date is older
|
|
1957
|
+
* than `retentionDays` — a cheap file UNLINK, never a line-level rewrite of a
|
|
1958
|
+
* live file (which jsonl makes awkward). So the store never grows unbounded and
|
|
1959
|
+
* TTL is O(files).
|
|
1960
|
+
*
|
|
1961
|
+
* Modeled on the #8 `AccountHealthProbeScheduler` / `AccountHealthSweeper`:
|
|
1962
|
+
* `start()` arms an `unref()`ed interval, `dispose()` clears it, a single-sweep
|
|
1963
|
+
* re-entrancy guard prevents overlap. A prune ALSO runs once at boot (`start`
|
|
1964
|
+
* fires an immediate sweep). Disabled/zero-retention config ⇒ armed-off ⇒ no-op
|
|
1965
|
+
* (byte-identical zero regression). Never throws.
|
|
1966
|
+
*
|
|
1967
|
+
* @module @omnicross/daemon/audit/AuditPruneSweeper
|
|
1968
|
+
*/
|
|
1969
|
+
|
|
1970
|
+
declare class AuditPruneSweeper {
|
|
1971
|
+
private readonly auditDir;
|
|
1972
|
+
private readonly logger;
|
|
1973
|
+
private config;
|
|
1974
|
+
private readonly intervalMs;
|
|
1975
|
+
/** Injectable clock (ms) for deterministic tests. */
|
|
1976
|
+
private readonly now;
|
|
1977
|
+
private timer;
|
|
1978
|
+
private sweeping;
|
|
1979
|
+
constructor(auditDir: string, logger: Logger, config: AuditConfig, intervalMs?: number,
|
|
1980
|
+
/** Injectable clock (ms) for deterministic tests. */
|
|
1981
|
+
now?: () => number);
|
|
1982
|
+
/** Whether pruning is active (audit enabled). */
|
|
1983
|
+
get enabled(): boolean;
|
|
1984
|
+
/** Re-apply config to the live instance (boot + admin PUT hot-reload). */
|
|
1985
|
+
configure(config: AuditConfig): void;
|
|
1986
|
+
/**
|
|
1987
|
+
* Arm the prune interval AND run one prune immediately (boot cleanup). No-op
|
|
1988
|
+
* when audit is disabled (zero regression). Idempotent.
|
|
1989
|
+
*/
|
|
1990
|
+
start(): void;
|
|
1991
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
1992
|
+
dispose(): void;
|
|
1993
|
+
/**
|
|
1994
|
+
* One prune: unlink every audit date file strictly OLDER than the retention
|
|
1995
|
+
* cutoff (`now - retentionDays` days, at local-midnight granularity). Exposed
|
|
1996
|
+
* for tests; never throws. Returns the number of files removed.
|
|
1997
|
+
*/
|
|
1998
|
+
sweep(): Promise<number>;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
/**
|
|
2002
|
+
* AuditWriter — the daemon's file-backed audit sink (request-audit-log, design
|
|
2003
|
+
* D4/D5). Registered as `@omnicross/core`'s audit sink when audit is enabled; its
|
|
2004
|
+
* {@link record} is what `recordAudit` hands each assembled record to.
|
|
2005
|
+
*
|
|
2006
|
+
* FIRE-AND-FORGET (hard constraint): {@link record} DEFERS the fs append off the
|
|
2007
|
+
* caller's stack (an injectable `defer`, default a zero-delay timer — the
|
|
2008
|
+
* `UsageRecorder` precedent) and returns immediately, so the relay response path
|
|
2009
|
+
* never waits on disk I/O. A write error is swallowed + logged (a failing audit
|
|
2010
|
+
* store must never affect a relay). Each record is appended as ONE JSON line to
|
|
2011
|
+
* `audit/audit-YYYY-MM-DD.jsonl` (the record's LOCAL date), matching the
|
|
2012
|
+
* `usage-events.jsonl` pattern — no new dependency, TTL is a whole-file unlink.
|
|
2013
|
+
*
|
|
2014
|
+
* @module @omnicross/daemon/audit/AuditWriter
|
|
2015
|
+
*/
|
|
2016
|
+
|
|
2017
|
+
declare class AuditWriter {
|
|
2018
|
+
private readonly auditDir;
|
|
2019
|
+
private readonly logger;
|
|
2020
|
+
/** Deferral used by `record()` to schedule the append off the caller's path. */
|
|
2021
|
+
private readonly defer;
|
|
2022
|
+
private dirEnsured;
|
|
2023
|
+
constructor(auditDir: string, logger: Logger,
|
|
2024
|
+
/** Deferral used by `record()` to schedule the append off the caller's path. */
|
|
2025
|
+
defer?: (fn: () => void) => void);
|
|
2026
|
+
/**
|
|
2027
|
+
* Enqueue one record for append. Returns IMMEDIATELY (fire-and-forget); the fs
|
|
2028
|
+
* write happens on the deferred tick. A failure is logged, never thrown.
|
|
2029
|
+
*/
|
|
2030
|
+
record(record: AuditRecord): void;
|
|
2031
|
+
/**
|
|
2032
|
+
* Append synchronously — the awaitable form tests use to assert the line landed.
|
|
2033
|
+
* Ensures the `audit/` directory exists on first write (lazy, like the usage
|
|
2034
|
+
* store's lazy file creation).
|
|
2035
|
+
*/
|
|
2036
|
+
appendNow(record: AuditRecord): void;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
/**
|
|
2040
|
+
* BillingPublisher — the daemon's durable-first billing sink (billing-event-stream,
|
|
2041
|
+
* design D2/D4). Registered as `@omnicross/core`'s billing sink when billing is
|
|
2042
|
+
* enabled; its {@link record} is what `publishBillingEvent` hands each assembled
|
|
2043
|
+
* event.
|
|
2044
|
+
*
|
|
2045
|
+
* DURABLE-FIRST (THE key decision, design D2): {@link record}
|
|
2046
|
+
* 1. APPENDS the event as one JSON line to `billing/billing-YYYY-MM-DD.jsonl` —
|
|
2047
|
+
* SYNCHRONOUSLY, BEFORE any delivery attempt. This is the DURABLE source of
|
|
2048
|
+
* truth: once appended, the event is NEVER lost, even if the process crashes
|
|
2049
|
+
* or every delivery attempt fails. A billing ledger is a financial record.
|
|
2050
|
+
* 2. then, only when an `endpoint` is configured, schedules a best-effort POST
|
|
2051
|
+
* OFF the caller's stack (an injectable `defer`, default a zero-delay timer)
|
|
2052
|
+
* so {@link record} RETURNS IMMEDIATELY — a slow/failing endpoint never blocks
|
|
2053
|
+
* the caller (which is already off the relay response path). Ledger-only mode
|
|
2054
|
+
* (no `endpoint`) simply appends — an external tailer consumes the jsonl.
|
|
2055
|
+
* 3. on a POST ack, appends a delivery marker (`delivered-YYYY-MM-DD.jsonl`); on
|
|
2056
|
+
* failure the event stays UNdelivered in the ledger for the retry sweep +
|
|
2057
|
+
* external reconciliation. A delivery failure NEVER drops the event.
|
|
2058
|
+
*
|
|
2059
|
+
* At-least-once: the consumer dedupes on the event `id` (the request id). The
|
|
2060
|
+
* built-in POST optionally signs the body with `X-Omnicross-Billing-Signature:
|
|
2061
|
+
* sha256=<hmac hex>` (node `crypto`, no new dep). The signing `secret` is used
|
|
2062
|
+
* ONLY to sign — it NEVER appears in the payload or a log line. Egress is #3's
|
|
2063
|
+
* proxy-aware `fetchUpstream` (global proxy).
|
|
2064
|
+
*
|
|
2065
|
+
* @module @omnicross/daemon/billing/BillingPublisher
|
|
2066
|
+
*/
|
|
2067
|
+
|
|
2068
|
+
/** The minimal `fetch` shape the publisher POSTs through (proxy-aware by default). */
|
|
2069
|
+
type BillingFetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
2070
|
+
/** Constructor knobs (all optional; test seams for fetch/defer/clock). */
|
|
2071
|
+
interface BillingPublisherOptions {
|
|
2072
|
+
/** Egress fn; defaults to #3's proxy-aware `fetchUpstream` (global proxy only). */
|
|
2073
|
+
fetchImpl?: BillingFetch;
|
|
2074
|
+
/** Deferral used by `record()` to schedule the POST off the caller's path. */
|
|
2075
|
+
defer?: (fn: () => void) => void;
|
|
2076
|
+
timeoutMs?: number;
|
|
2077
|
+
/** Clock seam for delivery-marker timestamps (tests fix it). */
|
|
2078
|
+
now?: () => number;
|
|
2079
|
+
}
|
|
2080
|
+
declare class BillingPublisher {
|
|
2081
|
+
private readonly billingDir;
|
|
2082
|
+
private readonly logger;
|
|
2083
|
+
private config;
|
|
2084
|
+
private dirEnsured;
|
|
2085
|
+
private readonly fetchImpl;
|
|
2086
|
+
private readonly defer;
|
|
2087
|
+
private readonly timeoutMs;
|
|
2088
|
+
private readonly now;
|
|
2089
|
+
constructor(billingDir: string, logger: Logger, opts?: BillingPublisherOptions);
|
|
2090
|
+
/** Install/replace the live billing config (endpoint + secret + retry bound). */
|
|
2091
|
+
setConfig(config: BillingConfig | undefined): void;
|
|
2092
|
+
/**
|
|
2093
|
+
* Record one billing event. DURABLE-FIRST: append synchronously (the event is
|
|
2094
|
+
* now on disk, never lost), THEN schedule a best-effort POST off the caller's
|
|
2095
|
+
* stack (non-blocking; ledger-only when no endpoint). Returns IMMEDIATELY and
|
|
2096
|
+
* NEVER throws — a failing append/POST is logged, never propagated.
|
|
2097
|
+
*/
|
|
2098
|
+
record(event: BillingEvent): void;
|
|
2099
|
+
/**
|
|
2100
|
+
* Append the event as one JSON line to `billing-YYYY-MM-DD.jsonl` (the event's
|
|
2101
|
+
* LOCAL date). Synchronous — the awaitable form tests use to assert the ledger
|
|
2102
|
+
* line landed BEFORE any delivery. Ensures the `billing/` directory on first write.
|
|
2103
|
+
*/
|
|
2104
|
+
appendNow(event: BillingEvent): void;
|
|
2105
|
+
/**
|
|
2106
|
+
* One best-effort delivery attempt for an event ALREADY in the ledger. POSTs the
|
|
2107
|
+
* event JSON to the configured endpoint (optionally HMAC-signed); on a 2xx ack
|
|
2108
|
+
* appends a delivery marker and returns `true`. Any non-2xx / thrown / timed-out
|
|
2109
|
+
* attempt returns `false` — the event stays UNdelivered in the ledger (never
|
|
2110
|
+
* lost). NEVER rejects. A no-op `false` when no endpoint is configured.
|
|
2111
|
+
*/
|
|
2112
|
+
deliverNow(event: BillingEvent): Promise<boolean>;
|
|
2113
|
+
/**
|
|
2114
|
+
* Append a delivery marker `{ id, deliveredAt }` to `delivered-YYYY-MM-DD.jsonl`
|
|
2115
|
+
* (keyed by the EVENT's date so the reader finds both together). Idempotent at
|
|
2116
|
+
* the reconciliation layer — the reader unions marker ids into a delivered set,
|
|
2117
|
+
* so a duplicate marker is harmless. A marker-write failure is logged, never thrown.
|
|
2118
|
+
*/
|
|
2119
|
+
markDelivered(event: BillingEvent): void;
|
|
2120
|
+
private ensureDir;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
/**
|
|
2124
|
+
* BillingRetrySweeper — the bounded retry + reconciliation sweep for the billing
|
|
2125
|
+
* ledger (billing-event-stream, design D5). Periodically re-POSTs UNdelivered
|
|
2126
|
+
* ledger events (via the publisher's built-in delivery) with a bounded age:
|
|
2127
|
+
* - an undelivered event WITHIN `maxRetryAgeMs` of its timestamp is re-POSTed
|
|
2128
|
+
* (the request id makes the re-POST safe — the consumer dedupes);
|
|
2129
|
+
* - an undelivered event PAST `maxRetryAgeMs` is LEFT in the ledger for external
|
|
2130
|
+
* reconciliation — it is NEVER deleted (a delivery failure must never drop a
|
|
2131
|
+
* billing record; the ledger is a financial record, so there is NO prune here,
|
|
2132
|
+
* unlike the #13 audit TTL);
|
|
2133
|
+
* - a DELIVERED event (has a marker) is never re-sent (delivery-marking prevents
|
|
2134
|
+
* double delivery).
|
|
2135
|
+
*
|
|
2136
|
+
* Modeled on the #8/#13 sweepers: `start()` arms an `unref()`ed interval, a
|
|
2137
|
+
* single-sweep re-entrancy guard prevents overlap, `dispose()` clears it. A sweep
|
|
2138
|
+
* ALSO runs once at boot (`start` fires an immediate sweep). Disabled/ledger-only
|
|
2139
|
+
* (no endpoint) ⇒ armed-off ⇒ no-op. Never throws.
|
|
2140
|
+
*
|
|
2141
|
+
* @module @omnicross/daemon/billing/BillingRetrySweeper
|
|
2142
|
+
*/
|
|
2143
|
+
|
|
2144
|
+
declare class BillingRetrySweeper {
|
|
2145
|
+
private readonly billingDir;
|
|
2146
|
+
private readonly publisher;
|
|
2147
|
+
private readonly logger;
|
|
2148
|
+
private config;
|
|
2149
|
+
private readonly intervalMs;
|
|
2150
|
+
/** Injectable clock (ms) for deterministic tests. */
|
|
2151
|
+
private readonly now;
|
|
2152
|
+
private timer;
|
|
2153
|
+
private sweeping;
|
|
2154
|
+
constructor(billingDir: string, publisher: BillingPublisher, logger: Logger, config: BillingConfig, intervalMs?: number,
|
|
2155
|
+
/** Injectable clock (ms) for deterministic tests. */
|
|
2156
|
+
now?: () => number);
|
|
2157
|
+
/** Whether retrying is active: billing enabled AND an endpoint is configured. */
|
|
2158
|
+
get enabled(): boolean;
|
|
2159
|
+
/** Re-apply config to the live instance (boot + admin PUT hot-reload). */
|
|
2160
|
+
configure(config: BillingConfig): void;
|
|
2161
|
+
/**
|
|
2162
|
+
* Arm the retry interval AND run one sweep immediately (boot catch-up for events
|
|
2163
|
+
* that failed to deliver while the daemon was down). No-op when disabled or in
|
|
2164
|
+
* ledger-only mode (no endpoint to POST to). Idempotent.
|
|
2165
|
+
*/
|
|
2166
|
+
start(): void;
|
|
2167
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
2168
|
+
dispose(): void;
|
|
2169
|
+
/**
|
|
2170
|
+
* One sweep: re-POST every UNdelivered ledger event still within
|
|
2171
|
+
* `maxRetryAgeMs`; leave over-age undelivered events for reconciliation (NEVER
|
|
2172
|
+
* deleted). Exposed for tests; never throws. Returns the number of events a
|
|
2173
|
+
* re-POST was attempted for.
|
|
2174
|
+
*/
|
|
2175
|
+
sweep(): Promise<number>;
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
/**
|
|
2179
|
+
* TokenRefreshScheduler — proactive background OAuth token refresh
|
|
2180
|
+
* (external-cli-sync).
|
|
2181
|
+
*
|
|
2182
|
+
* The auth strategies already refresh LAZILY (lead-window check before each
|
|
2183
|
+
* request + 401 retry), but a daemon that sits idle past a token's lifetime
|
|
2184
|
+
* pays the refresh latency — or a dead rotated token — on the first request.
|
|
2185
|
+
* This scheduler sweeps every account of every OAuth provider on an interval
|
|
2186
|
+
* and refreshes any token entering the expiry lead window.
|
|
2187
|
+
*
|
|
2188
|
+
* Safety properties:
|
|
2189
|
+
* - the store coalesces in-flight refreshes per account, so a sweep can never
|
|
2190
|
+
* double-spend a single-use refresh token against a concurrent lazy refresh;
|
|
2191
|
+
* - accounts already flagged `expired` are skipped (a dead refresh token is
|
|
2192
|
+
* not retried every tick — recovery is the external-import fallback or a
|
|
2193
|
+
* re-login);
|
|
2194
|
+
* - the ACTIVE account routes through the provider's active refresher (which
|
|
2195
|
+
* carries the external CLI import fallback); non-active accounts refresh
|
|
2196
|
+
* by id;
|
|
2197
|
+
* - one sweep runs at a time (a long sweep never overlaps the next tick).
|
|
2198
|
+
*
|
|
2199
|
+
* Modeled on `ApiKeyPoolService`'s interval lifecycle: `start()` arms an
|
|
2200
|
+
* `unref()`ed timer, `dispose()` clears it.
|
|
2201
|
+
*
|
|
2202
|
+
* @module @omnicross/daemon/TokenRefreshScheduler
|
|
2203
|
+
*/
|
|
2204
|
+
|
|
2205
|
+
declare class TokenRefreshScheduler {
|
|
2206
|
+
private readonly store;
|
|
2207
|
+
private readonly logger;
|
|
2208
|
+
private readonly intervalMs;
|
|
2209
|
+
private readonly leadMs;
|
|
2210
|
+
private timer;
|
|
2211
|
+
private sweeping;
|
|
2212
|
+
constructor(store: JsonSubscriptionCredentialStore, logger: Logger, intervalMs?: number, leadMs?: number);
|
|
2213
|
+
/** Arm the sweep interval. Idempotent. The timer never holds the loop open. */
|
|
2214
|
+
start(): void;
|
|
2215
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
2216
|
+
dispose(): void;
|
|
2217
|
+
/** One sweep over every account of every OAuth provider. Exposed for tests. */
|
|
2218
|
+
sweep(now?: number): Promise<void>;
|
|
2219
|
+
/** Expiring within the lead window, refreshable, and not already dead. */
|
|
2220
|
+
private needsRefresh;
|
|
2221
|
+
/** Refresh one account; failures are logged, never thrown (the store has
|
|
2222
|
+
* already flagged the account `expired`). */
|
|
2223
|
+
private refreshOne;
|
|
2224
|
+
private refreshActive;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
/**
|
|
2228
|
+
* WebhookDispatcher — the daemon-side fire-and-forget webhook sender
|
|
2229
|
+
* (webhook-notifications, design D4/D5/D6).
|
|
2230
|
+
*
|
|
2231
|
+
* Registered as the core emit sink at bootstrap. {@link emit} pushes the event
|
|
2232
|
+
* onto a bounded in-memory queue and RETURNS IMMEDIATELY — it never awaits a
|
|
2233
|
+
* send and never throws, so a slow/failing/throwing destination can NEVER block
|
|
2234
|
+
* or delay a relay request (the HARD contract). An async drain loop then:
|
|
2235
|
+
* - matches each event to every enabled destination whose `events` filter
|
|
2236
|
+
* allows it (absent/empty ⇒ all kinds),
|
|
2237
|
+
* - sends to the matching destinations CONCURRENTLY,
|
|
2238
|
+
* - retries a failed send with bounded exponential backoff up to
|
|
2239
|
+
* `maxAttempts`, then LOGS (via the injected #10 logger) and DROPS it.
|
|
2240
|
+
* The queue is bounded (drop-OLDEST + a one-shot warn) so a runaway source can't
|
|
2241
|
+
* OOM the process.
|
|
2242
|
+
*
|
|
2243
|
+
* Egress is #3's proxy-aware `fetchUpstream` with NO ctx → the GLOBAL proxy only
|
|
2244
|
+
* (webhooks aren't per-account). Signing uses node `crypto` (no new deps):
|
|
2245
|
+
* `custom` → optional `X-Omnicross-Signature: sha256=<hex hmac of body>`;
|
|
2246
|
+
* `feishu` → Feishu's `timestamp` + `sign` (HMAC-SHA256 base64 of
|
|
2247
|
+
* `timestamp\nsecret`) envelope. The destination `secret` is used ONLY to sign;
|
|
2248
|
+
* it is NEVER placed in a payload or a log line.
|
|
2249
|
+
*
|
|
2250
|
+
* @module @omnicross/daemon/webhook/WebhookDispatcher
|
|
2251
|
+
*/
|
|
2252
|
+
|
|
2253
|
+
/** The minimal `fetch` shape the dispatcher POSTs through (proxy-aware by default). */
|
|
2254
|
+
type WebhookFetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
2255
|
+
/** Outcome of a single delivery attempt (used by the admin test path). */
|
|
2256
|
+
interface WebhookDeliveryResult {
|
|
2257
|
+
ok: boolean;
|
|
2258
|
+
status?: number;
|
|
2259
|
+
error?: string;
|
|
2260
|
+
}
|
|
2261
|
+
/** Constructor knobs (all optional; test seams for fetch/logger/sleep/clock). */
|
|
2262
|
+
interface WebhookDispatcherOptions {
|
|
2263
|
+
/** Egress fn; defaults to #3's proxy-aware `fetchUpstream` (global proxy only). */
|
|
2264
|
+
fetchImpl?: WebhookFetch;
|
|
2265
|
+
/** Injected #10 logger for drop/debug lines (never logs a secret). */
|
|
2266
|
+
logger?: Logger;
|
|
2267
|
+
maxAttempts?: number;
|
|
2268
|
+
queueMax?: number;
|
|
2269
|
+
timeoutMs?: number;
|
|
2270
|
+
baseBackoffMs?: number;
|
|
2271
|
+
/** Backoff sleep seam (tests inject an instant/fake sleep). */
|
|
2272
|
+
sleep?: (ms: number) => Promise<void>;
|
|
2273
|
+
/** Clock seam for the `test` event `at` + Feishu `timestamp` (tests fix it). */
|
|
2274
|
+
now?: () => number;
|
|
2275
|
+
}
|
|
2276
|
+
declare class WebhookDispatcher {
|
|
2277
|
+
private config;
|
|
2278
|
+
private readonly queue;
|
|
2279
|
+
private draining;
|
|
2280
|
+
private warnedFull;
|
|
2281
|
+
private readonly fetchImpl;
|
|
2282
|
+
private readonly logger;
|
|
2283
|
+
private readonly maxAttempts;
|
|
2284
|
+
private readonly queueMax;
|
|
2285
|
+
private readonly timeoutMs;
|
|
2286
|
+
private readonly baseBackoffMs;
|
|
2287
|
+
private readonly sleep;
|
|
2288
|
+
private readonly now;
|
|
2289
|
+
constructor(opts?: WebhookDispatcherOptions);
|
|
2290
|
+
/** Install/replace the live webhook config (destinations + master switch). */
|
|
2291
|
+
setConfig(config: WebhookConfig | undefined): void;
|
|
2292
|
+
/**
|
|
2293
|
+
* Enqueue an event and return IMMEDIATELY (fire-and-forget). NEVER awaits a
|
|
2294
|
+
* send, NEVER throws — the drain loop does all sending on a side channel. A
|
|
2295
|
+
* full queue drops the OLDEST event (with a one-shot warn) so a runaway source
|
|
2296
|
+
* can't OOM the process.
|
|
2297
|
+
*/
|
|
2298
|
+
emit(event: WebhookEvent): void;
|
|
2299
|
+
/** Drain the queue, sending each event to its matching destinations concurrently. */
|
|
2300
|
+
private drain;
|
|
2301
|
+
/** The enabled destinations whose event filter admits this kind (empty ⇒ all). */
|
|
2302
|
+
private matchingDestinations;
|
|
2303
|
+
/** Send with bounded exponential backoff; log-and-drop after `maxAttempts`. */
|
|
2304
|
+
private sendWithRetry;
|
|
2305
|
+
/** One POST attempt. Returns an outcome; a thrown error becomes `{ ok:false }`. */
|
|
2306
|
+
private sendOnce;
|
|
2307
|
+
/**
|
|
2308
|
+
* ADMIN test path (design D8): deliver a `test` event to ONE destination and
|
|
2309
|
+
* AWAIT the single-attempt result. This is the ONLY awaited send — it runs on
|
|
2310
|
+
* the admin request path (an operator clicking "Test"), NEVER on a relay path,
|
|
2311
|
+
* so awaiting it is safe. Finds the destination regardless of its `enabled`
|
|
2312
|
+
* flag or the master switch (an explicit operator action).
|
|
2313
|
+
*/
|
|
2314
|
+
deliverTest(destinationId: string): Promise<WebhookDeliveryResult>;
|
|
2315
|
+
}
|
|
2316
|
+
|
|
1199
2317
|
/**
|
|
1200
2318
|
* bootstrap.ts — `buildDaemon` wires `@omnicross/core`'s `ProviderProxy` +
|
|
1201
2319
|
* `OutboundApiServer` STANDALONE (design D6).
|
|
1202
2320
|
*
|
|
1203
2321
|
* A DB-backed embedder wires the same `@omnicross/core` surface differently;
|
|
1204
|
-
* this standalone wiring makes
|
|
1205
|
-
* DB-backed ones) and
|
|
2322
|
+
* this standalone wiring makes SUBSTITUTIONS (file-backed ports replace
|
|
2323
|
+
* DB-backed ones) and SUBTRACTIONS:
|
|
1206
2324
|
* - no `CompletionService` (the BYO proxy path doesn't need it),
|
|
1207
|
-
* - no `apiKeyPool` / `usageRecorder` (optional `ProviderProxyDeps`),
|
|
1208
2325
|
* - no `anthropicIngressHandlerFactory` (→ `/v1/messages` returns 502 by core's
|
|
1209
2326
|
* existing contract — no daemon code needed).
|
|
2327
|
+
* (`apiKeyPool` and `usageRecorder` are NO LONGER subtracted: the pool is wired
|
|
2328
|
+
* for multi-key load balancing, and the usage recorder is wired over the
|
|
2329
|
+
* file-backed pricing/usage stores so every served request is cost-stamped and
|
|
2330
|
+
* persisted to `usage-events.jsonl`.)
|
|
1210
2331
|
*
|
|
1211
2332
|
* `getProviderProxy` / `getOutboundApiServer` are module singletons, so the boot
|
|
1212
2333
|
* smoke test calls `__resetProviderProxyForTests` / `__resetOutboundApiServerForTests`
|
|
@@ -1239,10 +2360,26 @@ interface DaemonPaths {
|
|
|
1239
2360
|
* hit a real token endpoint. Absent → the global `fetch`.
|
|
1240
2361
|
*/
|
|
1241
2362
|
oauthExchangeFetch?: FetchLike;
|
|
2363
|
+
/**
|
|
2364
|
+
* TEST SEAM (optional): override the Code CLI external-terminal opener so tests
|
|
2365
|
+
* never spawn a window. Absent → the real `defaultTerminalOpener`.
|
|
2366
|
+
*/
|
|
2367
|
+
cliTerminalOpener?: TerminalOpener;
|
|
2368
|
+
/**
|
|
2369
|
+
* TEST SEAM (optional): override the Code CLI PATH probe so tests can fake an
|
|
2370
|
+
* installed CLI. Absent → the real PATH scan.
|
|
2371
|
+
*/
|
|
2372
|
+
cliPathProbe?: PathProbe;
|
|
2373
|
+
/**
|
|
2374
|
+
* TEST SEAM (optional): override the Code CLI install command runner so tests
|
|
2375
|
+
* never invoke a real package manager. Absent → the real `exec`-based runner.
|
|
2376
|
+
*/
|
|
2377
|
+
cliCommandRunner?: CommandRunner;
|
|
1242
2378
|
}
|
|
1243
2379
|
/** The constructed daemon handles the CLI commands operate on. */
|
|
1244
2380
|
interface Daemon {
|
|
1245
|
-
|
|
2381
|
+
/** The injected `Logger` port (a `ConfigurableLogger` built from `config.logging`). */
|
|
2382
|
+
readonly logger: Logger;
|
|
1246
2383
|
readonly llmConfig: ConfigFileProviderConfigSource;
|
|
1247
2384
|
readonly keyDb: JsonOutboundKeyDb;
|
|
1248
2385
|
readonly settingsStore: JsonApiServerSettingsStore;
|
|
@@ -1264,8 +2401,69 @@ interface Daemon {
|
|
|
1264
2401
|
/** Subscription account service (token-free `listAll`) — now exposed for the
|
|
1265
2402
|
* admin dashboard's read-only accounts panel (RT3). */
|
|
1266
2403
|
readonly subscriptionAccounts: SubscriptionAccountService;
|
|
2404
|
+
/** File-backed pricing table (`pricing.json`; concrete for the admin DELETE). */
|
|
2405
|
+
readonly pricingStore: JsonPricingStore;
|
|
2406
|
+
/** Pricing engine (cost calc + source refresh + conflict resolution). */
|
|
2407
|
+
readonly pricingEngine: PricingEngine;
|
|
2408
|
+
/** Usage recorder over `usage-events.jsonl` — also the admin stats query facade. */
|
|
2409
|
+
readonly usageRecorder: UsageRecorder;
|
|
1267
2410
|
/** The localhost admin/dashboard HTTP listener (RT3). Started by `start.ts`. */
|
|
1268
2411
|
readonly adminServer: AdminServer;
|
|
2412
|
+
/**
|
|
2413
|
+
* Proactive background OAuth refresh sweep (external-cli-sync). NOT started
|
|
2414
|
+
* here — `start.ts` arms it for the resident daemon; the short-lived `launch`
|
|
2415
|
+
* boot leaves it off (the lazy strategy refresh covers a single session) but
|
|
2416
|
+
* still disposes it in cleanup.
|
|
2417
|
+
*/
|
|
2418
|
+
readonly tokenRefreshScheduler: TokenRefreshScheduler;
|
|
2419
|
+
/**
|
|
2420
|
+
* Proactive account-health recovery sweep (subscription-account-health, D6).
|
|
2421
|
+
* NOT started here — `start.ts` arms it for the resident daemon; disposed in
|
|
2422
|
+
* cleanup. Correctness never depends on it (health self-heals lazily on read).
|
|
2423
|
+
*/
|
|
2424
|
+
readonly accountHealthSweeper: AccountHealthSweeper;
|
|
2425
|
+
/**
|
|
2426
|
+
* Scheduled ACTIVE account-health probe (subscription-account-probe #8).
|
|
2427
|
+
* Constructed armed-off with default config (`enabled:false`); `start.ts`
|
|
2428
|
+
* `configure(...)`s it from the persisted `accountProbe` segment and starts it
|
|
2429
|
+
* ONLY when enabled. Disposed in cleanup.
|
|
2430
|
+
*/
|
|
2431
|
+
readonly accountHealthProbeScheduler: AccountHealthProbeScheduler;
|
|
2432
|
+
/**
|
|
2433
|
+
* Fire-and-forget webhook sender (webhook-notifications). Wired into the core
|
|
2434
|
+
* emit sink + the #2 health signals by `start.ts`/admin PUT via
|
|
2435
|
+
* `applyWebhookConfig`. INERT until a config enables it (zero regression).
|
|
2436
|
+
*/
|
|
2437
|
+
readonly webhookDispatcher: WebhookDispatcher;
|
|
2438
|
+
/**
|
|
2439
|
+
* File-backed audit sink (request-audit-log) — appends each captured record to
|
|
2440
|
+
* `audit/audit-YYYY-MM-DD.jsonl` fire-and-forget. Registered as the core sink
|
|
2441
|
+
* (via the audit runtime slot) by `start.ts`/admin PUT ONLY when the `audit`
|
|
2442
|
+
* segment is enabled. INERT until then (no sink ⇒ capture hook is a no-op).
|
|
2443
|
+
*/
|
|
2444
|
+
readonly auditWriter: AuditWriter;
|
|
2445
|
+
/**
|
|
2446
|
+
* TTL prune for the audit store (request-audit-log) — unlinks date files past
|
|
2447
|
+
* `retentionDays`. Armed-off; `start.ts` configures from the persisted `audit`
|
|
2448
|
+
* segment + starts it (running one prune at boot) ONLY when enabled. Disposed
|
|
2449
|
+
* in cleanup.
|
|
2450
|
+
*/
|
|
2451
|
+
readonly auditPruneSweeper: AuditPruneSweeper;
|
|
2452
|
+
/**
|
|
2453
|
+
* Durable-first billing publisher (billing-event-stream) — appends each event
|
|
2454
|
+
* to `billing/billing-YYYY-MM-DD.jsonl` FIRST, then best-effort POSTs it.
|
|
2455
|
+
* Registered as the core billing sink (via the billing runtime slot) by
|
|
2456
|
+
* `start.ts`/admin PUT ONLY when the `billing` segment is enabled. INERT until
|
|
2457
|
+
* then (no sink ⇒ `publishBillingEvent` is a no-op).
|
|
2458
|
+
*/
|
|
2459
|
+
readonly billingPublisher: BillingPublisher;
|
|
2460
|
+
/**
|
|
2461
|
+
* Bounded retry + reconciliation sweep for the billing ledger
|
|
2462
|
+
* (billing-event-stream) — re-POSTs undelivered events within `maxRetryAgeMs`,
|
|
2463
|
+
* NEVER deletes. Armed-off; `start.ts` configures from the persisted `billing`
|
|
2464
|
+
* segment + starts it ONLY when enabled with an endpoint. Disposed in cleanup.
|
|
2465
|
+
*/
|
|
2466
|
+
readonly billingRetrySweeper: BillingRetrySweeper;
|
|
1269
2467
|
}
|
|
1270
2468
|
/**
|
|
1271
2469
|
* Construct the standalone daemon from a loaded config + on-disk paths. Does NOT
|
|
@@ -1292,22 +2490,135 @@ declare function buildDaemon(config: DaemonConfig, paths: DaemonPaths): Daemon;
|
|
|
1292
2490
|
declare function resetDaemonSingletonsForTests(): void;
|
|
1293
2491
|
|
|
1294
2492
|
/**
|
|
1295
|
-
*
|
|
1296
|
-
*
|
|
1297
|
-
*
|
|
1298
|
-
*
|
|
1299
|
-
*
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1302
|
-
*
|
|
2493
|
+
* health.ts — the pure `/health` report builder (daemon-health-endpoint, D2).
|
|
2494
|
+
*
|
|
2495
|
+
* `buildHealthReport(deps)` returns a COARSE, SECRET-FREE {@link HealthReport}
|
|
2496
|
+
* from cheap SYNCHRONOUS probes. It NEVER hits an upstream, NEVER blocks, and
|
|
2497
|
+
* NEVER embeds a token/email/config-value/record-count — the body is served
|
|
2498
|
+
* UNAUTHENTICATED (before the admin auth gate, and optionally before the outbound
|
|
2499
|
+
* key-auth), so it must expose nothing sensitive.
|
|
2500
|
+
*
|
|
2501
|
+
* Each check is a caller-supplied boolean thunk; a thunk that THROWS collapses to
|
|
2502
|
+
* `false` (a health probe must never crash the process). Status math:
|
|
2503
|
+
* - CRITICAL (`config`, `credentialStore`): a false critical → `error`.
|
|
2504
|
+
* - READINESS (`outboundServer`): the serving-path signal — false → `degraded`.
|
|
2505
|
+
* - INFORMATIONAL (`adminServer`): reported in `checks` but does NOT affect
|
|
2506
|
+
* `status` — a disabled/loopback dashboard must not fail the TRAFFIC-port
|
|
2507
|
+
* probe (the whole point of the outbound secondary mount).
|
|
2508
|
+
* `error` and `degraded` both map to HTTP 503 (see `healthHttpStatus`), so a
|
|
2509
|
+
* probe treats "not fully ready" as not-ready.
|
|
2510
|
+
*
|
|
2511
|
+
* @module @omnicross/daemon/admin/health
|
|
2512
|
+
*/
|
|
2513
|
+
|
|
2514
|
+
/** The coarse dependency probes + process-stat seams the builder reads. */
|
|
2515
|
+
interface HealthReportDeps {
|
|
2516
|
+
/** The daemon package version (non-secret; also on the identity header). */
|
|
2517
|
+
version: string;
|
|
2518
|
+
/** CRITICAL: the bootstrap config is present/loaded. */
|
|
2519
|
+
configPresent: () => boolean;
|
|
2520
|
+
/** CRITICAL: the credential store is constructed + its file readable (no decrypt). */
|
|
2521
|
+
credentialStoreReadable: () => boolean;
|
|
2522
|
+
/** Non-critical: the outbound `/v1/*` serving listener is running. */
|
|
2523
|
+
outboundServerRunning: () => boolean;
|
|
2524
|
+
/** Non-critical: the admin listener is running. */
|
|
2525
|
+
adminServerRunning: () => boolean;
|
|
2526
|
+
/**
|
|
2527
|
+
* OPTIONAL coarse account-probe signal (subscription-account-probe #8, design
|
|
2528
|
+
* D5). Returns `true` when no PROBED account is currently unhealthy, `false`
|
|
2529
|
+
* when one is, or `undefined` when probing is DISABLED (→ the check is OMITTED,
|
|
2530
|
+
* keeping the `/health` body byte-identical when the feature is off).
|
|
2531
|
+
* INFORMATIONAL: it never affects `status` (a marked account is an operational
|
|
2532
|
+
* signal, not a daemon-readiness failure). Account-anonymous — no ids/counts.
|
|
2533
|
+
*/
|
|
2534
|
+
subscriptionAccountsHealthy?: () => boolean | undefined;
|
|
2535
|
+
/** TEST SEAM: process memory snapshot (defaults to `process.memoryUsage`). */
|
|
2536
|
+
memoryUsage?: () => NodeJS.MemoryUsage;
|
|
2537
|
+
/** TEST SEAM: process uptime seconds (defaults to `process.uptime`). */
|
|
2538
|
+
uptimeSeconds?: () => number;
|
|
2539
|
+
/** TEST SEAM: wall clock ms (defaults to `Date.now`). */
|
|
2540
|
+
now?: () => number;
|
|
2541
|
+
}
|
|
2542
|
+
/** Build the coarse, secret-free health report (design D2). */
|
|
2543
|
+
declare function buildHealthReport(deps: HealthReportDeps): HealthReport;
|
|
2544
|
+
|
|
2545
|
+
/**
|
|
2546
|
+
* ConfigurableLogger — a `Logger` port impl with level / format / file sink
|
|
2547
|
+
* (configurable-logging, design D3). Supersedes `ConsoleLogger` as the injected
|
|
2548
|
+
* daemon logger.
|
|
2549
|
+
*
|
|
2550
|
+
* - LEVEL: numeric severity `error(0) < warn(1) < info(2) < debug(3)`; a message
|
|
2551
|
+
* whose level is BELOW the configured threshold (higher ordinal) is dropped.
|
|
2552
|
+
* Default threshold = `debug` (prints everything).
|
|
2553
|
+
* - FORMAT: `text` (the legacy `console.*(message, meta)` shape) | `json` (one
|
|
2554
|
+
* structured line `{ ts, level, msg, ...meta }`). Default `text`.
|
|
2555
|
+
* - SINK: always the console; PLUS an optional append-only file stream when
|
|
2556
|
+
* `file` is set (lazy-open; a write/open error is swallowed → the daemon never
|
|
2557
|
+
* crashes on a logging failure, it just falls back to the console).
|
|
2558
|
+
*
|
|
2559
|
+
* ZERO-REGRESSION DEFAULT: `new ConfigurableLogger()` (no config) = console +
|
|
2560
|
+
* all levels + text = behaviorally byte-identical to the legacy `ConsoleLogger`
|
|
2561
|
+
* (same `console` method per level, same `(message[, meta])` / error arg shape).
|
|
2562
|
+
*
|
|
2563
|
+
* CAUTION (per the #3 host:port-only-logging precedent): the JSON serializer
|
|
2564
|
+
* reduces an `Error` to `{ message, stack }` and spreads a plain `meta` object,
|
|
2565
|
+
* but it is NOT a secret redactor — call sites remain responsible for not passing
|
|
2566
|
+
* secret-bearing objects.
|
|
2567
|
+
*
|
|
2568
|
+
* @module @omnicross/daemon/ports/ConfigurableLogger
|
|
2569
|
+
*/
|
|
2570
|
+
|
|
2571
|
+
declare class ConfigurableLogger implements Logger {
|
|
2572
|
+
private readonly threshold;
|
|
2573
|
+
private readonly format;
|
|
2574
|
+
private readonly filePath;
|
|
2575
|
+
private fileStream;
|
|
2576
|
+
private fileDisabled;
|
|
2577
|
+
constructor(cfg?: LoggingConfig);
|
|
2578
|
+
info(message: string, meta?: Record<string, unknown> | Error | object): void;
|
|
2579
|
+
warn(message: string, meta?: Record<string, unknown> | Error | object): void;
|
|
2580
|
+
error(message: string, error?: unknown, meta?: Record<string, unknown> | object): void;
|
|
2581
|
+
debug(message: string, meta?: Record<string, unknown> | Error | object): void;
|
|
2582
|
+
/**
|
|
2583
|
+
* Flush + close the file sink (tests / graceful shutdown). Resolves once the
|
|
2584
|
+
* append stream has finished flushing to disk. No-op when no file sink is open.
|
|
2585
|
+
*/
|
|
2586
|
+
close(): Promise<void>;
|
|
2587
|
+
private emit;
|
|
2588
|
+
/**
|
|
2589
|
+
* Console sink. In `text` format this reproduces the legacy `ConsoleLogger`
|
|
2590
|
+
* EXACTLY (same method + arg shape) so the unconfigured default is a byte-for-
|
|
2591
|
+
* byte drop-in; in `json` format it prints the structured line.
|
|
2592
|
+
*/
|
|
2593
|
+
private writeConsole;
|
|
2594
|
+
/** Append one line to the file sink; a failure disables the sink (swallowed). */
|
|
2595
|
+
private writeFile;
|
|
2596
|
+
/** Lazily open the append-only file stream; disable the sink on any error. */
|
|
2597
|
+
private getFileStream;
|
|
2598
|
+
private consoleFn;
|
|
2599
|
+
/** `{ ts, level, msg, ...meta }` (+ `error` when present) as a single line. */
|
|
2600
|
+
private jsonLine;
|
|
2601
|
+
/** Human-readable file line: `ISO [level] message {metaJson}`. */
|
|
2602
|
+
private textLine;
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
/**
|
|
2606
|
+
* ConsoleLogger — the daemon's file-less default `Logger` port impl (design D5).
|
|
1303
2607
|
*
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
2608
|
+
* A thin `console.*` wrapper. The serving core depends on the `Logger` port
|
|
2609
|
+
* (never a host class), so this trivial implementation is the only logger the
|
|
2610
|
+
* standalone daemon needs. `error` uses the WIDEST `(message, error?, meta?)`
|
|
2611
|
+
* signature so every core call site stays assignable.
|
|
1306
2612
|
*
|
|
1307
|
-
* @module @omnicross/daemon/
|
|
2613
|
+
* @module @omnicross/daemon/ports/ConsoleLogger
|
|
1308
2614
|
*/
|
|
1309
|
-
|
|
1310
|
-
declare
|
|
2615
|
+
|
|
2616
|
+
declare class ConsoleLogger implements Logger {
|
|
2617
|
+
info(message: string, meta?: Record<string, unknown> | Error | object): void;
|
|
2618
|
+
warn(message: string, meta?: Record<string, unknown> | Error | object): void;
|
|
2619
|
+
error(message: string, error?: unknown, meta?: Record<string, unknown> | object): void;
|
|
2620
|
+
debug(message: string, meta?: Record<string, unknown> | Error | object): void;
|
|
2621
|
+
}
|
|
1311
2622
|
|
|
1312
2623
|
/**
|
|
1313
2624
|
* ccr-import.ts — translate a `claude-code-router` (CCR) `config.json` into an
|
|
@@ -1372,4 +2683,4 @@ declare function mapCcrToOmnicross(ccr: CcrConfig): {
|
|
|
1372
2683
|
notes: string[];
|
|
1373
2684
|
};
|
|
1374
2685
|
|
|
1375
|
-
export { type AdminApiDeps, AdminServer, type AdminServerDeps, type AdminServerStatus, type CcrConfig, type CcrProvider, type CcrRouter, ConfigFileProviderConfigSource,
|
|
2686
|
+
export { type AdminApiDeps, AdminServer, type AdminServerDeps, type AdminServerStatus, type CcrConfig, type CcrProvider, type CcrRouter, ConfigFileProviderConfigSource, ConfigurableLogger, ConsoleLogger, DEFAULT_ADMIN_PORT, type Daemon, type DaemonAdminConfig, type DaemonApiFormat, type DaemonConfig, type DaemonPaths, type DaemonProviderConfig, type HealthReportDeps, JsonApiServerSettingsStore, JsonOutboundKeyDb, JsonSubscriptionCredentialStore, type ResolvedAdminConfig, buildDaemon, buildHealthReport, handleAdminApi, inferApiFormat, loadConfig, mapCcrToOmnicross, parseCcrConfig, resetDaemonSingletonsForTests, resolveAdminConfig, saveConfig, validateConfig };
|