@bitkyc08/opencodex 2.76.0 → 2.78.0-preview.20261005
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/bin/ocx.mjs +11 -10
- package/gui/dist/assets/App-CppMz-6w.js +54 -0
- package/gui/dist/assets/App-lCIslZ1L.css +1 -0
- package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-CJWQqtVv.js} +1 -1
- package/gui/dist/assets/index-DQt9srNi.js +94 -0
- package/gui/dist/assets/index-DmQB8D_R.css +1 -0
- package/gui/dist/assets/quota-summary-BMsyoZH2.js +1 -0
- package/gui/dist/index.html +4 -3
- package/package.json +1 -1
- package/src/adapters/anthropic/beta-allowlist.ts +19 -2
- package/src/adapters/anthropic/client-identity.ts +13 -4
- package/src/adapters/anthropic/native-client-preamble.ts +31 -0
- package/src/adapters/anthropic/passthrough.ts +103 -25
- package/src/adapters/antigravity-refusal.ts +28 -0
- package/src/adapters/cursor/protobuf-request.ts +6 -0
- package/src/adapters/cursor/request-builder.ts +3 -0
- package/src/adapters/cursor/structured-output.ts +13 -0
- package/src/adapters/cursor/types.ts +2 -0
- package/src/adapters/devin/cloud-direct/chat.ts +7 -2
- package/src/adapters/devin/trajectory.ts +49 -0
- package/src/adapters/devin.ts +13 -0
- package/src/adapters/ollama-native.ts +62 -19
- package/src/adapters/openai-responses/passthrough.ts +133 -103
- package/src/adapters/openai-responses/request-strips.ts +10 -4
- package/src/adapters/zed.ts +26 -4
- package/src/bridge/response-json.ts +14 -14
- package/src/bridge/sse.ts +15 -5
- package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
- package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
- package/src/claude/agents-inject.ts +4 -43
- package/src/claude/gateway-cache.ts +16 -13
- package/src/claude/inbound-model-options.ts +9 -2
- package/src/claude/intercept/cli-catalog.ts +27 -16
- package/src/claude/intercept/client-hello.ts +103 -0
- package/src/claude/intercept/model-bindings.ts +3 -2
- package/src/claude/intercept/picker-bootstrap.ts +37 -16
- package/src/claude/intercept/picker-budget.ts +41 -0
- package/src/claude/intercept/picker-listener.ts +279 -66
- package/src/claude/outbound.ts +2 -0
- package/src/claude/subagent-force-status.ts +53 -0
- package/src/claude/subagent-model.ts +93 -0
- package/src/cli/access-audio-input.ts +84 -0
- package/src/cli/access-audio-live.ts +99 -0
- package/src/cli/access-audio.ts +123 -0
- package/src/cli/access-data-client.ts +127 -0
- package/src/cli/access-data-plane.ts +104 -0
- package/src/cli/access-data-response.ts +76 -0
- package/src/cli/access.ts +65 -2
- package/src/cli/account-api.ts +33 -6
- package/src/cli/account-auth.ts +111 -42
- package/src/cli/account-key-quota.ts +118 -0
- package/src/cli/account-main.ts +11 -4
- package/src/cli/account-next-actions.ts +63 -0
- package/src/cli/account-policy-dto.ts +68 -0
- package/src/cli/account-policy.ts +167 -0
- package/src/cli/account-target.ts +8 -0
- package/src/cli/account.ts +37 -9
- package/src/cli/agent-runtime-settings.ts +130 -0
- package/src/cli/agent-settings.ts +101 -0
- package/src/cli/agent.ts +29 -2
- package/src/cli/alias.ts +7 -2
- package/src/cli/aside-profiles.ts +2 -2
- package/src/cli/capabilities-access-remote.ts +147 -0
- package/src/cli/capabilities-accounts.ts +195 -0
- package/src/cli/capabilities-agents-routing.ts +414 -0
- package/src/cli/capabilities-base.ts +783 -0
- package/src/cli/capabilities-command.ts +1 -0
- package/src/cli/capabilities-integrations.ts +264 -0
- package/src/cli/capabilities-lab.ts +214 -0
- package/src/cli/capabilities-observe-system.ts +662 -0
- package/src/cli/capabilities-provider-models.ts +302 -0
- package/src/cli/capabilities.ts +30 -1190
- package/src/cli/capability-types.ts +54 -0
- package/src/cli/catalog-command-result.ts +57 -0
- package/src/cli/chatgpt-command.ts +35 -9
- package/src/cli/claude-agent-startup-sync.ts +17 -0
- package/src/cli/claude-desktop-profile.ts +100 -0
- package/src/cli/claude-desktop.ts +39 -6
- package/src/cli/claude.ts +22 -6
- package/src/cli/codex-shim-autorestore.ts +2 -1
- package/src/cli/codex-shim-readiness.ts +109 -0
- package/src/cli/combo-input.ts +88 -0
- package/src/cli/combo-result.ts +52 -0
- package/src/cli/combo-stats.ts +123 -0
- package/src/cli/combo.ts +37 -14
- package/src/cli/companion-timeline.ts +142 -0
- package/src/cli/companion-usage.ts +146 -0
- package/src/cli/companion.ts +12 -3
- package/src/cli/config-command.ts +24 -8
- package/src/cli/connect.ts +19 -0
- package/src/cli/dispatch.ts +55 -82
- package/src/cli/doctor.ts +22 -4
- package/src/cli/export-command.ts +20 -9
- package/src/cli/gui.ts +8 -3
- package/src/cli/help-catalog.ts +101 -0
- package/src/cli/help-models-context.ts +17 -0
- package/src/cli/help-navigation.ts +50 -0
- package/src/cli/help-recovery.ts +66 -0
- package/src/cli/help.ts +69 -11
- package/src/cli/index.ts +38 -27
- package/src/cli/injection-follow.ts +35 -0
- package/src/cli/inspect.ts +5 -0
- package/src/cli/integration-aside-sync.ts +69 -0
- package/src/cli/integration-cursor.ts +106 -0
- package/src/cli/integration-input.ts +60 -0
- package/src/cli/integration-journal.ts +49 -0
- package/src/cli/integration-plan-dto.ts +156 -0
- package/src/cli/integration-preview.ts +215 -0
- package/src/cli/integrations.ts +24 -18
- package/src/cli/json-input.ts +134 -0
- package/src/cli/link.ts +32 -4
- package/src/cli/local-sync-result.ts +41 -0
- package/src/cli/log-follow.ts +84 -0
- package/src/cli/log-view-filter.ts +146 -0
- package/src/cli/logout-command.ts +44 -0
- package/src/cli/model-picker-ordering.ts +137 -0
- package/src/cli/models-custom-input.ts +107 -0
- package/src/cli/models-custom-runtime.ts +112 -0
- package/src/cli/models-order.ts +194 -0
- package/src/cli/models-runtime-subcommands.ts +2 -0
- package/src/cli/models-runtime.ts +9 -1
- package/src/cli/models.ts +66 -81
- package/src/cli/observe-stream.ts +142 -0
- package/src/cli/observe.ts +75 -34
- package/src/cli/opencode.ts +54 -10
- package/src/cli/provider-batch.ts +77 -0
- package/src/cli/provider-lifecycle-runtime.ts +170 -0
- package/src/cli/provider-result.ts +124 -0
- package/src/cli/provider-runtime.ts +15 -12
- package/src/cli/provider-settings.ts +143 -0
- package/src/cli/provider.ts +116 -64
- package/src/cli/registry.ts +90 -24
- package/src/cli/remote-workspace-hub.ts +83 -0
- package/src/cli/remote-workspace.ts +6 -1
- package/src/cli/restart-failure.ts +27 -0
- package/src/cli/root.ts +42 -9
- package/src/cli/route-policy-write.ts +137 -0
- package/src/cli/route-policy.ts +8 -0
- package/src/cli/runtime-api.ts +57 -80
- package/src/cli/secret-args.ts +149 -0
- package/src/cli/settings-result.ts +30 -0
- package/src/cli/storage.ts +77 -7
- package/src/cli/system-command.ts +60 -2
- package/src/cli/system-restart-client.ts +20 -3
- package/src/cli/system-settings-parity.ts +83 -0
- package/src/cli/tray-proxy.ts +3 -2
- package/src/cli/update-restart-candidate.ts +21 -0
- package/src/cli/update-restart-child.ts +81 -0
- package/src/cli/update-restart-home.ts +38 -0
- package/src/cli/update-restart-transport.ts +193 -0
- package/src/cli/update-restart.ts +260 -0
- package/src/cli/usage-model-search.ts +58 -0
- package/src/cli/usage-report.ts +13 -5
- package/src/cli/v2-input.ts +66 -0
- package/src/cli/v2-local-output.ts +37 -0
- package/src/cli/v2-runtime.ts +90 -0
- package/src/cli/v2.ts +102 -144
- package/src/clients/config-export/contracts.ts +100 -0
- package/src/clients/config-export/droid.ts +54 -2
- package/src/clients/config-export/dsh.ts +21 -0
- package/src/clients/config-export/kilo.ts +12 -2
- package/src/clients/config-export/model-metadata.ts +10 -4
- package/src/clients/config-export/reasoning-metadata.ts +47 -0
- package/src/clients/config-export.ts +53 -67
- package/src/clients/omo-role-models.ts +17 -3
- package/src/codex/account-credit-use.ts +4 -2
- package/src/codex/account-runtime-state.ts +21 -0
- package/src/codex/account-store.ts +11 -47
- package/src/codex/account-usability.ts +11 -8
- package/src/codex/auth-api/account-list.ts +73 -63
- package/src/codex/auth-api/main-account-probe.ts +25 -12
- package/src/codex/auth-api/pool-mode-gate.ts +6 -9
- package/src/codex/auth-api/pool-quota-probe.ts +2 -4
- package/src/codex/auth-api/reset-credit-service.ts +3 -2
- package/src/codex/auth-context.ts +30 -11
- package/src/codex/catalog/aggregation.ts +3 -0
- package/src/codex/catalog/build-entries.ts +1 -1
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/heal-observation.ts +66 -0
- package/src/codex/catalog/model-hints.ts +2 -1
- package/src/codex/catalog/model-visibility.ts +6 -3
- package/src/codex/catalog/parsing.ts +2 -0
- package/src/codex/catalog/provider-models.ts +13 -4
- package/src/codex/catalog/publication-observer.ts +19 -0
- package/src/codex/catalog/remote.ts +13 -15
- package/src/codex/catalog/restore.ts +19 -6
- package/src/codex/catalog/retained-sync.ts +60 -11
- package/src/codex/catalog/routed-removal.ts +116 -0
- package/src/codex/catalog/write-audit-contract.ts +21 -0
- package/src/codex/catalog/write-audit.ts +171 -0
- package/src/codex/catalog-self-heal.ts +284 -0
- package/src/codex/catalog-write-serialization.ts +71 -7
- package/src/codex/chatgpt-refresh-failure.ts +56 -0
- package/src/codex/codex-home-owner.ts +124 -0
- package/src/codex/convergence.ts +57 -16
- package/src/codex/features.ts +8 -1
- package/src/codex/inject/restore.ts +46 -4
- package/src/codex/inject.ts +11 -1
- package/src/codex/internal/catalog-writer.ts +120 -13
- package/src/codex/journal.ts +49 -13
- package/src/codex/main-account.ts +144 -11
- package/src/codex/management-convergence.ts +3 -0
- package/src/codex/quota-refresh-outcome.ts +17 -2
- package/src/codex/quota-types.ts +36 -1
- package/src/codex/quota.ts +67 -16
- package/src/codex/refresh.ts +20 -9
- package/src/codex/routing/cooldown-math.ts +10 -4
- package/src/codex/routing/selection.ts +11 -4
- package/src/codex/routing.ts +9 -9
- package/src/codex/runtime.ts +7 -1
- package/src/codex/shim-diagnostics.ts +80 -0
- package/src/codex/shim-fingerprint.ts +7 -2
- package/src/codex/shim-migration.ts +153 -0
- package/src/codex/shim-overlay.ts +509 -0
- package/src/codex/shim-path-resolution.ts +145 -0
- package/src/codex/shim-probe.ts +4 -3
- package/src/codex/shim-state-file.ts +93 -7
- package/src/codex/shim.ts +54 -107
- package/src/codex/subagent-model-fallback.ts +2 -1
- package/src/codex/sync.ts +24 -4
- package/src/combos/failover.ts +101 -1
- package/src/combos/index.ts +1 -0
- package/src/combos/jev.ts +13 -3
- package/src/combos/resolve.ts +12 -0
- package/src/config/atomic-write.ts +13 -8
- package/src/config/diagnostics.ts +23 -1
- package/src/config/load-degrade.ts +7 -0
- package/src/config/provider-validation.ts +3 -0
- package/src/config/schema/chatgpt-desktop.ts +22 -0
- package/src/config/schema/compaction-triggers.ts +8 -0
- package/src/config/schema/config-schema.ts +43 -14
- package/src/config/schema/leaf-validators.ts +8 -3
- package/src/config/subagent-models.ts +12 -0
- package/src/generated/compatibility-version.json +645 -237
- package/src/grok/session-identity.ts +33 -0
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/merge.ts +13 -5
- package/src/integrations/mutation-plan.ts +50 -5
- package/src/integrations/omp-yaml-source.ts +181 -1
- package/src/integrations/ownership-policy.ts +66 -0
- package/src/integrations/registry.ts +50 -1
- package/src/integrations/state.ts +106 -19
- package/src/integrations/target.ts +25 -2
- package/src/integrations/writer.ts +107 -35
- package/src/lib/gui-pair-capability.ts +17 -0
- package/src/lib/package-tree-integrity.ts +42 -1
- package/src/lib/provider-client-headers.ts +62 -0
- package/src/lib/proxy-env.ts +3 -0
- package/src/lib/test-home-guard.ts +2 -1
- package/src/oauth/anthropic-account-refusal.ts +19 -8
- package/src/oauth/anthropic-continuity.ts +65 -0
- package/src/oauth/anthropic-identity.ts +75 -0
- package/src/oauth/anthropic-routing.ts +20 -15
- package/src/oauth/anthropic.ts +34 -3
- package/src/oauth/generic-account-failover.ts +21 -0
- package/src/oauth/health.ts +32 -5
- package/src/oauth/index.ts +42 -35
- package/src/oauth/local-token-detect.ts +7 -0
- package/src/oauth/pool-settings-capability.ts +9 -2
- package/src/oauth/refresh-policy.ts +2 -0
- package/src/oauth/store.ts +55 -6
- package/src/oauth/types.ts +9 -0
- package/src/protocols/encoders/messages.ts +4 -3
- package/src/protocols/plan-snapshot.ts +1 -1
- package/src/protocols/settings.ts +28 -4
- package/src/providers/antigravity-effort-families.ts +75 -0
- package/src/providers/antigravity-models.ts +54 -16
- package/src/providers/antigravity-wire-snapshot.ts +66 -0
- package/src/providers/derive.ts +2 -0
- package/src/providers/model-rename-fields.ts +3 -0
- package/src/providers/new-model-policy.ts +48 -10
- package/src/providers/registry/entries-extended.ts +3 -0
- package/src/providers/registry/model-ids.ts +2 -0
- package/src/providers/registry/model-seeds.ts +5 -0
- package/src/providers/registry/types.ts +4 -0
- package/src/responses/compaction.ts +19 -0
- package/src/router.ts +6 -0
- package/src/server/auth-cors.ts +9 -0
- package/src/server/caller-session-identity.ts +32 -0
- package/src/server/chat-completions.ts +10 -0
- package/src/server/claude-messages.ts +29 -15
- package/src/server/droid-reasoning-default.ts +50 -0
- package/src/server/github-copilot-responses-repair.ts +3 -6
- package/src/server/grok-responses-control-frame.ts +2 -2
- package/src/server/grok-responses-snapshot-repair.ts +4 -5
- package/src/server/gui-session.ts +33 -11
- package/src/server/index/claude-intercept-lifecycle.ts +4 -0
- package/src/server/index/serve-options.ts +13 -7
- package/src/server/index.ts +1 -1
- package/src/server/management/agent-settings-routes.ts +12 -134
- package/src/server/management/config-routes.ts +1 -1
- package/src/server/management/context.ts +8 -1
- package/src/server/management/cursor-integration-routes.ts +3 -3
- package/src/server/management/integration-routes.ts +69 -11
- package/src/server/management/link-routes.ts +20 -13
- package/src/server/management/model-routes.ts +3 -3
- package/src/server/management/model-row-export-metadata.ts +150 -0
- package/src/server/management/model-rows.ts +148 -9
- package/src/server/management/native-integration-routes.ts +2 -2
- package/src/server/management/oauth-account-routes.ts +96 -30
- package/src/server/management/route-registry.ts +29 -27
- package/src/server/management/shared.ts +19 -5
- package/src/server/management/subagent-model-routes.ts +214 -0
- package/src/server/messages-native-eligibility.ts +3 -6
- package/src/server/messages-native-oauth.ts +82 -35
- package/src/server/messages-native.ts +78 -16
- package/src/server/relay.ts +14 -44
- package/src/server/request-log-generation-window.ts +38 -0
- package/src/server/request-log.ts +13 -0
- package/src/server/responses/adapter-dispatch.ts +152 -28
- package/src/server/responses/codex-auth-error.ts +10 -2
- package/src/server/responses/combo-stream-preflight.ts +29 -3
- package/src/server/responses/compaction-routing.ts +10 -1
- package/src/server/responses/core-auth.ts +67 -30
- package/src/server/responses/core-codex-account.ts +6 -3
- package/src/server/responses/core-combo-failure.ts +5 -1
- package/src/server/responses/core-combo-native.ts +7 -1
- package/src/server/responses/core-combo.ts +64 -7
- package/src/server/responses/core-normalize.ts +7 -0
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/fetch-helpers.ts +9 -0
- package/src/server/responses/passthrough-delivery.ts +1 -1
- package/src/server/responses/policy-fallback.ts +1 -1
- package/src/server/responses/request-prepare.ts +5 -0
- package/src/server/responses/request-spend.ts +30 -30
- package/src/server/responses-custom-tool-repair.ts +2 -2
- package/src/server/responses-hosted-image-display.ts +70 -9
- package/src/server/responses-terminal-repair.ts +15 -2
- package/src/server/sse-frame-buffer.ts +38 -36
- package/src/server/sse-payload-rewrite.ts +78 -49
- package/src/server/ws-bridge.ts +1 -11
- package/src/service/cli.ts +6 -1
- package/src/service/ownership-mutation-lease.d.mts +26 -0
- package/src/service/ownership-mutation-lease.mjs +76 -1
- package/src/service/windows-taskxml.ts +1 -2
- package/src/service-manager-probe.ts +36 -2
- package/src/types/config.ts +6 -0
- package/src/types/provider.ts +28 -10
- package/src/update/index.ts +2 -1
- package/src/update/npm-invocation.mjs +35 -3
- package/src/update/proxy-liveness-probe.mjs +38 -8
- package/src/update/update-failure-guidance.d.mts +6 -0
- package/src/update/update-failure-guidance.mjs +30 -5
- package/src/usage/expected-prices.ts +9 -0
- package/src/usage/log.ts +7 -0
- package/src/usage/summary.ts +82 -1
- package/src/web-search/loop.ts +11 -8
- package/src/web-search/run-turn-loop.ts +10 -7
- package/gui/dist/assets/App-CddorHdA.js +0 -51
- package/gui/dist/assets/App-DMudtISW.css +0 -1
- package/gui/dist/assets/index-Btze1I3T.css +0 -1
- package/gui/dist/assets/index-CvnY7E6i.js +0 -86
- package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Bottom-layer metadata contract: neither K nor the filesystem adapter owns intent. */
|
|
2
|
+
export type CatalogWriteIntent = "refresh" | "cache" | "pull" | "restore";
|
|
3
|
+
export type CatalogAuditConfigSource = "file" | "default" | "fallback" | "unreadable";
|
|
4
|
+
export type CatalogAuditRefusalReason = "foreign-owner" | "owner-unknown"
|
|
5
|
+
| "unbacked-routed-removal" | "unbacked-routed-clear";
|
|
6
|
+
|
|
7
|
+
export interface CatalogWriteAuditDetails {
|
|
8
|
+
readonly target: "catalog" | "cache";
|
|
9
|
+
readonly outcome: "written" | "refused";
|
|
10
|
+
readonly reason?: CatalogAuditRefusalReason;
|
|
11
|
+
readonly routedBefore?: number | null;
|
|
12
|
+
readonly routedAfter?: number | null;
|
|
13
|
+
readonly configSource?: CatalogAuditConfigSource;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CatalogWriteAuditEvent extends CatalogWriteAuditDetails {
|
|
17
|
+
readonly intent: CatalogWriteIntent;
|
|
18
|
+
readonly writer: string;
|
|
19
|
+
/** Supplied by K, never discovered by the adapter. */
|
|
20
|
+
readonly opencodexHome: string;
|
|
21
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync, constants, fstatSync, ftruncateSync, lstatSync, openSync, readSync, writeSync,
|
|
3
|
+
type Stats,
|
|
4
|
+
} from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { redactUserPath } from "../../lib/redact";
|
|
7
|
+
import { hardenSecretPath, windowsSecretAclApplies } from "../../lib/windows-secret-acl";
|
|
8
|
+
import type { CatalogWriteAuditEvent } from "./write-audit-contract";
|
|
9
|
+
|
|
10
|
+
export const CODEX_CATALOG_AUDIT_FILE = "opencodex-catalog-audit.jsonl";
|
|
11
|
+
export const CATALOG_AUDIT_MAX_BYTES = 256 * 1024;
|
|
12
|
+
export const CATALOG_AUDIT_KEEP_RECORDS = 400;
|
|
13
|
+
export const CATALOG_AUDIT_MAX_EVENT_BYTES = 2 * 1024;
|
|
14
|
+
const MAX_HOME_CHARACTERS = 256;
|
|
15
|
+
const AUDIT_ACL_DEADLINE_MS = 1000;
|
|
16
|
+
const WRITERS = new Set([
|
|
17
|
+
"convergence", "retained-sync", "cache-from-catalog", "cache-invalidate",
|
|
18
|
+
"catalog-pull", "catalog-restore", "codex-restore", "sync-cache", "startup-cache",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export function codexCatalogAuditPath(codexHome: string): string {
|
|
22
|
+
return join(codexHome, CODEX_CATALOG_AUDIT_FILE);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function safeCount(value: number | null | undefined): number | null {
|
|
26
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function auditLine(event: CatalogWriteAuditEvent): Buffer {
|
|
30
|
+
const writer = WRITERS.has(event.writer) ? event.writer : "other";
|
|
31
|
+
// Explicit projection: no argv, arbitrary event properties, model IDs or provider names.
|
|
32
|
+
return Buffer.from(`${JSON.stringify({
|
|
33
|
+
at: new Date().toISOString(), pid: process.pid, ppid: process.ppid,
|
|
34
|
+
command: writer,
|
|
35
|
+
opencodexHome: redactUserPath(event.opencodexHome).slice(0, MAX_HOME_CHARACTERS),
|
|
36
|
+
target: event.target, outcome: event.outcome, reason: event.reason,
|
|
37
|
+
intent: event.intent, writer,
|
|
38
|
+
configSource: event.configSource,
|
|
39
|
+
routedBefore: safeCount(event.routedBefore), routedAfter: safeCount(event.routedAfter),
|
|
40
|
+
})}\n`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function regularPrivateFile(stat: Stats): boolean {
|
|
44
|
+
// POSIX bits cannot prove NTFS privacy. Windows requires fresh-file ACL hardening.
|
|
45
|
+
return !windowsSecretAclApplies() && stat.isFile() && stat.nlink === 1
|
|
46
|
+
&& stat.uid === process.getuid?.() && (stat.mode & 0o777) === 0o600;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sameFile(left: Stats, right: Stats): boolean {
|
|
50
|
+
return left.dev === right.dev && left.ino === right.ino && left.size === right.size
|
|
51
|
+
&& left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Read at most the tail budget plus one byte to locate a complete first record. */
|
|
55
|
+
function readTail(fd: number, size: number): Buffer {
|
|
56
|
+
const start = Math.max(0, size - CATALOG_AUDIT_MAX_BYTES - 1);
|
|
57
|
+
const bytes = Buffer.alloc(size - start);
|
|
58
|
+
let length = 0;
|
|
59
|
+
while (length < bytes.length) {
|
|
60
|
+
const count = readSync(fd, bytes, length, bytes.length - length, start + length);
|
|
61
|
+
if (count === 0) break;
|
|
62
|
+
length += count;
|
|
63
|
+
}
|
|
64
|
+
if (length !== bytes.length) throw new Error("audit file changed during read");
|
|
65
|
+
if (start === 0) return bytes;
|
|
66
|
+
const boundary = bytes.indexOf(10);
|
|
67
|
+
return boundary === -1 ? Buffer.alloc(0) : bytes.subarray(boundary + 1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function completeRecords(bytes: Buffer): { records: Buffer[]; count: number; clean: boolean } {
|
|
71
|
+
const records: Buffer[] = [];
|
|
72
|
+
let start = 0;
|
|
73
|
+
let clean = true;
|
|
74
|
+
let count = 0;
|
|
75
|
+
for (let end = bytes.indexOf(10); end !== -1; end = bytes.indexOf(10, start)) {
|
|
76
|
+
const line = bytes.subarray(start, end);
|
|
77
|
+
try {
|
|
78
|
+
const text = line.toString("utf8");
|
|
79
|
+
if (!line.equals(Buffer.from(text))) throw new Error("invalid UTF-8");
|
|
80
|
+
JSON.parse(text);
|
|
81
|
+
records[count % CATALOG_AUDIT_KEEP_RECORDS] = bytes.subarray(start, end + 1);
|
|
82
|
+
count++;
|
|
83
|
+
} catch { clean = false; }
|
|
84
|
+
start = end + 1;
|
|
85
|
+
}
|
|
86
|
+
const pivot = count > CATALOG_AUDIT_KEEP_RECORDS ? count % CATALOG_AUDIT_KEEP_RECORDS : 0;
|
|
87
|
+
return { records: records.slice(pivot).concat(records.slice(0, pivot)), count,
|
|
88
|
+
clean: clean && start === bytes.length };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function writeAll(fd: number, bytes: Buffer, position: number | null): void {
|
|
92
|
+
let offset = 0;
|
|
93
|
+
while (offset < bytes.length) {
|
|
94
|
+
const count = writeSync(fd, bytes, offset, bytes.length - offset,
|
|
95
|
+
position === null ? null : position + offset);
|
|
96
|
+
if (count === 0) throw new Error("audit write made no progress");
|
|
97
|
+
offset += count;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Best-effort adapter. Caller MUST hold K, even for a refusal that has no permit.
|
|
103
|
+
* POSIX owners compact the descriptor; foreign callers only append within both caps.
|
|
104
|
+
* Windows only publishes into fresh owner files after required ACL hardening.
|
|
105
|
+
* This diagnostic has no replay or authorization role. Process death may leave a partial line.
|
|
106
|
+
*/
|
|
107
|
+
export function appendCatalogWriteAudit(
|
|
108
|
+
codexHome: string,
|
|
109
|
+
event: CatalogWriteAuditEvent,
|
|
110
|
+
options: { readonly create: boolean },
|
|
111
|
+
): "appended" | "created" | "skipped" {
|
|
112
|
+
let fd: number | undefined;
|
|
113
|
+
try {
|
|
114
|
+
const line = auditLine(event);
|
|
115
|
+
if (line.length > CATALOG_AUDIT_MAX_EVENT_BYTES) return "skipped";
|
|
116
|
+
const path = codexCatalogAuditPath(codexHome);
|
|
117
|
+
const windows = windowsSecretAclApplies();
|
|
118
|
+
let before: Stats | undefined;
|
|
119
|
+
try { before = lstatSync(path); }
|
|
120
|
+
catch (error) {
|
|
121
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT" || !options.create) return "skipped";
|
|
122
|
+
}
|
|
123
|
+
if (before && !regularPrivateFile(before)) return "skipped";
|
|
124
|
+
// No mkdir, pathname truncation or chmod. O_EXCL prevents creation over a raced path.
|
|
125
|
+
const flags = constants.O_RDWR | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)
|
|
126
|
+
| (before ? (options.create ? 0 : constants.O_APPEND) : constants.O_CREAT | constants.O_EXCL);
|
|
127
|
+
fd = openSync(path, flags, 0o600);
|
|
128
|
+
let opened = fstatSync(fd);
|
|
129
|
+
if (windows) {
|
|
130
|
+
// Existing files have already been refused: no read-only NTFS privacy verifier exists.
|
|
131
|
+
if (!opened.isFile() || opened.nlink !== 1 || opened.size !== 0
|
|
132
|
+
|| !sameFile(opened, lstatSync(path))) return "skipped";
|
|
133
|
+
if (!hardenSecretPath(path, { required: true, deadlineMs: AUDIT_ACL_DEADLINE_MS }).ok) return "skipped";
|
|
134
|
+
const hardened = fstatSync(fd);
|
|
135
|
+
// ACL changes legitimately move ctime. Retain object identity, then take a fresh baseline.
|
|
136
|
+
if (!hardened.isFile() || hardened.nlink !== 1 || hardened.size !== 0
|
|
137
|
+
|| opened.dev !== hardened.dev || opened.ino !== hardened.ino
|
|
138
|
+
|| !sameFile(hardened, lstatSync(path))) return "skipped";
|
|
139
|
+
opened = hardened;
|
|
140
|
+
} else if (!regularPrivateFile(opened) || (before && !sameFile(before, opened))) return "skipped";
|
|
141
|
+
if (!options.create && opened.size + line.length > CATALOG_AUDIT_MAX_BYTES) return "skipped";
|
|
142
|
+
const tail = readTail(fd, opened.size);
|
|
143
|
+
const parsed = completeRecords(tail);
|
|
144
|
+
if (!sameFile(opened, fstatSync(fd)) || !sameFile(opened, lstatSync(path))) return "skipped";
|
|
145
|
+
if (!options.create) {
|
|
146
|
+
if (!parsed.clean || parsed.count + 1 > CATALOG_AUDIT_KEEP_RECORDS) return "skipped";
|
|
147
|
+
writeAll(fd, line, null);
|
|
148
|
+
} else if (opened.size <= CATALOG_AUDIT_MAX_BYTES - line.length
|
|
149
|
+
&& parsed.clean && parsed.count < CATALOG_AUDIT_KEEP_RECORDS) {
|
|
150
|
+
writeAll(fd, line, opened.size);
|
|
151
|
+
} else {
|
|
152
|
+
const retained: Buffer[] = [line];
|
|
153
|
+
let bytes = line.length;
|
|
154
|
+
for (let index = parsed.records.length - 1; index >= 0 && retained.length < CATALOG_AUDIT_KEEP_RECORDS; index--) {
|
|
155
|
+
const record = parsed.records[index]!;
|
|
156
|
+
if (bytes + record.length > CATALOG_AUDIT_MAX_BYTES) break;
|
|
157
|
+
retained.unshift(record);
|
|
158
|
+
bytes += record.length;
|
|
159
|
+
}
|
|
160
|
+
const output = Buffer.concat(retained, bytes);
|
|
161
|
+
writeAll(fd, output, 0);
|
|
162
|
+
ftruncateSync(fd, output.length);
|
|
163
|
+
}
|
|
164
|
+
return before ? "appended" : "created";
|
|
165
|
+
} catch {
|
|
166
|
+
// Deliberately best-effort: an audit IO failure never changes the catalog result.
|
|
167
|
+
return "skipped";
|
|
168
|
+
} finally {
|
|
169
|
+
if (fd !== undefined) { try { closeSync(fd); } catch { /* diagnostic only */ } }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { readConfigAdmissionSnapshot } from "../config/diagnostics";
|
|
2
|
+
import { readRuntimePort } from "../config/process-state";
|
|
3
|
+
import { readClientConnectionState } from "../client/state";
|
|
4
|
+
import { getActiveTurnCount, isDraining, isRecyclingForExit, isShutdownDraining } from "../server/lifecycle";
|
|
5
|
+
import type { OcxConfig } from "../types";
|
|
6
|
+
import { observeCatalogHealFile, sameCatalogHealPath, selectCatalogHealPath, type CatalogObservation } from "./catalog/heal-observation";
|
|
7
|
+
import { subscribeCatalogPublication } from "./catalog/publication-observer";
|
|
8
|
+
import { configEnablesRoutedNamespace, ocxRoutedNamespaceCounts } from "./catalog/routed-removal";
|
|
9
|
+
import { inspectCodexHomeOwner } from "./codex-home-owner";
|
|
10
|
+
import { shouldSyncCodexOnStart } from "./desired-state";
|
|
11
|
+
import { JOURNAL_PATH, journalOwner } from "./journal";
|
|
12
|
+
import { CODEX_HOME, DEFAULT_CATALOG_PATH, resolveCodexConfigPath } from "./paths";
|
|
13
|
+
import { siblingOfLivePort } from "./sibling-start";
|
|
14
|
+
|
|
15
|
+
export type { CatalogObservation } from "./catalog/heal-observation";
|
|
16
|
+
export const CATALOG_HEAL_TICK_MS = 30_000;
|
|
17
|
+
export const CATALOG_HEAL_RECHECK_MS = 5 * 60_000;
|
|
18
|
+
export const CATALOG_HEAL_WINDOW_MS = 60 * 60_000;
|
|
19
|
+
export const CATALOG_HEAL_MAX_ATTEMPTS = 6;
|
|
20
|
+
export const CATALOG_HEAL_MAX_HEALS = 3;
|
|
21
|
+
|
|
22
|
+
export type CatalogSelfHealGate = "sibling" | "exiting" | "idle" | "runtime-record" | "config-unavailable" | "codex-off" | "not-owner" | "client";
|
|
23
|
+
export interface CatalogSelfHealGates {
|
|
24
|
+
siblingOfLivePort(): number | null;
|
|
25
|
+
exiting(): boolean;
|
|
26
|
+
idle(): boolean;
|
|
27
|
+
runtimeOwned(): boolean;
|
|
28
|
+
/** File-backed config authority; missing, malformed or salvaged config returns null. */
|
|
29
|
+
loadConfig(): OcxConfig | null;
|
|
30
|
+
ownsCodexHome(): boolean;
|
|
31
|
+
clientConnected(): boolean;
|
|
32
|
+
clientJournalOwner(): boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface CatalogHealOutcome { readonly committed: boolean }
|
|
35
|
+
export interface CatalogHealLifecycle {
|
|
36
|
+
readonly beforeCommit: () => boolean;
|
|
37
|
+
readonly expectedCatalogPath: string;
|
|
38
|
+
}
|
|
39
|
+
export interface CatalogSelfHealRecord {
|
|
40
|
+
readonly at: string;
|
|
41
|
+
readonly lostNamespaces: number;
|
|
42
|
+
readonly committed: boolean;
|
|
43
|
+
}
|
|
44
|
+
export interface CatalogSelfHealHandle {
|
|
45
|
+
stop(): void;
|
|
46
|
+
lastHeal(): CatalogSelfHealRecord | null;
|
|
47
|
+
tickForTests(): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
export interface CatalogSelfHealDeps {
|
|
50
|
+
scheduleFn?: (fn: () => void, ms: number) => { cancel(): void };
|
|
51
|
+
now?: () => number;
|
|
52
|
+
catalogPath?: () => string | null;
|
|
53
|
+
observe?: (path: string, readContent: boolean) => CatalogObservation | null;
|
|
54
|
+
converge?: (config: OcxConfig, lifecycle: CatalogHealLifecycle) => Promise<CatalogHealOutcome>;
|
|
55
|
+
subscribe?: typeof subscribeCatalogPublication;
|
|
56
|
+
gates?: Partial<CatalogSelfHealGates>;
|
|
57
|
+
log?: Pick<Console, "warn">;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function defaultSchedule(fn: () => void, ms: number): { cancel(): void } {
|
|
61
|
+
const timer = setTimeout(fn, ms);
|
|
62
|
+
timer.unref?.();
|
|
63
|
+
return { cancel: () => clearTimeout(timer) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function convergeOwnCatalog(config: OcxConfig, lifecycle: CatalogHealLifecycle): Promise<CatalogHealOutcome> {
|
|
67
|
+
const [{ armDetachedConfigBaseline }, { createManagementConvergeCodex }, { createCatalogConvergeRequest }] = await Promise.all([
|
|
68
|
+
import("../config"), import("./management-convergence"), import("./catalog-admission"),
|
|
69
|
+
]);
|
|
70
|
+
if (!lifecycle.beforeCommit()) return { committed: false };
|
|
71
|
+
// Discovery persistence rebases this detached snapshot against current disk config.
|
|
72
|
+
armDetachedConfigBaseline(config);
|
|
73
|
+
const outcome = await createManagementConvergeCodex(config, lifecycle)(createCatalogConvergeRequest({ deadlineMs: 1_000 }));
|
|
74
|
+
return { committed: outcome.kind === "catalog-only" && outcome.catalogRefresh.status === "committed" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function evaluateCatalogSelfHealGates(gates: CatalogSelfHealGates):
|
|
78
|
+
{ readonly open: true; readonly config: OcxConfig } | { readonly open: false; readonly gate: CatalogSelfHealGate } {
|
|
79
|
+
if (gates.siblingOfLivePort() !== null) return { open: false, gate: "sibling" };
|
|
80
|
+
if (gates.exiting()) return { open: false, gate: "exiting" };
|
|
81
|
+
if (!gates.idle()) return { open: false, gate: "idle" };
|
|
82
|
+
if (!gates.runtimeOwned()) return { open: false, gate: "runtime-record" };
|
|
83
|
+
const config = gates.loadConfig();
|
|
84
|
+
if (config === null) return { open: false, gate: "config-unavailable" };
|
|
85
|
+
if (!shouldSyncCodexOnStart(config)) return { open: false, gate: "codex-off" };
|
|
86
|
+
if (gates.clientConnected() || gates.clientJournalOwner()) return { open: false, gate: "client" };
|
|
87
|
+
if (!gates.ownsCodexHome()) return { open: false, gate: "not-owner" };
|
|
88
|
+
return { open: true, config };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type NamespaceObservation = { path: string; signature: string; namespaces: ReadonlySet<string> | null };
|
|
92
|
+
|
|
93
|
+
/** Owner lifecycle only: importing this module starts no timer or publication subscription. */
|
|
94
|
+
export function startCodexCatalogSelfHeal(options: { port?: number; deps?: CatalogSelfHealDeps } = {}): CatalogSelfHealHandle {
|
|
95
|
+
const deps = options.deps ?? {};
|
|
96
|
+
const scheduleFn = deps.scheduleFn ?? defaultSchedule;
|
|
97
|
+
const clock = deps.now ?? (() => performance.now());
|
|
98
|
+
const catalogPath = deps.catalogPath ?? (() => selectCatalogHealPath(JOURNAL_PATH, DEFAULT_CATALOG_PATH, resolveCodexConfigPath));
|
|
99
|
+
const observe = deps.observe ?? observeCatalogHealFile;
|
|
100
|
+
const converge = deps.converge ?? convergeOwnCatalog;
|
|
101
|
+
const gates: CatalogSelfHealGates = {
|
|
102
|
+
siblingOfLivePort,
|
|
103
|
+
exiting: () => isRecyclingForExit() || isShutdownDraining(),
|
|
104
|
+
idle: () => !isDraining() && getActiveTurnCount() === 0,
|
|
105
|
+
runtimeOwned: () => {
|
|
106
|
+
const runtime = readRuntimePort(process.pid);
|
|
107
|
+
return runtime !== null && runtime.siblingOfPort === undefined
|
|
108
|
+
&& (options.port === undefined || runtime.port === options.port);
|
|
109
|
+
},
|
|
110
|
+
loadConfig: () => {
|
|
111
|
+
const snapshot = readConfigAdmissionSnapshot();
|
|
112
|
+
return snapshot.kind === "read" && snapshot.diagnostics.source === "file"
|
|
113
|
+
? snapshot.diagnostics.config : null;
|
|
114
|
+
},
|
|
115
|
+
ownsCodexHome: () => {
|
|
116
|
+
const owner = inspectCodexHomeOwner(CODEX_HOME);
|
|
117
|
+
return owner.kind === "owned" || owner.kind === "unbound";
|
|
118
|
+
},
|
|
119
|
+
clientConnected: () => readClientConnectionState().kind !== "disconnected",
|
|
120
|
+
clientJournalOwner: () => journalOwner({ readOnly: true })?.kind === "client",
|
|
121
|
+
...deps.gates,
|
|
122
|
+
};
|
|
123
|
+
const log = deps.log ?? console;
|
|
124
|
+
let stopped = false;
|
|
125
|
+
let released = false;
|
|
126
|
+
let generation = 0;
|
|
127
|
+
let timer: { cancel(): void } | undefined;
|
|
128
|
+
let running = false;
|
|
129
|
+
let last: CatalogSelfHealRecord | null = null;
|
|
130
|
+
let target: string | null = null;
|
|
131
|
+
let baseline: NamespaceObservation | null = null;
|
|
132
|
+
let observed: NamespaceObservation | null = null;
|
|
133
|
+
let pendingRetry: { path: string; lostNamespaces: readonly string[] } | null = null;
|
|
134
|
+
let recheckAt = 0;
|
|
135
|
+
const attempts: number[] = [];
|
|
136
|
+
const heals: number[] = [];
|
|
137
|
+
|
|
138
|
+
const read = (path: string): NamespaceObservation | null => {
|
|
139
|
+
const quick = observe(path, false);
|
|
140
|
+
if (quick === null) return null;
|
|
141
|
+
if (observed !== null && sameCatalogHealPath(observed.path, path) && observed.signature === quick.signature) return observed;
|
|
142
|
+
const seen = observe(path, true);
|
|
143
|
+
if (seen === null) return null;
|
|
144
|
+
observed = { path, signature: seen.signature, namespaces: seen.catalog ? new Set(ocxRoutedNamespaceCounts(seen.catalog).keys()) : null };
|
|
145
|
+
return observed;
|
|
146
|
+
};
|
|
147
|
+
const accept = (path: string): void => {
|
|
148
|
+
const seen = read(path);
|
|
149
|
+
if (seen?.namespaces != null) baseline = seen;
|
|
150
|
+
};
|
|
151
|
+
const clear = (): void => {
|
|
152
|
+
generation += 1;
|
|
153
|
+
baseline = observed = null;
|
|
154
|
+
pendingRetry = null;
|
|
155
|
+
recheckAt = 0;
|
|
156
|
+
};
|
|
157
|
+
const updateTarget = (path: string | null): void => {
|
|
158
|
+
// Unavailable selection is not evidence of a different target or lost authority.
|
|
159
|
+
if (path === null) return;
|
|
160
|
+
if (target !== null && sameCatalogHealPath(target, path)) return;
|
|
161
|
+
clear();
|
|
162
|
+
target = path;
|
|
163
|
+
};
|
|
164
|
+
const beforeCommit = (path: string, entryGeneration: number): boolean => {
|
|
165
|
+
try {
|
|
166
|
+
const selected = catalogPath();
|
|
167
|
+
return !stopped && !released && generation === entryGeneration
|
|
168
|
+
&& selected !== null && sameCatalogHealPath(selected, path) && evaluateCatalogSelfHealGates(gates).open;
|
|
169
|
+
} catch { return false; }
|
|
170
|
+
};
|
|
171
|
+
const capRetryAt = (now: number): number => {
|
|
172
|
+
while (attempts.length && now - attempts[0]! >= CATALOG_HEAL_WINDOW_MS) attempts.shift();
|
|
173
|
+
while (heals.length && now - heals[0]! >= CATALOG_HEAL_WINDOW_MS) heals.shift();
|
|
174
|
+
return Math.max(
|
|
175
|
+
attempts.length >= CATALOG_HEAL_MAX_ATTEMPTS ? attempts[0]! + CATALOG_HEAL_WINDOW_MS : 0,
|
|
176
|
+
heals.length >= CATALOG_HEAL_MAX_HEALS ? heals[0]! + CATALOG_HEAL_WINDOW_MS : 0,
|
|
177
|
+
);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const tick = async (): Promise<void> => {
|
|
181
|
+
if (stopped || running || released) return;
|
|
182
|
+
running = true;
|
|
183
|
+
try {
|
|
184
|
+
const path = catalogPath();
|
|
185
|
+
updateTarget(path);
|
|
186
|
+
if (path === null) return;
|
|
187
|
+
// Pending cache/funnel failure is independent of catalog stat or apparent repaired rows.
|
|
188
|
+
if (pendingRetry) {
|
|
189
|
+
const config = gates.loadConfig();
|
|
190
|
+
if (config === null) return;
|
|
191
|
+
const enabled = pendingRetry.lostNamespaces.filter(ns => configEnablesRoutedNamespace(config, ns));
|
|
192
|
+
if (enabled.length === 0) {
|
|
193
|
+
pendingRetry = null;
|
|
194
|
+
recheckAt = 0;
|
|
195
|
+
const seen = read(path);
|
|
196
|
+
if (seen?.namespaces != null) baseline = seen;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
pendingRetry = { path, lostNamespaces: enabled };
|
|
200
|
+
}
|
|
201
|
+
const now = clock();
|
|
202
|
+
if (now < recheckAt) return;
|
|
203
|
+
const seen = read(path);
|
|
204
|
+
// Missing/corrupt custom paths are unavailable, never reconstructed from the default.
|
|
205
|
+
if (seen?.namespaces == null) return;
|
|
206
|
+
if (baseline === null) { baseline = seen; return; }
|
|
207
|
+
if (!pendingRetry) {
|
|
208
|
+
if (baseline.signature === seen.signature) return;
|
|
209
|
+
const lost = [...baseline.namespaces!].filter(ns => !seen.namespaces!.has(ns));
|
|
210
|
+
if (lost.length === 0) { baseline = seen; return; }
|
|
211
|
+
pendingRetry = { path, lostNamespaces: lost };
|
|
212
|
+
}
|
|
213
|
+
const gate = evaluateCatalogSelfHealGates(gates);
|
|
214
|
+
if (!gate.open) { recheckAt = now + CATALOG_HEAL_RECHECK_MS; return; }
|
|
215
|
+
const lostEnabled = pendingRetry.lostNamespaces.filter(ns => configEnablesRoutedNamespace(gate.config, ns));
|
|
216
|
+
if (lostEnabled.length === 0) { pendingRetry = null; baseline = seen; return; }
|
|
217
|
+
pendingRetry = { path, lostNamespaces: lostEnabled };
|
|
218
|
+
const cappedUntil = capRetryAt(now);
|
|
219
|
+
if (cappedUntil > now) { recheckAt = cappedUntil; return; }
|
|
220
|
+
attempts.push(now);
|
|
221
|
+
const entryGeneration = generation;
|
|
222
|
+
log.warn(`[catalog-self-heal] republishing ${lostEnabled.length} lost routed provider namespace${lostEnabled.length === 1 ? "" : "s"}`);
|
|
223
|
+
let committed = false;
|
|
224
|
+
try { committed = (await converge(gate.config, {
|
|
225
|
+
beforeCommit: () => beforeCommit(path, entryGeneration), expectedCatalogPath: path,
|
|
226
|
+
})).committed; }
|
|
227
|
+
catch { /* Failed attempts retain pending retry and consume the attempt budget. */ }
|
|
228
|
+
const selected = catalogPath();
|
|
229
|
+
if (stopped || released || generation !== entryGeneration || selected === null || !sameCatalogHealPath(selected, path)) return;
|
|
230
|
+
last = { at: new Date().toISOString(), lostNamespaces: lostEnabled.length, committed };
|
|
231
|
+
if (committed) {
|
|
232
|
+
heals.push(clock());
|
|
233
|
+
pendingRetry = null;
|
|
234
|
+
recheckAt = 0;
|
|
235
|
+
accept(path); // Authoritative emptiness is a successful owner baseline too.
|
|
236
|
+
} else recheckAt = clock() + CATALOG_HEAL_RECHECK_MS;
|
|
237
|
+
} catch {
|
|
238
|
+
log.warn("[catalog-self-heal] observation unavailable; deferred");
|
|
239
|
+
} finally { running = false; }
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const unsubscribe = (deps.subscribe ?? subscribeCatalogPublication)(event => {
|
|
243
|
+
if (stopped) return;
|
|
244
|
+
// Native restoration can remove the journal before notification. Fence the known
|
|
245
|
+
// target (and target-less releases) without resolving possibly unavailable evidence.
|
|
246
|
+
const release = event.kind === "native-released" || event.intent === "restore";
|
|
247
|
+
if (release && (event.path === null || (target !== null && sameCatalogHealPath(event.path, target)))) {
|
|
248
|
+
clear();
|
|
249
|
+
released = true;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const path = catalogPath();
|
|
253
|
+
if (event.path !== null && (target === null || !sameCatalogHealPath(event.path, target))
|
|
254
|
+
&& (path === null || !sameCatalogHealPath(event.path, path))) return;
|
|
255
|
+
if (release) {
|
|
256
|
+
clear();
|
|
257
|
+
released = true; // Only a later accepted owner publication can re-arm this lifecycle.
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (running) return; // Catalog-success/cache-failure must not accept a baseline.
|
|
261
|
+
// A committed owner publication supplies a read-only baseline even while heal writes are gated.
|
|
262
|
+
if (event.path === null || path === null || !sameCatalogHealPath(event.path, path)) return;
|
|
263
|
+
updateTarget(path);
|
|
264
|
+
released = false;
|
|
265
|
+
pendingRetry = null;
|
|
266
|
+
recheckAt = 0;
|
|
267
|
+
accept(event.path);
|
|
268
|
+
});
|
|
269
|
+
// Capture immediately after startup sync, before a foreign write can become the first tick.
|
|
270
|
+
try { updateTarget(catalogPath()); if (target !== null) accept(target); }
|
|
271
|
+
catch { /* Unavailable observation can be retried by the scheduled tick. */ }
|
|
272
|
+
const schedule = (): void => {
|
|
273
|
+
if (stopped) return;
|
|
274
|
+
timer = scheduleFn(() => {
|
|
275
|
+
void tick().catch(() => log.warn("[catalog-self-heal] scheduled observation failed")).finally(schedule);
|
|
276
|
+
}, CATALOG_HEAL_TICK_MS);
|
|
277
|
+
};
|
|
278
|
+
schedule();
|
|
279
|
+
return {
|
|
280
|
+
stop() { stopped = true; clear(); timer?.cancel(); timer = undefined; unsubscribe(); },
|
|
281
|
+
lastHeal: () => last,
|
|
282
|
+
tickForTests: tick,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
@@ -28,12 +28,16 @@ import { chmodSync, lstatSync, realpathSync } from "node:fs";
|
|
|
28
28
|
|
|
29
29
|
import { Database } from "bun:sqlite";
|
|
30
30
|
|
|
31
|
+
import { currentOpencodexHome, inspectCodexHomeOwner } from "./codex-home-owner";
|
|
32
|
+
import { appendCatalogWriteAudit } from "./catalog/write-audit";
|
|
33
|
+
import type { CatalogWriteAuditDetails, CatalogWriteIntent } from "./catalog/write-audit-contract";
|
|
31
34
|
import {
|
|
32
35
|
CodexUserIdentityRefusal,
|
|
33
36
|
resolveCodexCatalogSerializationDatabasePath,
|
|
34
37
|
resolveEffectiveUserIdentity,
|
|
35
38
|
samePathIdentity,
|
|
36
39
|
} from "./user-identity";
|
|
40
|
+
import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard";
|
|
37
41
|
|
|
38
42
|
/**
|
|
39
43
|
* Authorization to perform the fixed sequence of catalog mutations inside ONE
|
|
@@ -51,9 +55,16 @@ export interface CatalogWritePermit {
|
|
|
51
55
|
|
|
52
56
|
declare const catalogWritePermitBrand: unique symbol;
|
|
53
57
|
|
|
58
|
+
export type { CatalogWriteIntent } from "./catalog/write-audit-contract";
|
|
59
|
+
|
|
60
|
+
export interface CatalogWriteOptions {
|
|
61
|
+
readonly intent: CatalogWriteIntent;
|
|
62
|
+
readonly writer: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
export type CatalogSerializationOutcome<T> =
|
|
55
66
|
| { kind: "completed"; value: T }
|
|
56
|
-
| { kind: "unavailable"; reason: "busy" | "database" | "unsafe-path" };
|
|
67
|
+
| { kind: "unavailable"; reason: "busy" | "database" | "unsafe-path" | "foreign-owner" | "owner-unknown" };
|
|
57
68
|
|
|
58
69
|
export class CatalogWritePermitRefusal extends Error {
|
|
59
70
|
readonly code = "CODEX_CATALOG_WRITE_PERMIT_REFUSED";
|
|
@@ -69,6 +80,8 @@ interface PermitRegistration {
|
|
|
69
80
|
readonly canonicalCodexHome: string;
|
|
70
81
|
/** Identifies the acquisition, so a permit cannot outlive its transaction. */
|
|
71
82
|
readonly transactionId: string;
|
|
83
|
+
readonly intent: CatalogWriteIntent;
|
|
84
|
+
readonly writer: string;
|
|
72
85
|
live: boolean;
|
|
73
86
|
}
|
|
74
87
|
|
|
@@ -121,6 +134,34 @@ export function assertCatalogWritePermit(
|
|
|
121
134
|
}
|
|
122
135
|
}
|
|
123
136
|
|
|
137
|
+
/** Only a live acquisition may expose its declared intent and writer. */
|
|
138
|
+
export function catalogWritePermitContext(permit: CatalogWritePermit): CatalogWriteOptions {
|
|
139
|
+
const registration = activePermits.get(permit as unknown as object);
|
|
140
|
+
if (!registration?.live) throw new CatalogWritePermitRefusal("The catalog write permit is not live.");
|
|
141
|
+
return { intent: registration.intent, writer: registration.writer };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Audit mutation is subject to the same live-home proof as catalog publication. */
|
|
145
|
+
export function auditCatalogWriteWithPermit(
|
|
146
|
+
permit: CatalogWritePermit,
|
|
147
|
+
canonicalCodexHome: string,
|
|
148
|
+
details: CatalogWriteAuditDetails,
|
|
149
|
+
): "appended" | "created" | "skipped" {
|
|
150
|
+
assertCatalogWritePermit(permit, canonicalCodexHome);
|
|
151
|
+
const context = catalogWritePermitContext(permit);
|
|
152
|
+
try { return appendCatalogWriteAudit(canonicalCodexHome, {
|
|
153
|
+
target: details.target, outcome: details.outcome, reason: details.reason,
|
|
154
|
+
routedBefore: details.routedBefore, routedAfter: details.routedAfter,
|
|
155
|
+
configSource: details.configSource, intent: context.intent, writer: context.writer,
|
|
156
|
+
opencodexHome: currentOpencodexHome(),
|
|
157
|
+
}, { create: true }); } catch { return "skipped"; }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function ownerRefusal(canonicalCodexHome: string): "foreign-owner" | "owner-unknown" | null {
|
|
161
|
+
const owner = inspectCodexHomeOwner(canonicalCodexHome);
|
|
162
|
+
return owner.kind === "foreign" ? "foreign-owner" : owner.kind === "unknown" ? "owner-unknown" : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
124
165
|
/**
|
|
125
166
|
* Acquire K for one canonical CODEX_HOME and run `write` while it is held.
|
|
126
167
|
*
|
|
@@ -135,7 +176,14 @@ export function assertCatalogWritePermit(
|
|
|
135
176
|
export function withCatalogWriteSerialization<T>(
|
|
136
177
|
canonicalCodexHome: string,
|
|
137
178
|
write: (permit: CatalogWritePermit) => T,
|
|
179
|
+
options: CatalogWriteOptions,
|
|
138
180
|
): CatalogSerializationOutcome<T> {
|
|
181
|
+
// First, ahead of the owner precheck and its diagnostics: a test process never gets a permit for
|
|
182
|
+
// the real Codex home, whatever its CODEX_HOME resolved to (#6529).
|
|
183
|
+
assertNotRealCodexHomeUnderTest(canonicalCodexHome);
|
|
184
|
+
// Re-evaluate on every acquisition; unknown evidence cannot authorize a takeover.
|
|
185
|
+
const refusedOwner = ownerRefusal(canonicalCodexHome);
|
|
186
|
+
// A precheck refusal may still acquire K for append-only diagnostics; it never gets a permit.
|
|
139
187
|
let databasePath: string;
|
|
140
188
|
try {
|
|
141
189
|
databasePath = resolveCodexCatalogSerializationDatabasePath(
|
|
@@ -144,9 +192,9 @@ export function withCatalogWriteSerialization<T>(
|
|
|
144
192
|
);
|
|
145
193
|
} catch (error) {
|
|
146
194
|
if (error instanceof CodexUserIdentityRefusal) {
|
|
147
|
-
return { kind: "unavailable", reason: "unsafe-path" };
|
|
195
|
+
return { kind: "unavailable", reason: refusedOwner ?? "unsafe-path" };
|
|
148
196
|
}
|
|
149
|
-
return { kind: "unavailable", reason: "database" };
|
|
197
|
+
return { kind: "unavailable", reason: refusedOwner ?? "database" };
|
|
150
198
|
}
|
|
151
199
|
|
|
152
200
|
let database: Database | undefined;
|
|
@@ -159,12 +207,12 @@ export function withCatalogWriteSerialization<T>(
|
|
|
159
207
|
try {
|
|
160
208
|
const before = lstatSync(databasePath);
|
|
161
209
|
if (before.isSymbolicLink() || !before.isFile()) {
|
|
162
|
-
return { kind: "unavailable", reason: "unsafe-path" };
|
|
210
|
+
return { kind: "unavailable", reason: refusedOwner ?? "unsafe-path" };
|
|
163
211
|
}
|
|
164
212
|
if (process.platform !== "win32") {
|
|
165
213
|
const uid = process.getuid?.();
|
|
166
214
|
if (uid === undefined || before.uid !== uid || (before.mode & 0o777) !== 0o600) {
|
|
167
|
-
return { kind: "unavailable", reason: "unsafe-path" };
|
|
215
|
+
return { kind: "unavailable", reason: refusedOwner ?? "unsafe-path" };
|
|
168
216
|
}
|
|
169
217
|
}
|
|
170
218
|
} catch (cause) {
|
|
@@ -182,16 +230,31 @@ export function withCatalogWriteSerialization<T>(
|
|
|
182
230
|
const opened = lstatSync(databasePath);
|
|
183
231
|
if (opened.isSymbolicLink() || !opened.isFile()
|
|
184
232
|
|| !samePathIdentity(realpathSync.native(databasePath), databasePath)) {
|
|
185
|
-
return { kind: "unavailable", reason: "unsafe-path" };
|
|
233
|
+
return { kind: "unavailable", reason: refusedOwner ?? "unsafe-path" };
|
|
186
234
|
}
|
|
187
235
|
|
|
188
236
|
database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
|
|
189
237
|
transactionOpen = true;
|
|
190
238
|
|
|
239
|
+
// Ownership may have changed while opening K. Refuse before minting a permit or invoking work.
|
|
240
|
+
const lockedOwnerRefusal = refusedOwner ?? ownerRefusal(canonicalCodexHome);
|
|
241
|
+
if (lockedOwnerRefusal) {
|
|
242
|
+
try { appendCatalogWriteAudit(canonicalCodexHome, {
|
|
243
|
+
target: options.intent === "cache" ? "cache" : "catalog",
|
|
244
|
+
outcome: "refused", reason: lockedOwnerRefusal,
|
|
245
|
+
intent: options.intent, writer: options.writer, opencodexHome: currentOpencodexHome(),
|
|
246
|
+
}, { create: false }); } catch { /* Diagnostic metadata cannot mask the owner refusal. */ }
|
|
247
|
+
database.exec("ROLLBACK");
|
|
248
|
+
transactionOpen = false;
|
|
249
|
+
return { kind: "unavailable", reason: lockedOwnerRefusal };
|
|
250
|
+
}
|
|
251
|
+
|
|
191
252
|
acquisitionCounter += 1;
|
|
192
253
|
registration = {
|
|
193
254
|
canonicalCodexHome,
|
|
194
255
|
transactionId: `${process.pid}:${acquisitionCounter}`,
|
|
256
|
+
intent: options.intent,
|
|
257
|
+
writer: options.writer,
|
|
195
258
|
live: true,
|
|
196
259
|
};
|
|
197
260
|
// A bare object: nothing about it is guessable or reconstructable, because
|
|
@@ -224,8 +287,9 @@ export function withCatalogWriteSerialization<T>(
|
|
|
224
287
|
if (permit) activePermits.delete(permit as unknown as object);
|
|
225
288
|
}
|
|
226
289
|
if (error instanceof CodexUserIdentityRefusal) {
|
|
227
|
-
return { kind: "unavailable", reason: "unsafe-path" };
|
|
290
|
+
return { kind: "unavailable", reason: refusedOwner ?? "unsafe-path" };
|
|
228
291
|
}
|
|
292
|
+
if (refusedOwner) return { kind: "unavailable", reason: refusedOwner };
|
|
229
293
|
if (isBusy(error)) return { kind: "unavailable", reason: "busy" };
|
|
230
294
|
// A callback failure is the caller's error, not a lock outcome: K acquired
|
|
231
295
|
// fine. Reporting it as `unavailable` would tell the caller to retry
|