@bitkyc08/opencodex 2.59.0 → 2.61.0-preview.20260922
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/AGENTS_INSTALL.md +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-E64Rzjap.js +50 -0
- package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
- package/gui/dist/assets/index-DpdfZWMK.js +86 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/stepfun-color.svg +1 -0
- package/package.json +8 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +20 -1
- package/src/adapters/base.ts +23 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +32 -4
- package/src/adapters/command-code.ts +52 -4
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +20 -4
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +14 -3
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin/cloud-direct/chat.ts +3 -1
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
- package/src/adapters/devin.ts +125 -36
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +34 -7
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google-http.ts +49 -10
- package/src/adapters/google-tool-schema.ts +595 -31
- package/src/adapters/google-wire-compiler.ts +93 -10
- package/src/adapters/google-wire-shape.ts +461 -0
- package/src/adapters/google.ts +66 -11
- package/src/adapters/image.ts +4 -1
- package/src/adapters/input-media-guard.ts +21 -9
- package/src/adapters/kiro/usage.ts +3 -2
- package/src/adapters/kiro-tool-fallback.ts +1 -1
- package/src/adapters/ollama-native.ts +6 -0
- package/src/adapters/openai-chat/developer-role.ts +61 -0
- package/src/adapters/openai-chat/messages.ts +46 -27
- package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
- package/src/adapters/openai-chat/passthrough.ts +33 -9
- package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +23 -58
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +17 -3
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/adapters/registry.ts +3 -2
- package/src/adapters/run-turn-queue.ts +178 -29
- package/src/adapters/xai-web-search.ts +16 -1
- package/src/bridge/errors.ts +8 -2
- package/src/bridge/response-json.ts +9 -1
- package/src/bridge/sse.ts +13 -151
- package/src/chat/inbound.ts +141 -5
- package/src/claude/desktop-3p.ts +7 -1
- package/src/claude/desktop-first-party.ts +183 -0
- package/src/claude/desktop-gateway-state.ts +41 -0
- package/src/claude/inbound-content-options.ts +6 -0
- package/src/claude/inbound.ts +32 -6
- package/src/claude/intercept/connect-proxy.ts +179 -0
- package/src/claude/intercept/listener.ts +122 -0
- package/src/claude/intercept/local-ca.ts +298 -0
- package/src/claude/intercept/runtime.ts +98 -0
- package/src/claude/intercept/settings.ts +189 -0
- package/src/cli/access.ts +87 -0
- package/src/cli/account-auth.ts +19 -0
- package/src/cli/account-extended.ts +4 -4
- package/src/cli/capabilities.ts +31 -0
- package/src/cli/claude-desktop.ts +206 -16
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/companion.ts +56 -0
- package/src/cli/dispatch.ts +46 -7
- package/src/cli/doctor.ts +28 -9
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +203 -59
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -1
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/cli/registry.ts +41 -2
- package/src/cli/resolve.ts +230 -0
- package/src/cli/root.ts +24 -1
- package/src/cli/start-ownership-publication.ts +56 -0
- package/src/cli/status-probes.ts +2 -18
- package/src/cli/status.ts +62 -0
- package/src/cli/stop-report.ts +143 -0
- package/src/cli/uninstall-plan.ts +9 -0
- package/src/client/machine-api.ts +2 -2
- package/src/client/machine-listener.ts +4 -7
- package/src/client/runtime.ts +26 -2
- package/src/clients/aside-profiles.ts +4 -0
- package/src/clients/config-export/zcode-store.ts +157 -0
- package/src/clients/config-export.ts +36 -0
- package/src/codex/account-store.ts +65 -0
- package/src/codex/app-server-processes.ts +72 -40
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/login-flow.ts +6 -1
- package/src/codex/auth-api/pool-quota-probe.ts +30 -7
- package/src/codex/autostart-health.ts +28 -0
- package/src/codex/catalog/build-entries.ts +2 -2
- package/src/codex/catalog/effort.ts +3 -3
- package/src/codex/catalog/gather-capture.ts +21 -2
- package/src/codex/catalog/model-hints.ts +29 -28
- package/src/codex/catalog/parsing.ts +7 -0
- package/src/codex/catalog/provider-models.ts +43 -17
- package/src/codex/catalog/retained-sync.ts +24 -28
- package/src/codex/catalog/routed-gather.ts +19 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +31 -166
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/inject/config-toml.ts +41 -6
- package/src/codex/inject/paginated-openai-compat.ts +90 -0
- package/src/codex/inject.ts +18 -15
- package/src/codex/injected-marker.ts +18 -0
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/main-account.ts +6 -0
- package/src/codex/model-cache.ts +99 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +116 -54
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/observed-model-denials.ts +101 -8
- package/src/codex/prompt-text-probe.ts +9 -6
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +12 -42
- package/src/codex/shim-templates.ts +29 -3
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +22 -4
- package/src/combos/failover.ts +3 -0
- package/src/companion/settings.ts +132 -0
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/diagnostics.ts +22 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +52 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/proxy-env.ts +8 -2
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +31 -1
- package/src/config/schema/leaf-validators.ts +59 -0
- package/src/config.ts +3 -3
- package/src/generated/compatibility-version.json +604 -280
- package/src/grok/reset-coupons.ts +38 -19
- package/src/images/loop.ts +6 -1
- package/src/integrations/aside-profile-context.ts +37 -3
- package/src/integrations/aside-profile-journal.ts +68 -3
- package/src/integrations/aside-profiles.ts +128 -3
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +921 -0
- package/src/integrations/registry.ts +38 -0
- package/src/integrations/state.ts +78 -45
- package/src/integrations/target.ts +208 -0
- package/src/integrations/writer.ts +134 -110
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lab/live/transport.ts +4 -0
- package/src/lab/live/types.ts +5 -0
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/admin-secrets.ts +9 -1
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +63 -0
- package/src/lib/errors.ts +79 -0
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/open-url.ts +51 -7
- package/src/lib/package-tree-integrity.ts +2 -1
- package/src/lib/package-version.ts +8 -0
- package/src/lib/pinned-http.ts +142 -2
- package/src/lib/plain-data.ts +103 -0
- package/src/lib/process-control.ts +13 -5
- package/src/lib/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +109 -16
- package/src/lib/proxy-env.ts +82 -7
- package/src/lib/request-execution-budget.ts +72 -0
- package/src/lib/request-failure-attribution.ts +183 -0
- package/src/lib/request-failure-model.ts +236 -0
- package/src/lib/request-resend-gate.ts +138 -0
- package/src/lib/socks5-fetch.ts +136 -26
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +218 -27
- package/src/lib/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +1 -0
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +27 -108
- package/src/oauth/login-cli.ts +80 -29
- package/src/oauth/login-flow-state.ts +127 -0
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/key-failover.ts +97 -19
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +179 -38
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/vendor-probes-key.ts +38 -23
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +47 -20
- package/src/providers/registry/entries-extended.ts +63 -4
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +56 -10
- package/src/providers/registry/types.ts +2 -0
- package/src/providers/resolved-model-policy-merge.ts +167 -0
- package/src/providers/resolved-model-policy.ts +406 -0
- package/src/providers/stale-vision-classification-migration.ts +137 -0
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -0
- package/src/responses/apply-patch-envelope.ts +0 -12
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- package/src/responses/function-call-compat.ts +38 -1
- package/src/responses/inline-document.ts +65 -0
- package/src/responses/input-media.ts +42 -8
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/muse-tool-name-alias.ts +19 -0
- package/src/responses/parser-content.ts +8 -2
- package/src/responses/parser-tools.ts +3 -0
- package/src/responses/parser.ts +3 -1
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/schema.ts +3 -0
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +108 -117
- package/src/routing/compatibility/behavior.ts +9 -0
- package/src/routing/compatibility/subject.ts +16 -1
- package/src/server/adapter-resolve.ts +9 -0
- package/src/server/admission-model-scope.ts +219 -0
- package/src/server/audio-live.ts +9 -3
- package/src/server/audio-upstream.ts +18 -0
- package/src/server/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +60 -4
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +61 -20
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/grok-responses-snapshot-repair.ts +113 -11
- package/src/server/gui-freshness.ts +103 -0
- package/src/server/gui-static.ts +7 -9
- package/src/server/images.ts +59 -6
- package/src/server/index/claude-intercept-lifecycle.ts +49 -0
- package/src/server/index/serve-options.ts +142 -39
- package/src/server/index/spend-ledger-lifecycle.ts +92 -0
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +34 -38
- package/src/server/lifecycle.ts +4 -4
- package/src/server/live-call-bindings.ts +6 -0
- package/src/server/live.ts +88 -3
- package/src/server/management/agent-settings-routes.ts +121 -36
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/companion-routes.ts +77 -0
- package/src/server/management/config-routes.ts +18 -2
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/logs-usage-routes.ts +19 -0
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/native-integration-routes.ts +103 -6
- package/src/server/management/oauth-account-routes.ts +45 -7
- package/src/server/management/route-registry.ts +20 -0
- package/src/server/management/shared.ts +28 -4
- package/src/server/management/system-restart.ts +7 -2
- package/src/server/management/system-routes.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +4 -0
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +10 -9
- package/src/server/management-auth.ts +15 -1
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +138 -12
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +169 -2
- package/src/server/request-metrics.ts +298 -0
- package/src/server/responses/adapter-continuation.ts +3 -3
- package/src/server/responses/adapter-dispatch.ts +11 -6
- package/src/server/responses/codex-ws-wire.ts +34 -8
- package/src/server/responses/combo-stream-preflight.ts +168 -6
- package/src/server/responses/compact.ts +43 -10
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +8 -3
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +6 -12
- package/src/server/responses/core-opaque-recovery.ts +91 -0
- package/src/server/responses/core-options.ts +4 -0
- package/src/server/responses/encrypted-payload.ts +20 -2
- package/src/server/responses/fetch-helpers.ts +124 -8
- package/src/server/responses/input-admission.ts +10 -0
- package/src/server/responses/passthrough-delivery.ts +45 -15
- package/src/server/responses/passthrough-dispatch.ts +215 -44
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +109 -18
- package/src/server/responses/request-send-budget.ts +17 -1
- package/src/server/responses/request-sidecar-auth.ts +1 -1
- package/src/server/responses/request-transport.ts +26 -6
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses/run-turn-execution.ts +25 -3
- package/src/server/responses/sidecar-execution.ts +17 -2
- package/src/server/responses/ws-upstream.ts +14 -27
- package/src/server/responses-custom-tool-repair.ts +27 -54
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +35 -2
- package/src/server/search.ts +25 -1
- package/src/server/sse-payload-rewrite.ts +1 -1
- package/src/server/usage-ledger-retention.ts +73 -0
- package/src/service/cli.ts +48 -2
- package/src/service/health.ts +3 -2
- package/src/service/install-state-contract.d.mts +27 -0
- package/src/service/install-state-contract.mjs +34 -0
- package/src/service/launchd.ts +1 -1
- package/src/service/orchestration.ts +2 -4
- package/src/service/ownership-compatibility.ts +164 -0
- package/src/service/ownership-mutation-lease.d.mts +32 -0
- package/src/service/ownership-mutation-lease.mjs +211 -0
- package/src/service/repair.ts +45 -1
- package/src/service/state-lock.ts +269 -0
- package/src/service/state-record.d.mts +36 -0
- package/src/service/state-record.mjs +138 -0
- package/src/service/state.ts +582 -68
- package/src/service/windows-taskxml.ts +11 -10
- package/src/service.ts +7 -3
- package/src/tray/windows-tray.ps1 +156 -4
- package/src/types/config.ts +48 -3
- package/src/types/provider.ts +91 -0
- package/src/types/request.ts +30 -2
- package/src/types/tools.ts +33 -0
- package/src/types.ts +4 -0
- package/src/update/index.ts +207 -63
- package/src/update/job.ts +9 -5
- package/src/update/ownership-transaction.ts +47 -0
- package/src/update/restart-ownership.ts +54 -0
- package/src/update/runtime-ownership.d.mts +40 -0
- package/src/update/runtime-ownership.mjs +122 -0
- package/src/usage/attempt-delivery.ts +198 -0
- package/src/usage/cache-diagnostic.ts +305 -0
- package/src/usage/failure-fingerprint.ts +118 -0
- package/src/usage/failure-projection-cache.ts +174 -0
- package/src/usage/failure-projection.ts +174 -0
- package/src/usage/ledger-retention.ts +165 -0
- package/src/usage/log.ts +126 -79
- package/src/usage/request-outcome.ts +150 -0
- package/src/usage/retention-contract.ts +28 -0
- package/src/usage/summary.ts +2 -2
- package/src/usage/telemetry-contract.ts +237 -0
- package/src/usage/timeline.ts +236 -0
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/alpha-search.ts +21 -1
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/gui/dist/assets/index-C5IebErG.js +0 -136
- package/gui/dist/assets/index-OESInAjC.css +0 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getConfigDir } from "../config/paths";
|
|
4
|
+
import {
|
|
5
|
+
TIMELINE_HOURS,
|
|
6
|
+
isTimelineModelId,
|
|
7
|
+
type TimelineAggregation,
|
|
8
|
+
type TimelineGrouping,
|
|
9
|
+
type TimelineMetric,
|
|
10
|
+
} from "../usage/timeline";
|
|
11
|
+
|
|
12
|
+
export interface CompanionSettings {
|
|
13
|
+
menuBarMetric: "requests" | "tokens" | "cost" | "quota" | "none";
|
|
14
|
+
menuBarTemplate: string | null;
|
|
15
|
+
showToday: boolean;
|
|
16
|
+
showChart: boolean;
|
|
17
|
+
showModels: boolean;
|
|
18
|
+
showCost: boolean;
|
|
19
|
+
showAccounts: boolean;
|
|
20
|
+
chartHours: typeof TIMELINE_HOURS[number];
|
|
21
|
+
bucketMinutes: number;
|
|
22
|
+
chartStyle: "line" | "stackedBar";
|
|
23
|
+
tokenMetric: TimelineMetric;
|
|
24
|
+
aggregation: TimelineAggregation;
|
|
25
|
+
chartGrouping: TimelineGrouping;
|
|
26
|
+
models: string[] | null;
|
|
27
|
+
hiddenProviders: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_COMPANION_SETTINGS: CompanionSettings = {
|
|
31
|
+
menuBarMetric: "tokens",
|
|
32
|
+
menuBarTemplate: null,
|
|
33
|
+
showToday: true,
|
|
34
|
+
showChart: true,
|
|
35
|
+
showModels: true,
|
|
36
|
+
showCost: true,
|
|
37
|
+
showAccounts: true,
|
|
38
|
+
chartHours: 24,
|
|
39
|
+
bucketMinutes: 60,
|
|
40
|
+
chartStyle: "line",
|
|
41
|
+
tokenMetric: "total",
|
|
42
|
+
aggregation: "sum",
|
|
43
|
+
chartGrouping: "model",
|
|
44
|
+
models: null,
|
|
45
|
+
hiddenProviders: [],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const TEMPLATE_FIELDS = new Set(["requests", "totalTokens", "inputTokens", "outputTokens", "costUsd", "quotaPercent"]);
|
|
49
|
+
const MENU_BAR_METRICS = new Set(["requests", "tokens", "cost", "quota", "none"]);
|
|
50
|
+
const CHART_STYLES = new Set(["line", "stackedBar"]);
|
|
51
|
+
const TIMELINE_METRICS = new Set(["total", "input", "output", "cached"]);
|
|
52
|
+
const AGGREGATIONS = new Set(["sum", "average", "max"]);
|
|
53
|
+
const GROUPINGS = new Set(["model", "modelAccount"]);
|
|
54
|
+
const SETTINGS_KEYS = Object.keys(DEFAULT_COMPANION_SETTINGS) as (keyof CompanionSettings)[];
|
|
55
|
+
|
|
56
|
+
export function companionSettingsPath(): string {
|
|
57
|
+
return join(getConfigDir(), "companion.json");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function invalid(message: string): { error: string } {
|
|
61
|
+
return { error: message };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validModels(value: unknown, key: string): value is string[] | null {
|
|
65
|
+
return value === null
|
|
66
|
+
|| (Array.isArray(value)
|
|
67
|
+
&& value.length <= 100
|
|
68
|
+
&& value.every(isTimelineModelId));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validateValue(key: keyof CompanionSettings, value: unknown): string | null {
|
|
72
|
+
if (key === "menuBarMetric") return typeof value === "string" && MENU_BAR_METRICS.has(value) ? null : "menuBarMetric is invalid";
|
|
73
|
+
if (key === "menuBarTemplate") {
|
|
74
|
+
if (value === null) return null;
|
|
75
|
+
if (typeof value !== "string" || value.length > 200) return "menuBarTemplate must be null or at most 200 characters";
|
|
76
|
+
for (const match of value.matchAll(/\{([^{}]+)\}/g)) {
|
|
77
|
+
if (!TEMPLATE_FIELDS.has(match[1]!)) return `menuBarTemplate contains unknown placeholder: ${match[1]}`;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (["showToday", "showChart", "showModels", "showCost", "showAccounts"].includes(key)) {
|
|
82
|
+
return typeof value === "boolean" ? null : `${key} must be a boolean`;
|
|
83
|
+
}
|
|
84
|
+
if (key === "chartHours") return TIMELINE_HOURS.includes(value as typeof TIMELINE_HOURS[number]) ? null : "chartHours is invalid";
|
|
85
|
+
if (key === "bucketMinutes") return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 1440 ? null : "bucketMinutes must be an integer from 1 through 1440";
|
|
86
|
+
if (key === "chartStyle") return typeof value === "string" && CHART_STYLES.has(value) ? null : "chartStyle is invalid";
|
|
87
|
+
if (key === "tokenMetric") return typeof value === "string" && TIMELINE_METRICS.has(value) ? null : "tokenMetric is invalid";
|
|
88
|
+
if (key === "aggregation") return typeof value === "string" && AGGREGATIONS.has(value) ? null : "aggregation is invalid";
|
|
89
|
+
if (key === "chartGrouping") return typeof value === "string" && GROUPINGS.has(value) ? null : "chartGrouping is invalid";
|
|
90
|
+
if (key === "models") return validModels(value, key) ? null : "models must be null or at most 100 provider/model identifiers";
|
|
91
|
+
if (key === "hiddenProviders") return Array.isArray(value) && value.length <= 100 && value.every(item => typeof item === "string" && item.length > 0 && !/\s/.test(item))
|
|
92
|
+
? null : "hiddenProviders must contain at most 100 provider names";
|
|
93
|
+
return `${key} is unsupported`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function applyCompanionSettingsPatch(
|
|
97
|
+
current: CompanionSettings,
|
|
98
|
+
patch: unknown,
|
|
99
|
+
): CompanionSettings | { error: string } {
|
|
100
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch)) return invalid("settings must be an object");
|
|
101
|
+
const values = patch as Record<string, unknown>;
|
|
102
|
+
for (const key of Object.keys(values)) {
|
|
103
|
+
if (!SETTINGS_KEYS.includes(key as keyof CompanionSettings)) return invalid(`unknown settings key: ${key}`);
|
|
104
|
+
const error = validateValue(key as keyof CompanionSettings, values[key]);
|
|
105
|
+
if (error) return invalid(error);
|
|
106
|
+
}
|
|
107
|
+
return { ...current, ...values } as CompanionSettings;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function loadCompanionSettings(): { settings: CompanionSettings; updatedAt: number | null; corrupt?: true } {
|
|
111
|
+
const path = companionSettingsPath();
|
|
112
|
+
if (!existsSync(path)) return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null };
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
115
|
+
const settings = applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, parsed);
|
|
116
|
+
if ("error" in settings) return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null, corrupt: true };
|
|
117
|
+
return { settings, updatedAt: statSync(path).mtimeMs };
|
|
118
|
+
} catch {
|
|
119
|
+
return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null, corrupt: true };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function saveCompanionSettings(settings: CompanionSettings): void {
|
|
124
|
+
const path = companionSettingsPath();
|
|
125
|
+
const dir = getConfigDir();
|
|
126
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
127
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
128
|
+
writeFileSync(temp, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
|
|
129
|
+
chmodSync(temp, 0o600);
|
|
130
|
+
renameSync(temp, path);
|
|
131
|
+
chmodSync(path, 0o600);
|
|
132
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { readConfigAdmissionSnapshot } from "./diagnostics";
|
|
2
|
+
import { getConfigPath } from "./paths";
|
|
3
|
+
import { canonicalPlainData, copyPlainData, isPlainObject, ownDataKeys } from "../lib/plain-data";
|
|
4
|
+
import type { OcxConfig } from "../types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A configuration a roster may be built from, detached from the object the caller holds.
|
|
8
|
+
*
|
|
9
|
+
* The detachment is the point. A gather suspends, and while it is suspended a management route can
|
|
10
|
+
* edit the resident configuration in place; gathering from one state and projecting from another
|
|
11
|
+
* would produce rows that belong to neither. Every authoritative pass uses this copy from
|
|
12
|
+
* beginning to end, so what the resident object does in the meantime becomes a question about
|
|
13
|
+
* whether the result may be retained rather than a question about what the result is.
|
|
14
|
+
*
|
|
15
|
+
* What this deliberately does NOT do is require the resident configuration to equal the file on
|
|
16
|
+
* disk. The proxy routes by the configuration it is holding, so that is the configuration a
|
|
17
|
+
* preview and the mutation it authorizes must both describe. A file the operator has edited and
|
|
18
|
+
* the process has not adopted is a supported state rather than a fault: live reconciliation
|
|
19
|
+
* (src/config/live-reconcile.ts) merges persisted state while deliberately retaining live changes
|
|
20
|
+
* and the active listener binding, and can persist a binding the resident object does not have.
|
|
21
|
+
* Demanding equality would make preview permanently unavailable on exactly those configurations
|
|
22
|
+
* while proving nothing about the rows, which come from the resident object either way.
|
|
23
|
+
*/
|
|
24
|
+
export interface ExportConfigAdmission {
|
|
25
|
+
readonly config: OcxConfig;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What was true when an admission was captured, kept here rather than on the admission object.
|
|
30
|
+
*
|
|
31
|
+
* None of it is data a consumer has any business reading: the file term is a digest of the
|
|
32
|
+
* operator's configuration file and the canonical form contains their credentials. Holding it in a
|
|
33
|
+
* module WeakMap means an admission can be passed around, and even serialized by a careless
|
|
34
|
+
* caller, without carrying any of it.
|
|
35
|
+
*/
|
|
36
|
+
interface AdmissionEvidence {
|
|
37
|
+
readonly path: string;
|
|
38
|
+
readonly file: string;
|
|
39
|
+
readonly data: string;
|
|
40
|
+
readonly executors: ReadonlyMap<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const evidence = new WeakMap<ExportConfigAdmission, AdmissionEvidence>();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Detach the configuration a roster is about to be built from, and record what it was.
|
|
47
|
+
*
|
|
48
|
+
* Two things are recorded because two things can move independently. The resident configuration is
|
|
49
|
+
* what the rows are derived from, so its complete structure is captured: structurally rather than
|
|
50
|
+
* as a list of the fields that seemed to matter, because such a list is only as complete as
|
|
51
|
+
* whoever last thought about it and this one had already missed export-affecting configuration.
|
|
52
|
+
* The configuration file is recorded beside it, so a roster does not outlive an operator editing
|
|
53
|
+
* the configuration under a process that has not adopted it yet.
|
|
54
|
+
*
|
|
55
|
+
* Null when the file cannot be read, when it is there but the loader would have had to salvage it,
|
|
56
|
+
* and when the configuration object cannot be copied as plain data. Every caller fails closed on
|
|
57
|
+
* it: refusing a preview costs an ordinary load, and serving one that describes a configuration
|
|
58
|
+
* nobody has costs a file the operator did not ask for.
|
|
59
|
+
*/
|
|
60
|
+
export function captureExportConfigAdmission(live: OcxConfig): ExportConfigAdmission | null {
|
|
61
|
+
const path = getConfigPath();
|
|
62
|
+
const file = admittedFileTerm();
|
|
63
|
+
if (file === null) return null;
|
|
64
|
+
const resident = detachConfig(live);
|
|
65
|
+
if (resident === null) return null;
|
|
66
|
+
const config = withExecutors(resident);
|
|
67
|
+
if (config === null) return null;
|
|
68
|
+
const admission: ExportConfigAdmission = { config };
|
|
69
|
+
evidence.set(admission, { path, file, data: canonicalPlainData(resident.data), executors: resident.executors });
|
|
70
|
+
return admission;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether an admission still describes the configuration in hand and the file it was taken beside.
|
|
75
|
+
*
|
|
76
|
+
* Three things are checked because three things can move: the file can be rewritten, the resident
|
|
77
|
+
* object can be edited in place, and a consumer of the detached copy can mutate what it was given.
|
|
78
|
+
* The last matters as much as the others, because a pass that edited its own input and then
|
|
79
|
+
* published would be retaining a roster under a state that no longer describes even that input.
|
|
80
|
+
*
|
|
81
|
+
* Passive: it reads the configuration file and nothing else. No credential is resolved, no
|
|
82
|
+
* provider is contacted, no path is hardened and nothing is written.
|
|
83
|
+
*/
|
|
84
|
+
export function isExportConfigAdmissionCurrent(admission: ExportConfigAdmission, live: OcxConfig): boolean {
|
|
85
|
+
const captured = evidence.get(admission);
|
|
86
|
+
if (captured === undefined) return false;
|
|
87
|
+
// A different configuration home is a different question, not a stale answer to this one.
|
|
88
|
+
if (getConfigPath() !== captured.path) return false;
|
|
89
|
+
const file = admittedFileTerm();
|
|
90
|
+
if (file === null || file !== captured.file) return false;
|
|
91
|
+
const resident = detachConfig(live);
|
|
92
|
+
if (resident === null || canonicalPlainData(resident.data) !== captured.data) return false;
|
|
93
|
+
if (!sameExecutors(resident.executors, captured.executors)) return false;
|
|
94
|
+
const working = detachConfig(admission.config);
|
|
95
|
+
return working !== null
|
|
96
|
+
&& canonicalPlainData(working.data) === captured.data
|
|
97
|
+
&& sameExecutors(working.executors, captured.executors);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A plain-data copy of a configuration for a consumer that must not observe later edits, or null.
|
|
102
|
+
*
|
|
103
|
+
* The integration writer is the case this exists for. It freezes every other resolution seam
|
|
104
|
+
* before its first await and then held the configuration by reference, so a plan checked under one
|
|
105
|
+
* configuration could be written from another: the check and the document it authorized were
|
|
106
|
+
* reading the same object at two different moments. One copy taken before the await gives both of
|
|
107
|
+
* them the same configuration.
|
|
108
|
+
*
|
|
109
|
+
* Null rather than the caller's object when the copy cannot be made. Handing back the reference
|
|
110
|
+
* would have been a copy in name only, and the caller would have gone on to describe it as the
|
|
111
|
+
* configuration it checked.
|
|
112
|
+
*/
|
|
113
|
+
export function detachedConfigSnapshot(config: OcxConfig): OcxConfig | null {
|
|
114
|
+
const detached = detachConfig(config);
|
|
115
|
+
return detached === null ? null : withExecutors(detached);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The configuration file as an opaque term: its exact bytes, or the distinguished absence of one.
|
|
120
|
+
*
|
|
121
|
+
* This is a byte observation and nothing more. It says the operator's configuration file has not
|
|
122
|
+
* been rewritten since a roster was built; it is not a claim about whether the configuration the
|
|
123
|
+
* process is holding agrees with that file.
|
|
124
|
+
*
|
|
125
|
+
* Null for a file that cannot be read, because then a later read cannot tell whether it changed.
|
|
126
|
+
* Null too for one that is there and does not load cleanly, which is the existing contract for a
|
|
127
|
+
* derived roster rather than an inference about the resident configuration. Before this, a digest
|
|
128
|
+
* was accepted ahead of any look at what the parse produced.
|
|
129
|
+
*
|
|
130
|
+
* Absence is a configuration rather than the lack of one. No file means defaults, which is an
|
|
131
|
+
* ordinary fresh install and the ordinary state in CI.
|
|
132
|
+
*/
|
|
133
|
+
function admittedFileTerm(): string | null {
|
|
134
|
+
const snapshot = readConfigAdmissionSnapshot();
|
|
135
|
+
const { source, error } = snapshot.diagnostics;
|
|
136
|
+
if (snapshot.kind === "read") return source === "file" && error === null ? snapshot.contentSha256 : null;
|
|
137
|
+
return source === "default" && error === null ? "absent" : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface DetachedConfig {
|
|
141
|
+
readonly data: Record<string, unknown>;
|
|
142
|
+
readonly executors: ReadonlyMap<string, unknown>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A configuration as plain data, with the transport executors kept out of it.
|
|
147
|
+
*
|
|
148
|
+
* The copier refuses everything JSON could not have produced, so the one thing that needs handling
|
|
149
|
+
* here is a provider's fetch executor: a caller owns it and the gather uses it instead of the
|
|
150
|
+
* global transport. It is held by reference for the detached copy and compared by reference
|
|
151
|
+
* afterwards, so replacing it invalidates the binding while it is never serialized.
|
|
152
|
+
*/
|
|
153
|
+
function detachConfig(live: OcxConfig): DetachedConfig | null {
|
|
154
|
+
const executors = new Map<string, unknown>();
|
|
155
|
+
const root = live as unknown;
|
|
156
|
+
if (!isPlainObject(root)) return null;
|
|
157
|
+
const data: Record<string, unknown> = {};
|
|
158
|
+
for (const key of ownDataKeys(root)) {
|
|
159
|
+
if (key === null) return null;
|
|
160
|
+
const value = root[key];
|
|
161
|
+
if (value === undefined) continue;
|
|
162
|
+
if (key !== "providers") {
|
|
163
|
+
const copied = copyPlainData(value);
|
|
164
|
+
if (!copied.ok) return null;
|
|
165
|
+
data[key] = copied.value;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (!isPlainObject(value)) return null;
|
|
169
|
+
const providers: Record<string, unknown> = {};
|
|
170
|
+
for (const name of ownDataKeys(value)) {
|
|
171
|
+
if (name === null) return null;
|
|
172
|
+
const provider = value[name];
|
|
173
|
+
if (provider === undefined) continue;
|
|
174
|
+
if (!isPlainObject(provider)) return null;
|
|
175
|
+
const copiedProvider: Record<string, unknown> = {};
|
|
176
|
+
for (const field of ownDataKeys(provider)) {
|
|
177
|
+
if (field === null) return null;
|
|
178
|
+
const fieldValue = provider[field];
|
|
179
|
+
if (fieldValue === undefined) continue;
|
|
180
|
+
// The executor exception applies to an executor. A provider entry keeps unknown
|
|
181
|
+
// configuration keys, so a fetch value that is not a function is something an operator
|
|
182
|
+
// wrote into the file, and it is copied and compared as the data it is. Discovery reads it
|
|
183
|
+
// the same way: the outbound transport takes its built-in path unless the value is
|
|
184
|
+
// callable.
|
|
185
|
+
if (field === "fetch" && typeof fieldValue === "function") {
|
|
186
|
+
executors.set(name, fieldValue);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const copied = copyPlainData(fieldValue);
|
|
190
|
+
if (!copied.ok) return null;
|
|
191
|
+
copiedProvider[field] = copied.value;
|
|
192
|
+
}
|
|
193
|
+
providers[name] = copiedProvider;
|
|
194
|
+
}
|
|
195
|
+
data[key] = providers;
|
|
196
|
+
}
|
|
197
|
+
return { data, executors };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The copy a consumer runs against, with the executors put back by reference. */
|
|
201
|
+
function withExecutors(detached: DetachedConfig): OcxConfig | null {
|
|
202
|
+
// A second copy, so what is compared later is never the object handed to a consumer.
|
|
203
|
+
const copied = copyPlainData(detached.data);
|
|
204
|
+
if (!copied.ok) return null;
|
|
205
|
+
const config = copied.value;
|
|
206
|
+
const providers = config.providers;
|
|
207
|
+
if (isPlainObject(providers)) {
|
|
208
|
+
for (const [name, executor] of detached.executors) {
|
|
209
|
+
const provider = providers[name];
|
|
210
|
+
if (isPlainObject(provider)) provider.fetch = executor;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return config as unknown as OcxConfig;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function sameExecutors(left: ReadonlyMap<string, unknown>, right: ReadonlyMap<string, unknown>): boolean {
|
|
217
|
+
if (left.size !== right.size) return false;
|
|
218
|
+
for (const [name, executor] of left) {
|
|
219
|
+
if (!right.has(name) || right.get(name) !== executor) return false;
|
|
220
|
+
}
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
chmodSync,
|
|
3
3
|
closeSync,
|
|
4
4
|
fchmodSync,
|
|
5
|
+
fsyncSync,
|
|
5
6
|
fstatSync,
|
|
6
7
|
lstatSync,
|
|
7
8
|
openSync,
|
|
@@ -10,7 +11,7 @@ import {
|
|
|
10
11
|
unlinkSync,
|
|
11
12
|
writeFileSync,
|
|
12
13
|
} from "node:fs";
|
|
13
|
-
import { dirname } from "node:path";
|
|
14
|
+
import { basename, dirname, join } from "node:path";
|
|
14
15
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
15
16
|
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
16
17
|
import {
|
|
@@ -162,6 +163,27 @@ function carryHardenAcrossContentWrite(path: string): void {
|
|
|
162
163
|
reattributeHardenedSecretPath(path);
|
|
163
164
|
}
|
|
164
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Commit the directory entry a rename just wrote.
|
|
168
|
+
*
|
|
169
|
+
* Best effort by platform, not by importance: Windows has no directory descriptor to sync and
|
|
170
|
+
* some filesystems refuse the open, and failing a replacement that already happened would be
|
|
171
|
+
* worse than reporting it. The throw that matters is the temp's own `fsync`, which runs before
|
|
172
|
+
* the rename and stops it.
|
|
173
|
+
*/
|
|
174
|
+
function syncParentDirectory(target: string): void {
|
|
175
|
+
if (process.platform === "win32") return;
|
|
176
|
+
let descriptor: number | undefined;
|
|
177
|
+
try {
|
|
178
|
+
descriptor = openSync(dirname(target), "r");
|
|
179
|
+
fsyncSync(descriptor);
|
|
180
|
+
} catch {
|
|
181
|
+
/* the rename already landed; a directory that cannot be synced is not a reason to undo it */
|
|
182
|
+
} finally {
|
|
183
|
+
if (descriptor !== undefined) { try { closeSync(descriptor); } catch { /* already closed */ } }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
165
187
|
function writePrivateTempFile(
|
|
166
188
|
path: string,
|
|
167
189
|
content: string,
|
|
@@ -191,6 +213,40 @@ function writePrivateTempFile(
|
|
|
191
213
|
carryHardenAcrossContentWrite(path);
|
|
192
214
|
}
|
|
193
215
|
|
|
216
|
+
/**
|
|
217
|
+
* The same private temp, filled by a writer that streams into the descriptor.
|
|
218
|
+
*
|
|
219
|
+
* For content that must not be held in memory as one string. The identity assertions, the
|
|
220
|
+
* ownership handshake and the hardening are the same; the difference is that the bytes arrive in
|
|
221
|
+
* bounded chunks and the descriptor is flushed before it closes.
|
|
222
|
+
*
|
|
223
|
+
* The `fsync` is not optional here and its failure is not swallowed. A replacement whose
|
|
224
|
+
* REPLACEMENT is not on disk can lose the rows it was supposed to retain, so the throw is what
|
|
225
|
+
* stops the rename from happening at all.
|
|
226
|
+
*/
|
|
227
|
+
function writePrivateTempFileWith(
|
|
228
|
+
path: string,
|
|
229
|
+
write: (descriptor: number) => void,
|
|
230
|
+
timeoutMemoKey: string,
|
|
231
|
+
onCreated: () => void,
|
|
232
|
+
): void {
|
|
233
|
+
const descriptor = openSync(path, "wx", 0o600);
|
|
234
|
+
onCreated();
|
|
235
|
+
try {
|
|
236
|
+
if (windowsHardeningApplies()) {
|
|
237
|
+
hardenSecretPath(path, { required: true, timeoutMemoKey });
|
|
238
|
+
}
|
|
239
|
+
if (process.platform !== "win32") fchmodSync(descriptor, 0o600);
|
|
240
|
+
assertPrivateTempDescriptor(path, descriptor);
|
|
241
|
+
write(descriptor);
|
|
242
|
+
assertPrivateTempDescriptor(path, descriptor);
|
|
243
|
+
fsyncSync(descriptor);
|
|
244
|
+
} finally {
|
|
245
|
+
closeSync(descriptor);
|
|
246
|
+
}
|
|
247
|
+
carryHardenAcrossContentWrite(path);
|
|
248
|
+
}
|
|
249
|
+
|
|
194
250
|
async function writePrivateTempFileAsync(
|
|
195
251
|
path: string,
|
|
196
252
|
content: string,
|
|
@@ -215,14 +271,14 @@ async function writePrivateTempFileAsync(
|
|
|
215
271
|
carryHardenAcrossContentWrite(path);
|
|
216
272
|
}
|
|
217
273
|
|
|
218
|
-
|
|
274
|
+
function atomicWriteFileToTarget(
|
|
219
275
|
path: string,
|
|
220
|
-
content: string,
|
|
276
|
+
content: string | ((descriptor: number) => void),
|
|
277
|
+
target: string,
|
|
221
278
|
io?: AtomicWriteIO,
|
|
222
279
|
hooks: AtomicWriteHooks = {},
|
|
223
280
|
): void {
|
|
224
281
|
recordOwnedConfigPath(getConfigDir(), path);
|
|
225
|
-
const target = resolveWriteTarget(path);
|
|
226
282
|
assertResolvedTargetAllowed(path, target);
|
|
227
283
|
const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`;
|
|
228
284
|
let hardened = false;
|
|
@@ -246,13 +302,26 @@ export function atomicWriteFile(
|
|
|
246
302
|
};
|
|
247
303
|
try {
|
|
248
304
|
if (io) ownsTemp = true;
|
|
249
|
-
|
|
305
|
+
// A streaming writer bypasses the string form of `write` and nothing else. Every later
|
|
306
|
+
// step -- harden, the pre-rename hooks, the rename and the whole residual-cleanup path,
|
|
307
|
+
// which still scrubs through `effective.write(tmp, "")` -- is shared with the string form.
|
|
308
|
+
if (typeof content === "function") writePrivateTempFileWith(tmp, content, path, () => { ownsTemp = true; });
|
|
309
|
+
else effective.write(tmp, content);
|
|
250
310
|
hooks.afterTempWrite?.(tmp, target);
|
|
251
311
|
effective.harden(tmp);
|
|
252
312
|
hardened = true;
|
|
253
313
|
hooks.beforeRename?.(tmp, target);
|
|
254
314
|
hooks.validateBeforeRename?.(target);
|
|
255
315
|
effective.rename(tmp, target);
|
|
316
|
+
// The rename is only as durable as the directory entry recording it. Fsyncing the temp's
|
|
317
|
+
// CONTENT and then losing the entry in a power cut leaves the old file in place, or the
|
|
318
|
+
// directory in an indeterminate state, while the caller was told the replacement landed.
|
|
319
|
+
//
|
|
320
|
+
// Only the streaming form does this. It is the one that makes a durability claim -- a
|
|
321
|
+
// replacement is not an append, and losing it can lose the rows it was meant to keep -- and
|
|
322
|
+
// adding a directory sync to the string form would charge every config write for a promise
|
|
323
|
+
// its callers have never been given.
|
|
324
|
+
if (typeof content === "function") syncParentDirectory(target);
|
|
256
325
|
forgetEphemeralSecretPath(tmp);
|
|
257
326
|
} catch (cause) {
|
|
258
327
|
if (!ownsTemp) throw cause;
|
|
@@ -287,6 +356,49 @@ export function atomicWriteFile(
|
|
|
287
356
|
}
|
|
288
357
|
}
|
|
289
358
|
|
|
359
|
+
export function atomicWriteFile(
|
|
360
|
+
path: string,
|
|
361
|
+
content: string,
|
|
362
|
+
io?: AtomicWriteIO,
|
|
363
|
+
hooks: AtomicWriteHooks = {},
|
|
364
|
+
): void {
|
|
365
|
+
atomicWriteFileToTarget(path, content, resolveWriteTarget(path), io, hooks);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Atomically replace a file with bytes produced straight into the temporary descriptor.
|
|
370
|
+
*
|
|
371
|
+
* Same publication contract as {@link atomicWriteFile}: an exclusively created private temp, the
|
|
372
|
+
* identity assertions around the write, `hooks.validateBeforeRename` immediately before the
|
|
373
|
+
* rename, the platform-aware replace, and the residual cleanup on any failure. A custom
|
|
374
|
+
* {@link AtomicWriteIO} is not accepted, because the point of this form is that the default
|
|
375
|
+
* writer owns the descriptor.
|
|
376
|
+
*/
|
|
377
|
+
export function atomicWriteFileStreamed(
|
|
378
|
+
path: string,
|
|
379
|
+
write: (descriptor: number) => void,
|
|
380
|
+
hooks: AtomicWriteHooks = {},
|
|
381
|
+
): void {
|
|
382
|
+
atomicWriteFileToTarget(path, write, resolveWriteTarget(path), undefined, hooks);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Atomically replace the named directory entry without resolving a symlink at
|
|
387
|
+
* that entry. This is for files in directories writable by another process:
|
|
388
|
+
* a raced symlink is replaced, never followed to a more privileged target.
|
|
389
|
+
*/
|
|
390
|
+
export function atomicWriteFileNoFollow(
|
|
391
|
+
path: string,
|
|
392
|
+
content: string,
|
|
393
|
+
io?: AtomicWriteIO,
|
|
394
|
+
hooks: AtomicWriteHooks = {},
|
|
395
|
+
): void {
|
|
396
|
+
// Only the final entry is no-follow: the parent still resolves, because an
|
|
397
|
+
// OS alias above the configured root (a home junction, /tmp) is legitimate
|
|
398
|
+
// and Windows cannot exclusive-create a temp through a junction.
|
|
399
|
+
atomicWriteFileToTarget(path, content, join(resolveWriteTarget(dirname(path)), basename(path)), io, hooks);
|
|
400
|
+
}
|
|
401
|
+
|
|
290
402
|
export interface AtomicWriteAsyncIO {
|
|
291
403
|
write: (path: string, content: string) => void | Promise<void>;
|
|
292
404
|
harden: (path: string) => void | Promise<void>;
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
remoteGuiConfigSchema,
|
|
61
61
|
runtimeRoleSchema,
|
|
62
62
|
spendSchema,
|
|
63
|
+
compactionRoutingSchema,
|
|
63
64
|
} from "./schema/leaf-validators";
|
|
64
65
|
|
|
65
66
|
export type ConfigDiagnostics = {
|
|
@@ -561,7 +562,26 @@ function managementIngressConfigError(value: unknown): string | null {
|
|
|
561
562
|
return null;
|
|
562
563
|
}
|
|
563
564
|
|
|
565
|
+
/** Load degrades malformed metrics export config to off; live writes reject the same shape. */
|
|
566
|
+
export function metricsExportConfigError(value: unknown): string | null {
|
|
567
|
+
const raw = rawConfigRecord(value);
|
|
568
|
+
if (!raw || !Object.hasOwn(raw, "metricsExport") || raw.metricsExport === undefined) return null;
|
|
569
|
+
const metricsExport = rawConfigRecord(raw.metricsExport);
|
|
570
|
+
if (!metricsExport) return "schema_invalid: metricsExport: must be an object or omitted";
|
|
571
|
+
if (Object.keys(metricsExport).some(key => key !== "enabled")) {
|
|
572
|
+
return "schema_invalid: metricsExport: contains an unsupported field";
|
|
573
|
+
}
|
|
574
|
+
if (metricsExport.enabled !== undefined && typeof metricsExport.enabled !== "boolean") {
|
|
575
|
+
return "schema_invalid: metricsExport.enabled: must be a boolean";
|
|
576
|
+
}
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
|
|
564
580
|
export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
|
|
581
|
+
const compactionRouting = rawConfigRecord(value)?.compactionRouting;
|
|
582
|
+
if (compactionRouting !== undefined && !compactionRoutingSchema.safeParse(compactionRouting).success) {
|
|
583
|
+
return { ok: false, error: "schema_invalid: compactionRouting: requires a nonblank model, an optional valid reasoningEffort, and optional non-repeating triggers drawn from \"manual\" and \"auto\"" };
|
|
584
|
+
}
|
|
565
585
|
const boundaryError = configReasoningPinsConfigError(value)
|
|
566
586
|
?? blankHostnameError(value)
|
|
567
587
|
?? claudeSubagentEffortError(value)
|
|
@@ -586,7 +606,8 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
|
|
|
586
606
|
?? clientConnectionConfigError(value)
|
|
587
607
|
?? clientRolePairError(value)
|
|
588
608
|
?? loopbackListenerPortError(value)
|
|
589
|
-
?? managementIngressConfigError(value)
|
|
609
|
+
?? managementIngressConfigError(value)
|
|
610
|
+
?? metricsExportConfigError(value);
|
|
590
611
|
if (boundaryError) return { ok: false, error: boundaryError };
|
|
591
612
|
const result = configSchema.safeParse(value);
|
|
592
613
|
if (result.success) {
|
|
@@ -12,6 +12,11 @@ export function ultraFastTierEnabled(config: Pick<OcxConfig, "ultraFastTier">):
|
|
|
12
12
|
return config.ultraFastTier === true;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** Default-off aggregate request metrics; activation is fixed for one server process lifetime. */
|
|
16
|
+
export function metricsExportEnabled(config: Pick<OcxConfig, "metricsExport">): boolean {
|
|
17
|
+
return config.metricsExport?.enabled === true;
|
|
18
|
+
}
|
|
19
|
+
|
|
15
20
|
/**
|
|
16
21
|
* Default cadence for the opt-in catalog auto-refresh (issue #3630): one converge pass
|
|
17
22
|
* per hour. Each pass spends a live /models call against every enabled provider, and
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
quotaResetNotifySchema,
|
|
33
33
|
remoteGuiConfigSchema,
|
|
34
34
|
retryOn429PolicySchema,
|
|
35
|
+
retryOnResetPolicySchema,
|
|
35
36
|
runtimeRoleSchema,
|
|
36
37
|
spendSchema,
|
|
37
38
|
} from "./schema/leaf-validators";
|
|
@@ -101,6 +102,24 @@ export function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig)
|
|
|
101
102
|
}
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
export function warnDegradedCompactionRouting(rawParsed: unknown, validated: OcxConfig): void {
|
|
106
|
+
if (!rawParsed || typeof rawParsed !== "object") return;
|
|
107
|
+
const raw = (rawParsed as Record<string, unknown>).compactionRouting;
|
|
108
|
+
if (raw !== undefined && validated.compactionRouting === undefined) {
|
|
109
|
+
console.warn("⚠️ config.json compactionRouting is invalid (expected { model, reasoningEffort?, triggers? } with a nonblank model, a declared effort, and triggers drawn without repetition from \"manual\" and \"auto\") — compaction keeps the conversation model");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Top-level opt-in blocks whose hand-edited form degrades to "off" instead of failing the whole
|
|
115
|
+
* schema. Grouped behind one entry point because `src/config.ts` sits at its file-size cap, and
|
|
116
|
+
* the ratchet only ever moves down: a per-block call there costs a line the file does not have.
|
|
117
|
+
*/
|
|
118
|
+
export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxConfig): void {
|
|
119
|
+
warnDegradedStreamMode(rawParsed, validated);
|
|
120
|
+
warnDegradedCompactionRouting(rawParsed, validated);
|
|
121
|
+
}
|
|
122
|
+
|
|
104
123
|
/**
|
|
105
124
|
* Load-time degradation for `retryOn429` (loadConfig only): one hand-edited invalid optional
|
|
106
125
|
* field (e.g. `attempts: 0` or a string) must not trip the whole provider schema and hide every
|
|
@@ -177,18 +196,44 @@ export function sanitizeRetryOn429ForLoad(parsed: unknown): void {
|
|
|
177
196
|
* redacted (a malformed write can place a secret in a property name).
|
|
178
197
|
*/
|
|
179
198
|
export function retryOn429PolicyConfigError(policy: unknown): string | null {
|
|
199
|
+
return strictPolicyConfigError("retryOn429", retryOn429PolicySchema, policy);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Management write-boundary validation for `retryOnReset`, with the same fail-closed contract
|
|
204
|
+
* as `retryOn429PolicyConfigError`: the load-time schema degrades a malformed block to
|
|
205
|
+
* "absent", so this is the one place a bad value is refused instead of silently dropped.
|
|
206
|
+
*/
|
|
207
|
+
export function retryOnResetPolicyConfigError(policy: unknown): string | null {
|
|
208
|
+
return strictPolicyConfigError("retryOnReset", retryOnResetPolicySchema, policy);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The shared body of both. Written once because the two differ only in the field name they
|
|
213
|
+
* report, and a second hand-copied formatter is a second place for the redaction to be
|
|
214
|
+
* forgotten.
|
|
215
|
+
*/
|
|
216
|
+
function strictPolicyConfigError(
|
|
217
|
+
field: string,
|
|
218
|
+
schema: {
|
|
219
|
+
safeParse: (value: unknown) => { success: true } | {
|
|
220
|
+
success: false;
|
|
221
|
+
error: { issues: Array<{ code: string; message: string; path: PropertyKey[]; keys?: string[] }> };
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
policy: unknown,
|
|
225
|
+
): string | null {
|
|
180
226
|
if (policy === undefined) return null;
|
|
181
|
-
const result =
|
|
227
|
+
const result = schema.safeParse(policy);
|
|
182
228
|
if (result.success) return null;
|
|
183
229
|
const first = result.error.issues[0];
|
|
184
|
-
if (!first) return
|
|
185
|
-
if (first.code === "unrecognized_keys") {
|
|
230
|
+
if (!first) return `${field} is invalid`;
|
|
231
|
+
if (first.code === "unrecognized_keys" && first.keys) {
|
|
186
232
|
const names = first.keys.map(key => JSON.stringify(redactSecretString(key))).join(", ");
|
|
187
|
-
return
|
|
233
|
+
return `${field} has unrecognized field${first.keys.length > 1 ? "s" : ""}: ${names}`;
|
|
188
234
|
}
|
|
189
|
-
if (first.path.length === 0) return
|
|
190
|
-
|
|
191
|
-
return `retryOn429.${field} is invalid (${first.message})`;
|
|
235
|
+
if (first.path.length === 0) return `${field} is invalid (${first.message})`;
|
|
236
|
+
return `${field}.${String(first.path[first.path.length - 1])} is invalid (${first.message})`;
|
|
192
237
|
}
|
|
193
238
|
|
|
194
239
|
export function sanitizeCapabilityDeclarationsForLoad(parsed: unknown): void {
|