@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,921 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value-free planning for integration mutations.
|
|
3
|
+
*
|
|
4
|
+
* An operator confirming "apply", "overwrite", "disable" or "undo" is agreeing to consequences
|
|
5
|
+
* nobody has shown them. This module computes those consequences as bounded managed schema paths
|
|
6
|
+
* and closed change kinds, and binds them to a fingerprint over every input the decision rested
|
|
7
|
+
* on, so a confirmation can be refused when the state it described has moved.
|
|
8
|
+
*
|
|
9
|
+
* Two rules give the output its safety. Nothing here is a value: paths are structural, and a
|
|
10
|
+
* segment that is not representable in the managed grammar is dropped rather than echoed, because
|
|
11
|
+
* an ownership record on disk accepts arbitrary strings and is not a validation authority. And
|
|
12
|
+
* nothing here writes: this module owns no IO, takes no lock, and must never import the writer.
|
|
13
|
+
* The dependency direction is state/ownership/merge into here, and here into the writer and the
|
|
14
|
+
* preview route.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { canonicalContribution, fingerprint, type OwnershipRecord } from "./ownership";
|
|
18
|
+
import { ClientPathError, EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
|
|
19
|
+
import { OPENCODE_PROVIDER_ID } from "../clients/config-export/constants";
|
|
20
|
+
import {
|
|
21
|
+
ZCODE_STORE_MODEL_RULES_PATH,
|
|
22
|
+
ZCODE_STORE_PROVIDER_RULES_PATH,
|
|
23
|
+
} from "../clients/config-export/zcode-store";
|
|
24
|
+
import { createClineIO, ClineTransactionError } from "./cline-io";
|
|
25
|
+
import { parseClineDocument } from "./cline-document";
|
|
26
|
+
import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
|
|
27
|
+
import {
|
|
28
|
+
INTEGRATION_CLIENTS,
|
|
29
|
+
isLoopbackOnly,
|
|
30
|
+
resolveIntegrationPaths,
|
|
31
|
+
type IntegrationClientId,
|
|
32
|
+
} from "./registry";
|
|
33
|
+
import { declaredIntegrationTarget, resolveIntegrationTarget, type IntegrationTarget } from "./target";
|
|
34
|
+
import { shouldInjectApiAuthHeader } from "../codex/inject";
|
|
35
|
+
import { classifyIntegration, exportContextOf, readPath, type IntegrationState, type StateReason } from "./state";
|
|
36
|
+
import { InvalidSelectorError } from "./merge";
|
|
37
|
+
import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
|
|
38
|
+
import type { OcxConfig } from "../types";
|
|
39
|
+
import { matchesOperationResult, type JournalEntry } from "./journal";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Why a mutation refused. Declared here rather than in the writer so the planner can report a
|
|
43
|
+
* refusal without depending on the module that performs writes; the writer re-exports it, so this
|
|
44
|
+
* is a move rather than a second vocabulary.
|
|
45
|
+
*/
|
|
46
|
+
export type RefusalReason =
|
|
47
|
+
| "not_installed"
|
|
48
|
+
| "conflict"
|
|
49
|
+
| "unsafe"
|
|
50
|
+
| "non_loopback"
|
|
51
|
+
| "superseded_store"
|
|
52
|
+
| "drift_requires_confirm"
|
|
53
|
+
| "snapshot_expired"
|
|
54
|
+
| "write_failed";
|
|
55
|
+
|
|
56
|
+
export type IntegrationPlanOperation = "apply" | "overwrite" | "disable" | "restore";
|
|
57
|
+
export type IntegrationPlanChangeKind = "add" | "replace" | "remove" | "snapshot" | "ownership" | "journal";
|
|
58
|
+
export type IntegrationPlanForeignEdit = "none" | "unowned" | "foreign-edit" | "drift";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Effects that are not places in the client's document. They carry no disk location and no value,
|
|
62
|
+
* so an operator learns that history will be written without learning where it lives.
|
|
63
|
+
*/
|
|
64
|
+
export const PLAN_SNAPSHOT_PATH = "$snapshot";
|
|
65
|
+
export const PLAN_OWNERSHIP_PATH = "$ownership";
|
|
66
|
+
export const PLAN_JOURNAL_PATH = "$journal";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Upper bound on reported changes. Above the largest contribution any registered client builds and
|
|
70
|
+
* well below a response worth truncating, so the cap is a guard rather than a routine limit.
|
|
71
|
+
*/
|
|
72
|
+
export const PLAN_CHANGE_LIMIT = 256;
|
|
73
|
+
|
|
74
|
+
export interface IntegrationPlanChange {
|
|
75
|
+
readonly kind: IntegrationPlanChangeKind;
|
|
76
|
+
readonly path: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface IntegrationMutationPlan {
|
|
80
|
+
readonly version: 1;
|
|
81
|
+
readonly clientId: IntegrationClientId;
|
|
82
|
+
readonly operation: IntegrationPlanOperation;
|
|
83
|
+
readonly state: IntegrationState;
|
|
84
|
+
readonly foreignEdit: IntegrationPlanForeignEdit;
|
|
85
|
+
readonly changes: readonly IntegrationPlanChange[];
|
|
86
|
+
readonly fingerprint: string;
|
|
87
|
+
readonly canApply: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Whether confirming would write at all.
|
|
90
|
+
*
|
|
91
|
+
* An apply against an already-current file and a disable against an absent one both succeed
|
|
92
|
+
* while writing nothing, so reporting a snapshot and a journal row for them would describe
|
|
93
|
+
* consequences that never happen.
|
|
94
|
+
*/
|
|
95
|
+
readonly willChange: boolean;
|
|
96
|
+
readonly refusalReason?: RefusalReason;
|
|
97
|
+
readonly profileId?: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A position whose observed value is never published, only its presence. */
|
|
101
|
+
export const DYNAMIC_SEGMENT = "*";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Where each client's managed fragments live, declared rather than inferred.
|
|
105
|
+
*
|
|
106
|
+
* A general "looks like a plain key" rule is not good enough, and Kimi is the proof: it writes one
|
|
107
|
+
* fragment per model at `models.<alias>`, so an alphanumeric allowlist would publish a user's
|
|
108
|
+
* model identifier verbatim. The same rule would accept any plain path sitting in an ownership
|
|
109
|
+
* record, and a record on disk is not a validation authority.
|
|
110
|
+
*
|
|
111
|
+
* So a path is published only when it matches one of these templates exactly. Static segments must
|
|
112
|
+
* match literally, a DYNAMIC_SEGMENT position accepts any observed segment, and the string that
|
|
113
|
+
* leaves this module is the TEMPLATE rather than the observed path. That is what makes publishing
|
|
114
|
+
* a value structurally impossible instead of merely unlikely.
|
|
115
|
+
*
|
|
116
|
+
* The satisfies clause makes a new client a type error here, so nobody can add one whose managed
|
|
117
|
+
* paths silently have no declaration.
|
|
118
|
+
*/
|
|
119
|
+
const CLIENT_MANAGED_PATHS = {
|
|
120
|
+
opencode: [["provider", OPENCODE_PROVIDER_ID], ["providers", OPENCODE_PROVIDER_ID]],
|
|
121
|
+
pi: [["providers", OPENCODE_PROVIDER_ID]],
|
|
122
|
+
omp: [["providers", OPENCODE_PROVIDER_ID]],
|
|
123
|
+
hermes: [["providers", OPENCODE_PROVIDER_ID]],
|
|
124
|
+
openclaw: [["models", "providers", OPENCODE_PROVIDER_ID]],
|
|
125
|
+
kimi: [["providers", OPENCODE_PROVIDER_ID], ["models", DYNAMIC_SEGMENT]],
|
|
126
|
+
gajae: [["providers", OPENCODE_PROVIDER_ID]],
|
|
127
|
+
dsh: [["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID]],
|
|
128
|
+
mcode: [["custom_provider", OPENCODE_PROVIDER_ID]],
|
|
129
|
+
zcode: [
|
|
130
|
+
["provider", OPENCODE_PROVIDER_ID],
|
|
131
|
+
/*
|
|
132
|
+
* The store the current client reads. The model rule carries the model id in
|
|
133
|
+
* its own selector, so the published segment is the dynamic one: the template
|
|
134
|
+
* leaves this module, never the observed path.
|
|
135
|
+
*/
|
|
136
|
+
[...ZCODE_STORE_PROVIDER_RULES_PATH, `[providerId=${OPENCODE_PROVIDER_ID}]`],
|
|
137
|
+
[...ZCODE_STORE_MODEL_RULES_PATH, DYNAMIC_SEGMENT],
|
|
138
|
+
],
|
|
139
|
+
prime: [["providers", OPENCODE_PROVIDER_ID]],
|
|
140
|
+
aside: [["providers", OPENCODE_PROVIDER_ID]],
|
|
141
|
+
raycast: [["providers", `[id=${OPENCODE_PROVIDER_ID}]`]],
|
|
142
|
+
omo: [["providers", OPENCODE_PROVIDER_ID]],
|
|
143
|
+
cline: [
|
|
144
|
+
["settings", "providers", OPENCODE_PROVIDER_ID],
|
|
145
|
+
["catalog", "providers", OPENCODE_PROVIDER_ID],
|
|
146
|
+
],
|
|
147
|
+
} satisfies Record<IntegrationClientId, readonly (readonly string[])[]>;
|
|
148
|
+
|
|
149
|
+
/** Not a configuration surface. Exported so a parity case can compare it against the shipped clients. */
|
|
150
|
+
export const MANAGED_PATH_TEMPLATES: Readonly<Record<IntegrationClientId, readonly (readonly string[])[]>> = CLIENT_MANAGED_PATHS;
|
|
151
|
+
|
|
152
|
+
function matchesTemplate(template: readonly string[], path: readonly string[]): boolean {
|
|
153
|
+
if (template.length !== path.length) return false;
|
|
154
|
+
return template.every((segment, index) => {
|
|
155
|
+
const observed = path[index];
|
|
156
|
+
if (observed === undefined || observed.length === 0) return false;
|
|
157
|
+
return segment === DYNAMIC_SEGMENT || segment === observed;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The managed schema path this change touches, or null when the path is outside the client's
|
|
163
|
+
* declared grammar.
|
|
164
|
+
*
|
|
165
|
+
* Null is not an error to work around. A path nobody declared is either a record written by a
|
|
166
|
+
* different version or something arbitrary, and neither is safe to name, so the caller reports the
|
|
167
|
+
* fixed ownership pseudo-path or a refusal instead of inventing a description.
|
|
168
|
+
*/
|
|
169
|
+
export function canonicalSchemaPath(clientId: IntegrationClientId, path: readonly string[]): string | null {
|
|
170
|
+
if (path.length === 0) return null;
|
|
171
|
+
for (const template of CLIENT_MANAGED_PATHS[clientId]) {
|
|
172
|
+
if (matchesTemplate(template, path)) return template.join(".");
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const KIND_ORDER: readonly IntegrationPlanChangeKind[] = ["add", "replace", "remove", "snapshot", "ownership", "journal"];
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Deterministic, deduplicated and capped. Deterministic because the fingerprint is taken over this
|
|
181
|
+
* projection, so an unstable order would stale a plan that did not change.
|
|
182
|
+
*/
|
|
183
|
+
export function orderPlanChanges(changes: readonly IntegrationPlanChange[]): readonly IntegrationPlanChange[] {
|
|
184
|
+
const seen = new Set<string>();
|
|
185
|
+
const unique: IntegrationPlanChange[] = [];
|
|
186
|
+
for (const change of changes) {
|
|
187
|
+
const key = `${change.kind}\u0000${change.path}`;
|
|
188
|
+
if (seen.has(key)) continue;
|
|
189
|
+
seen.add(key);
|
|
190
|
+
unique.push(change);
|
|
191
|
+
}
|
|
192
|
+
unique.sort((left, right) => {
|
|
193
|
+
const byKind = KIND_ORDER.indexOf(left.kind) - KIND_ORDER.indexOf(right.kind);
|
|
194
|
+
if (byKind !== 0) return byKind;
|
|
195
|
+
return left.path < right.path ? -1 : left.path > right.path ? 1 : 0;
|
|
196
|
+
});
|
|
197
|
+
return Object.freeze(unique.slice(0, PLAN_CHANGE_LIMIT));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Every input the plan's authority rests on.
|
|
202
|
+
*
|
|
203
|
+
* `models` is here because the desired contribution is derived from it, so a model roster that
|
|
204
|
+
* changed between preview and commit changes what would be written. `snapshot` carries a digest of
|
|
205
|
+
* the bytes a restore would actually publish rather than only the operation id, because the id
|
|
206
|
+
* names the row and the bytes are what lands in the user's file.
|
|
207
|
+
*/
|
|
208
|
+
export interface PlanFingerprintInput {
|
|
209
|
+
readonly operation: IntegrationPlanOperation;
|
|
210
|
+
readonly clientId: IntegrationClientId;
|
|
211
|
+
readonly profileId?: number;
|
|
212
|
+
readonly configPath: string;
|
|
213
|
+
readonly detectDir: string;
|
|
214
|
+
/**
|
|
215
|
+
* What the detect directory actually was when observed, not merely where it is.
|
|
216
|
+
*
|
|
217
|
+
* Binding only the path leaves a confirmation valid across an uninstall: the contribution is
|
|
218
|
+
* unchanged, so every other component matches, while the answer to "is this client installed"
|
|
219
|
+
* has flipped. The observed kind is the input the not_installed refusal is derived from.
|
|
220
|
+
*/
|
|
221
|
+
readonly installKind: string;
|
|
222
|
+
/**
|
|
223
|
+
* Whether admission policy blocks this integration, which is the non_loopback refusal's input.
|
|
224
|
+
*
|
|
225
|
+
* Config eligibility can change without touching the file, the record or the contribution, so a
|
|
226
|
+
* plan that did not bind it could be confirmed after the proxy stopped being a legal target.
|
|
227
|
+
*/
|
|
228
|
+
readonly admissionBlocked: boolean;
|
|
229
|
+
/**
|
|
230
|
+
* Why a write to the target would not reach the client, or null.
|
|
231
|
+
*
|
|
232
|
+
* Bound for the same reason `installKind` is: it can flip without touching the
|
|
233
|
+
* file, the record or the contribution. A client that creates its new store
|
|
234
|
+
* while a confirmation is outstanding has changed whether the write can reach
|
|
235
|
+
* it, and a plan that did not bind this would still authorize the write. The
|
|
236
|
+
* reason travels with the location because both can move on their own: a store
|
|
237
|
+
* whose schema version changes under an unchanged path is the same class of
|
|
238
|
+
* flip as a store appearing.
|
|
239
|
+
*/
|
|
240
|
+
readonly ineffectiveWrite: string | null;
|
|
241
|
+
/** Exact current bytes, or null when the target is missing. Missing and empty are not equal. */
|
|
242
|
+
readonly before: string | null;
|
|
243
|
+
readonly contribution: ManagedContribution | null;
|
|
244
|
+
readonly record: OwnershipRecord | null;
|
|
245
|
+
readonly models: readonly ExportModel[];
|
|
246
|
+
readonly restore?: {
|
|
247
|
+
readonly opId: string;
|
|
248
|
+
readonly entry: JournalEntry;
|
|
249
|
+
readonly snapshotKind: string;
|
|
250
|
+
/** Digest of the snapshot's exact text, or null when it holds none. */
|
|
251
|
+
readonly snapshotText: string | null;
|
|
252
|
+
readonly confirmDrift: boolean;
|
|
253
|
+
/**
|
|
254
|
+
* Whether the target has changed since the operation being undone, which is the
|
|
255
|
+
* drift_requires_confirm predicate. Passed in rather than recomputed so the plan and the
|
|
256
|
+
* mutation read drift from the same comparison.
|
|
257
|
+
*/
|
|
258
|
+
readonly driftsFromResult: boolean;
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const PLAN_FINGERPRINT_VERSION = "p2";
|
|
263
|
+
|
|
264
|
+
function digest(value: string): string {
|
|
265
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 32);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* An opaque optimistic-concurrency token, not authorization.
|
|
270
|
+
*
|
|
271
|
+
* Longer than the 16-hex ownership fingerprint because this one is supplied by a caller and
|
|
272
|
+
* compared for equality, so accidental collision matters more than it does for a stored digest.
|
|
273
|
+
* The version prefix means a future input set invalidates old tokens instead of silently
|
|
274
|
+
* comparing two different meanings.
|
|
275
|
+
*/
|
|
276
|
+
export function planFingerprint(input: PlanFingerprintInput): string {
|
|
277
|
+
const restore = input.restore;
|
|
278
|
+
const components = [
|
|
279
|
+
PLAN_FINGERPRINT_VERSION,
|
|
280
|
+
input.operation,
|
|
281
|
+
input.clientId,
|
|
282
|
+
input.profileId === undefined ? null : input.profileId,
|
|
283
|
+
input.configPath,
|
|
284
|
+
input.detectDir,
|
|
285
|
+
input.installKind,
|
|
286
|
+
input.admissionBlocked,
|
|
287
|
+
input.ineffectiveWrite,
|
|
288
|
+
input.before === null ? "\u0000absent" : fingerprint(input.before),
|
|
289
|
+
input.contribution === null ? null : fingerprint(canonicalContribution(input.contribution)),
|
|
290
|
+
input.record === null ? null : fingerprint(JSON.stringify(input.record)),
|
|
291
|
+
fingerprint(JSON.stringify(input.models)),
|
|
292
|
+
restore === undefined ? null : [
|
|
293
|
+
restore.opId,
|
|
294
|
+
fingerprint(JSON.stringify(restore.entry)),
|
|
295
|
+
restore.snapshotKind,
|
|
296
|
+
restore.snapshotText === null ? "\u0000none" : fingerprint(restore.snapshotText),
|
|
297
|
+
restore.confirmDrift,
|
|
298
|
+
restore.driftsFromResult,
|
|
299
|
+
],
|
|
300
|
+
];
|
|
301
|
+
return `${PLAN_FINGERPRINT_VERSION}:${digest(JSON.stringify(components))}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The observation facts a plan is derived from, beside the fingerprint inputs. */
|
|
305
|
+
export interface PlanInput extends PlanFingerprintInput {
|
|
306
|
+
readonly classified: { readonly state: IntegrationState; readonly reason?: StateReason };
|
|
307
|
+
/**
|
|
308
|
+
* The parsed target document. Whether a managed place is occupied is a fact about the file, not
|
|
309
|
+
* about our record: an overwrite of a key somebody else wrote replaces a value even though no
|
|
310
|
+
* record of ours mentions it.
|
|
311
|
+
*/
|
|
312
|
+
readonly parsed: unknown;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
type PlanOutcome =
|
|
316
|
+
| { readonly kind: "refuse"; readonly reason: RefusalReason }
|
|
317
|
+
| { readonly kind: "noop" }
|
|
318
|
+
| { readonly kind: "change" };
|
|
319
|
+
|
|
320
|
+
const CHANGE: PlanOutcome = { kind: "change" };
|
|
321
|
+
const NOOP: PlanOutcome = { kind: "noop" };
|
|
322
|
+
const deny = (reason: RefusalReason): PlanOutcome => ({ kind: "refuse", reason });
|
|
323
|
+
|
|
324
|
+
function foreignEditOf(input: PlanInput): IntegrationPlanForeignEdit {
|
|
325
|
+
if (input.restore?.driftsFromResult) return "drift";
|
|
326
|
+
if (input.classified.reason === "unowned-key") return "unowned";
|
|
327
|
+
if (input.classified.reason === "foreign-edit") return "foreign-edit";
|
|
328
|
+
return "none";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Why this operation would refuse, in the writer's own order.
|
|
333
|
+
*
|
|
334
|
+
* The order is not cosmetic. An uninstalled client is reported as not installed rather than as
|
|
335
|
+
* whatever its leftover file happens to classify as, and an unreadable or unparseable file is
|
|
336
|
+
* reported before either, because that is the sequence the writer itself refuses in. A plan that
|
|
337
|
+
* named a different reason than the mutation would name is worse than no plan.
|
|
338
|
+
*/
|
|
339
|
+
function applyOutcome(input: PlanInput): PlanOutcome {
|
|
340
|
+
if (input.installKind !== "dir") return deny("not_installed");
|
|
341
|
+
if (input.admissionBlocked) return deny("non_loopback");
|
|
342
|
+
/*
|
|
343
|
+
* Before any file state. The document may be perfectly writable and our block
|
|
344
|
+
* may already be current in it; neither says anything about whether the
|
|
345
|
+
* client reads it, and reporting a change to a file nobody opens is the
|
|
346
|
+
* defect this refusal exists for.
|
|
347
|
+
*/
|
|
348
|
+
if (input.ineffectiveWrite !== null) return deny("superseded_store");
|
|
349
|
+
// Overwrite exists precisely to proceed through a conflict the operator has been shown.
|
|
350
|
+
if (input.classified.state === "conflict" && input.operation !== "overwrite") return deny("conflict");
|
|
351
|
+
if (input.classified.state === "unsafe") return deny("unsafe");
|
|
352
|
+
if (input.classified.state === "current") return NOOP;
|
|
353
|
+
return CHANGE;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Disable answers a different question, so it asks different ones.
|
|
358
|
+
*
|
|
359
|
+
* It never checks installation or admission: removing what we wrote from a file that still exists
|
|
360
|
+
* is meaningful whether or not the client is installed now, and it emits nothing that admission
|
|
361
|
+
* policy could object to. An absent block is a success that writes nothing rather than a refusal.
|
|
362
|
+
*/
|
|
363
|
+
function disableOutcome(input: PlanInput): PlanOutcome {
|
|
364
|
+
if (input.classified.state === "absent") return NOOP;
|
|
365
|
+
if (input.classified.state === "conflict") return deny("conflict");
|
|
366
|
+
if (input.classified.state === "unsafe") return deny("unsafe");
|
|
367
|
+
return CHANGE;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function restoreOutcome(input: PlanInput): PlanOutcome {
|
|
371
|
+
if (input.restore === undefined) return deny("unsafe");
|
|
372
|
+
if (input.restore.snapshotKind === "expired") return deny("snapshot_expired");
|
|
373
|
+
if (input.restore.driftsFromResult && !input.restore.confirmDrift) return deny("drift_requires_confirm");
|
|
374
|
+
return CHANGE;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* What this operation would do, decided the way the operation itself decides it.
|
|
379
|
+
*
|
|
380
|
+
* Applying one global sequence to all four was wrong: it reported disable as refused on an
|
|
381
|
+
* uninstalled client the writer would have accepted, and it ranked the classifier's unsafe ahead
|
|
382
|
+
* of a conflict that apply reports first. A plan is only useful if it reaches the same verdict,
|
|
383
|
+
* for the same reason, as the mutation it describes.
|
|
384
|
+
*/
|
|
385
|
+
function outcomeOf(input: PlanInput): PlanOutcome {
|
|
386
|
+
if (input.operation === "restore") return restoreOutcome(input);
|
|
387
|
+
if (input.operation === "disable") return disableOutcome(input);
|
|
388
|
+
return applyOutcome(input);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The managed places this operation would touch, plus the history it would write.
|
|
394
|
+
*
|
|
395
|
+
* A path that does not canonicalize is omitted rather than guessed at. For a shipped client that
|
|
396
|
+
* cannot happen, and the parity case proves it; what it does cover is a record written by another
|
|
397
|
+
* version, where declining to describe a path is the honest answer and the fixed ownership entry
|
|
398
|
+
* still tells the operator that ownership changes.
|
|
399
|
+
*/
|
|
400
|
+
function changesOf(input: PlanInput): readonly IntegrationPlanChange[] {
|
|
401
|
+
const changes: IntegrationPlanChange[] = [];
|
|
402
|
+
if (input.operation === "apply" || input.operation === "overwrite") {
|
|
403
|
+
for (const fragment of input.contribution?.fragments ?? []) {
|
|
404
|
+
const path = canonicalSchemaPath(input.clientId, fragment.path);
|
|
405
|
+
if (path === null) continue;
|
|
406
|
+
// Occupied is a fact about the document. Deciding from our own record instead would call an
|
|
407
|
+
// overwrite of somebody else's key an addition, which is the one case overwrite exists for.
|
|
408
|
+
const occupied = readPath(input.parsed, fragment.path) !== undefined;
|
|
409
|
+
changes.push({ kind: occupied ? "replace" : "add", path });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (input.operation === "disable") {
|
|
413
|
+
for (const owned of input.record?.fragmentPaths ?? []) {
|
|
414
|
+
const path = canonicalSchemaPath(input.clientId, owned);
|
|
415
|
+
if (path === null) continue;
|
|
416
|
+
changes.push({ kind: "remove", path });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (input.operation === "restore") {
|
|
420
|
+
/*
|
|
421
|
+
* An undo replaces the whole document, so what it changes is the difference between the
|
|
422
|
+
* places that are ours now and the places the row recorded as ours before that operation ran.
|
|
423
|
+
*
|
|
424
|
+
* Reading only the prior record got both common undos wrong. Undoing an initial apply has no
|
|
425
|
+
* prior record, so the plan described a change to nothing at all while the undo removed the
|
|
426
|
+
* managed block. Undoing a disable has a prior record and an empty document, so the plan said
|
|
427
|
+
* it would replace paths the file does not currently have.
|
|
428
|
+
*
|
|
429
|
+
* Provenance is still restored rather than re-derived: the prior record is what says which
|
|
430
|
+
* places are ours afterwards. The document only answers whether each of them is there now.
|
|
431
|
+
*/
|
|
432
|
+
const prior = new Map<string, readonly string[]>();
|
|
433
|
+
for (const fragment of input.restore?.entry.priorRecord?.fragmentPaths ?? []) {
|
|
434
|
+
const path = canonicalSchemaPath(input.clientId, fragment);
|
|
435
|
+
if (path !== null) prior.set(path, fragment);
|
|
436
|
+
}
|
|
437
|
+
/*
|
|
438
|
+
* A document we could not read says nothing about whether a place is there, and restore does
|
|
439
|
+
* not need it read: eligibility is a question about bytes. Where it cannot be read, each place
|
|
440
|
+
* is reported as a replacement, which is what this list said before any of it was derived.
|
|
441
|
+
*/
|
|
442
|
+
const documentKnown = input.parsed !== PARSE_FAILED;
|
|
443
|
+
for (const [path, fragment] of prior) {
|
|
444
|
+
let absent = false;
|
|
445
|
+
if (documentKnown) {
|
|
446
|
+
try {
|
|
447
|
+
absent = readPath(input.parsed, fragment) === undefined;
|
|
448
|
+
} catch (error) {
|
|
449
|
+
/*
|
|
450
|
+
* Restore eligibility is byte-based and does not parse ownership paths.
|
|
451
|
+
* A malformed versioned selector therefore makes this ONE descriptive
|
|
452
|
+
* comparison unknown; it must not make preview execute a different
|
|
453
|
+
* selector, and it must not hide the backup behind an internal error.
|
|
454
|
+
*/
|
|
455
|
+
if (!(error instanceof InvalidSelectorError)) throw error;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
changes.push({ kind: absent ? "add" : "replace", path });
|
|
459
|
+
}
|
|
460
|
+
for (const fragment of input.record?.fragmentPaths ?? []) {
|
|
461
|
+
const path = canonicalSchemaPath(input.clientId, fragment);
|
|
462
|
+
if (path === null || prior.has(path)) continue;
|
|
463
|
+
changes.push({ kind: "remove", path });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
changes.push({ kind: "snapshot", path: PLAN_SNAPSHOT_PATH });
|
|
467
|
+
changes.push({ kind: "ownership", path: PLAN_OWNERSHIP_PATH });
|
|
468
|
+
changes.push({ kind: "journal", path: PLAN_JOURNAL_PATH });
|
|
469
|
+
return orderPlanChanges(changes);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The whole plan, value-free.
|
|
474
|
+
*
|
|
475
|
+
* A refused plan is still worth returning: knowing that undo is blocked because the backup expired
|
|
476
|
+
* is the answer an operator needs, and it carries no more detail than an allowed one.
|
|
477
|
+
*/
|
|
478
|
+
export function buildMutationPlan(input: PlanInput): IntegrationMutationPlan {
|
|
479
|
+
const outcome = outcomeOf(input);
|
|
480
|
+
return Object.freeze({
|
|
481
|
+
version: 1 as const,
|
|
482
|
+
clientId: input.clientId,
|
|
483
|
+
operation: input.operation,
|
|
484
|
+
state: input.classified.state,
|
|
485
|
+
foreignEdit: foreignEditOf(input),
|
|
486
|
+
// Only a plan that would actually write describes places to write.
|
|
487
|
+
changes: outcome.kind === "change" ? changesOf(input) : Object.freeze([]),
|
|
488
|
+
fingerprint: planFingerprint(input),
|
|
489
|
+
canApply: outcome.kind !== "refuse",
|
|
490
|
+
willChange: outcome.kind === "change",
|
|
491
|
+
...(outcome.kind === "refuse" ? { refusalReason: outcome.reason } : {}),
|
|
492
|
+
...(input.profileId === undefined ? {} : { profileId: input.profileId }),
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The token a plan carries when observation itself refused.
|
|
498
|
+
*
|
|
499
|
+
* Such a plan never read the state it would have bound, so there is nothing to bind. It is safe
|
|
500
|
+
* for every one of them to share this value because `canApply` is false, and a mutation may only
|
|
501
|
+
* be bound to a plan that could apply.
|
|
502
|
+
*/
|
|
503
|
+
export const PLAN_UNBOUND_FINGERPRINT = `${PLAN_FINGERPRINT_VERSION}:unbound`;
|
|
504
|
+
|
|
505
|
+
function unboundPlan(
|
|
506
|
+
clientId: IntegrationClientId,
|
|
507
|
+
operation: IntegrationPlanOperation,
|
|
508
|
+
failure: IntegrationObservationFailure,
|
|
509
|
+
profileId?: number,
|
|
510
|
+
): IntegrationMutationPlan {
|
|
511
|
+
return Object.freeze({
|
|
512
|
+
version: 1 as const,
|
|
513
|
+
clientId,
|
|
514
|
+
operation,
|
|
515
|
+
state: failure.state,
|
|
516
|
+
foreignEdit: "none" as const,
|
|
517
|
+
changes: Object.freeze([]),
|
|
518
|
+
fingerprint: PLAN_UNBOUND_FINGERPRINT,
|
|
519
|
+
canApply: false,
|
|
520
|
+
willChange: false,
|
|
521
|
+
refusalReason: failure.reason,
|
|
522
|
+
...(profileId === undefined ? {} : { profileId }),
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Restore reads a different specification, so it gets its own observation.
|
|
528
|
+
*
|
|
529
|
+
* The writer's undo path never parses and never classifies: it compares the resolved config path
|
|
530
|
+
* against the one the journal row was recorded for, reads the snapshot, and reads the target's
|
|
531
|
+
* BYTES. Routing a preview through the general observation therefore refused an undo of a file
|
|
532
|
+
* that was readable but unparseable, which is the state that most needs restoring, and accepted a
|
|
533
|
+
* row recorded against a previous home, which is the single case path equality exists to refuse.
|
|
534
|
+
*/
|
|
535
|
+
export function observeRestore(
|
|
536
|
+
input: IntegrationWriteInput,
|
|
537
|
+
opId: string,
|
|
538
|
+
effects: ObservationEffects,
|
|
539
|
+
/**
|
|
540
|
+
* The row a caller already selected, with the store it came from.
|
|
541
|
+
*
|
|
542
|
+
* Aside can hold more than one valid copy of the same operation, so re-resolving here could
|
|
543
|
+
* legitimately pick a different row than the mutation will. The plan would then describe an
|
|
544
|
+
* operation the confirmation was never about. A caller that has resolved one passes it in, and
|
|
545
|
+
* neither side resolves again.
|
|
546
|
+
*/
|
|
547
|
+
selectedOperation?: { entry: JournalEntry; store: IntegrationStateStore },
|
|
548
|
+
) {
|
|
549
|
+
const store = selectedOperation?.store ?? input.store ?? createIntegrationStateStore();
|
|
550
|
+
let io = input.io ?? defaultIntegrationIO(store);
|
|
551
|
+
const clientId = input.clientId;
|
|
552
|
+
let resolved: { configPath: string; detectDir: string };
|
|
553
|
+
try {
|
|
554
|
+
resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
if (!(error instanceof ClientPathError)) throw error;
|
|
557
|
+
return { failed: observationFailure("unsafe", "unsafe", error.message) } as const;
|
|
558
|
+
}
|
|
559
|
+
// Coordinated restore refuses this before it looks at the row at all: an undo will not create
|
|
560
|
+
// the client's home, so a writer-lock client without one has nothing to restore into.
|
|
561
|
+
if (INTEGRATION_CLIENTS[clientId].writerLock && io.statKind(resolved.detectDir) !== "dir") {
|
|
562
|
+
return {
|
|
563
|
+
failed: observationFailure("unsafe", "unsafe", "the client home is missing; restore will not create it"),
|
|
564
|
+
} as const;
|
|
565
|
+
}
|
|
566
|
+
const entry = selectedOperation?.entry ?? store.findOperation(opId);
|
|
567
|
+
if (!entry || entry.clientId !== clientId) {
|
|
568
|
+
return { failed: observationFailure("unsafe", "unsafe", "that operation cannot be undone") } as const;
|
|
569
|
+
}
|
|
570
|
+
const configPath = entry.configPath;
|
|
571
|
+
/*
|
|
572
|
+
* An undo acts on the path the operation was journaled against. A row recorded for one home must
|
|
573
|
+
* never be allowed to rewrite a file in another — but a client may legally have written more than
|
|
574
|
+
* one file, so the test is whether this client still names that location, not whether it is the
|
|
575
|
+
* config file. The answer also carries the document shape those bytes are in.
|
|
576
|
+
*/
|
|
577
|
+
const rowTarget = declaredIntegrationTarget({
|
|
578
|
+
clientId, configPath, resolvedConfigPath: resolved.configPath, env: input.env, home: input.home,
|
|
579
|
+
});
|
|
580
|
+
if (rowTarget === null) {
|
|
581
|
+
return {
|
|
582
|
+
failed: observationFailure("conflict", "conflict", "that operation was recorded for a different location"),
|
|
583
|
+
} as const;
|
|
584
|
+
}
|
|
585
|
+
if (clientId === "cline") {
|
|
586
|
+
try { io = createClineIO(io, configPath, store, effects.recover); }
|
|
587
|
+
catch (error) {
|
|
588
|
+
if (!(error instanceof ClineTransactionError)) throw error;
|
|
589
|
+
return { failed: { ...observationFailure("unsafe", "unsafe", error.message, error.snapshotPath), residual: true } } as const;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const snapshot = store.readSnapshot(entry);
|
|
593
|
+
/*
|
|
594
|
+
* Expiry is decided before the target is read, exactly as the writer decides it. Reading first
|
|
595
|
+
* let an expired backup over an unreadable file report the file as the problem, when the answer
|
|
596
|
+
* the operator needs is that the backup is gone.
|
|
597
|
+
*/
|
|
598
|
+
if (snapshot.kind === "expired") {
|
|
599
|
+
return { failed: observationFailure("snapshot_expired", "absent", "that backup has expired") } as const;
|
|
600
|
+
}
|
|
601
|
+
const target = loadTarget(io, configPath);
|
|
602
|
+
if (!target.ok) {
|
|
603
|
+
return { failed: observationFailure("unsafe", "unsafe", "the target cannot be read safely") } as const;
|
|
604
|
+
}
|
|
605
|
+
const before = target.before;
|
|
606
|
+
return {
|
|
607
|
+
failed: undefined,
|
|
608
|
+
clientId,
|
|
609
|
+
configPath,
|
|
610
|
+
format: rowTarget.format,
|
|
611
|
+
detectDir: resolved.detectDir,
|
|
612
|
+
installKind: io.statKind(resolved.detectDir),
|
|
613
|
+
entry,
|
|
614
|
+
snapshotKind: snapshot.kind,
|
|
615
|
+
snapshotText: snapshot.kind === "stored" ? snapshot.text : null,
|
|
616
|
+
before,
|
|
617
|
+
// Bytes only. Parsing here is exactly what must not happen.
|
|
618
|
+
driftsFromResult: !matchesOperationResult(entry, before),
|
|
619
|
+
} as const;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export interface PreviewRequest {
|
|
623
|
+
readonly operation: IntegrationPlanOperation;
|
|
624
|
+
/** Required for restore; names the journalled operation being undone. */
|
|
625
|
+
readonly opId?: string;
|
|
626
|
+
readonly confirmDrift?: boolean;
|
|
627
|
+
readonly profileId?: number;
|
|
628
|
+
/** A row and store the caller already selected, so neither side resolves it twice. */
|
|
629
|
+
readonly resolved?: { entry: JournalEntry; store: IntegrationStateStore };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Plan an operation without performing it.
|
|
634
|
+
*
|
|
635
|
+
* Observation runs with both write-capable effects off, so this path prunes nothing, recovers
|
|
636
|
+
* nothing, takes no lock and enters no mutation flight. Everything it reads is a read: the target
|
|
637
|
+
* file, the ownership records, and for restore the journal row and its snapshot.
|
|
638
|
+
*/
|
|
639
|
+
export function previewIntegration(input: IntegrationWriteInput, request: PreviewRequest): IntegrationMutationPlan {
|
|
640
|
+
// Restore never reaches the general observation, because the writer's undo path never parses
|
|
641
|
+
// or classifies and a preview that did would answer a different question.
|
|
642
|
+
if (request.operation === "restore") return previewRestore(input, request);
|
|
643
|
+
const observed = observeIntegration(input, { maintenance: false, recover: false });
|
|
644
|
+
if (observed.failed) return unboundPlan(input.clientId, request.operation, observed.failed, request.profileId);
|
|
645
|
+
|
|
646
|
+
const shared = {
|
|
647
|
+
operation: request.operation,
|
|
648
|
+
clientId: observed.clientId,
|
|
649
|
+
configPath: observed.configPath,
|
|
650
|
+
detectDir: observed.detectDir,
|
|
651
|
+
installKind: observed.io.statKind(observed.detectDir),
|
|
652
|
+
// Loopback-only clients cannot carry the admission header a non-loopback bind requires.
|
|
653
|
+
admissionBlocked: isLoopbackOnly(observed.clientId) && shouldInjectApiAuthHeader(input.config),
|
|
654
|
+
ineffectiveWrite: observed.ineffectiveWrite,
|
|
655
|
+
before: observed.before,
|
|
656
|
+
contribution: observed.contribution,
|
|
657
|
+
record: observed.record,
|
|
658
|
+
models: input.models,
|
|
659
|
+
classified: observed.classified,
|
|
660
|
+
parsed: observed.parsed,
|
|
661
|
+
...(request.profileId === undefined ? {} : { profileId: request.profileId }),
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
return buildMutationPlan(shared);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Which places are ours in the file this undo would rewrite, read the same way the general
|
|
669
|
+
* observation reads them.
|
|
670
|
+
*
|
|
671
|
+
* Read from the store bound to the target being rewritten, never from the store a historical row
|
|
672
|
+
* was selected out of. Aside keeps a copy of an operation in the root store while the profile's
|
|
673
|
+
* own store holds the ownership for its file, so asking the selected row's store would have
|
|
674
|
+
* described the wrong file's ownership. The selected store stays what it is for: the row and its
|
|
675
|
+
* snapshot. A record written for another location grants nothing here either, which is the same
|
|
676
|
+
* rule the writer applies.
|
|
677
|
+
*/
|
|
678
|
+
function currentRecordFor(
|
|
679
|
+
input: IntegrationWriteInput,
|
|
680
|
+
clientId: IntegrationClientId,
|
|
681
|
+
configPath: string,
|
|
682
|
+
): OwnershipRecord | null {
|
|
683
|
+
const store = input.store ?? createIntegrationStateStore();
|
|
684
|
+
const stored = store.readRecords()[clientId] ?? null;
|
|
685
|
+
return stored && stored.clientId === clientId && stored.configPath === configPath ? stored : null;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Plan an undo the way the writer performs one.
|
|
690
|
+
*
|
|
691
|
+
* State is derived from bytes alone: a missing target is absent, a target that no longer matches
|
|
692
|
+
* the row's recorded result is a conflict, and anything else is current. Admission is not asked
|
|
693
|
+
* about, because restore emits nothing an admission policy could object to and the writer does not
|
|
694
|
+
* ask either.
|
|
695
|
+
*/
|
|
696
|
+
function previewRestore(input: IntegrationWriteInput, request: PreviewRequest): IntegrationMutationPlan {
|
|
697
|
+
const refusal = (message: string): IntegrationMutationPlan =>
|
|
698
|
+
unboundPlan(input.clientId, "restore", { reason: "unsafe", state: "unsafe", message }, request.profileId);
|
|
699
|
+
if (request.opId === undefined) return refusal("that operation cannot be undone");
|
|
700
|
+
|
|
701
|
+
const observed = observeRestore(
|
|
702
|
+
input,
|
|
703
|
+
request.opId,
|
|
704
|
+
{ maintenance: false, recover: false },
|
|
705
|
+
request.resolved,
|
|
706
|
+
);
|
|
707
|
+
if (observed.failed) return unboundPlan(input.clientId, "restore", observed.failed, request.profileId);
|
|
708
|
+
|
|
709
|
+
/*
|
|
710
|
+
* Drift decides first. A row that recorded a file and now finds none has drifted, and calling
|
|
711
|
+
* that absent would report a missing file as an ordinary undo while the writer refuses it
|
|
712
|
+
* pending confirmation. Absent is only honest when the recorded result was absence too.
|
|
713
|
+
*/
|
|
714
|
+
const state: IntegrationState = observed.driftsFromResult
|
|
715
|
+
? "conflict"
|
|
716
|
+
: observed.before === null ? "absent" : "current";
|
|
717
|
+
|
|
718
|
+
return buildMutationPlan({
|
|
719
|
+
operation: "restore",
|
|
720
|
+
clientId: observed.clientId,
|
|
721
|
+
configPath: observed.configPath,
|
|
722
|
+
detectDir: observed.detectDir,
|
|
723
|
+
installKind: observed.installKind,
|
|
724
|
+
admissionBlocked: false,
|
|
725
|
+
/*
|
|
726
|
+
* Undo puts back bytes this project already wrote to this file. Whether the
|
|
727
|
+
* client still reads the file does not change whether those bytes may be
|
|
728
|
+
* restored, and refusing here would strand a user on a state they asked to
|
|
729
|
+
* leave.
|
|
730
|
+
*/
|
|
731
|
+
ineffectiveWrite: null,
|
|
732
|
+
before: observed.before,
|
|
733
|
+
contribution: null,
|
|
734
|
+
// Descriptive, never decisive. The record says which places are ours now and the document
|
|
735
|
+
// says which of them the file holds, so the change list can distinguish a place this undo
|
|
736
|
+
// adds back from one it replaces and one it takes away. Neither is allowed to refuse: an
|
|
737
|
+
// unreadable document is reported as PARSE_FAILED and leaves every place a replacement, and
|
|
738
|
+
// restore eligibility stays the byte comparison it was.
|
|
739
|
+
record: currentRecordFor(input, observed.clientId, observed.configPath),
|
|
740
|
+
models: input.models,
|
|
741
|
+
classified: { state },
|
|
742
|
+
parsed: observed.before === null
|
|
743
|
+
? {}
|
|
744
|
+
: observed.clientId === "cline"
|
|
745
|
+
? parseClineDocument(observed.before)
|
|
746
|
+
: parseConfig(observed.before, observed.format),
|
|
747
|
+
restore: {
|
|
748
|
+
opId: observed.entry.opId,
|
|
749
|
+
entry: observed.entry,
|
|
750
|
+
snapshotKind: observed.snapshotKind,
|
|
751
|
+
snapshotText: observed.snapshotText,
|
|
752
|
+
confirmDrift: request.confirmDrift === true,
|
|
753
|
+
driftsFromResult: observed.driftsFromResult,
|
|
754
|
+
},
|
|
755
|
+
...(request.profileId === undefined ? {} : { profileId: request.profileId }),
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* What a mutation is asked to do. Declared here because the observation below consumes it and the
|
|
761
|
+
* planner must not depend on the writer; the writer re-exports it, so callers are unaffected.
|
|
762
|
+
*/
|
|
763
|
+
export interface IntegrationWriteInput {
|
|
764
|
+
clientId: IntegrationClientId;
|
|
765
|
+
models: readonly ExportModel[];
|
|
766
|
+
config: OcxConfig;
|
|
767
|
+
port: number;
|
|
768
|
+
env?: NodeJS.ProcessEnv;
|
|
769
|
+
home?: string;
|
|
770
|
+
store?: IntegrationStateStore;
|
|
771
|
+
io?: IntegrationIO;
|
|
772
|
+
/** Frozen once by the async coordinator; synchronous callers may omit it. */
|
|
773
|
+
resolvedPaths?: { configPath: string; detectDir: string };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** A refusal in the planner's own vocabulary, so observation does not depend on the writer's result type. */
|
|
777
|
+
export interface IntegrationObservationFailure {
|
|
778
|
+
readonly reason: RefusalReason;
|
|
779
|
+
readonly state: IntegrationState;
|
|
780
|
+
readonly message: string;
|
|
781
|
+
readonly snapshotPath?: string;
|
|
782
|
+
/** A Cline transaction left residue that only a mutation may clear. */
|
|
783
|
+
readonly residual?: boolean;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function observationFailure(
|
|
787
|
+
reason: RefusalReason,
|
|
788
|
+
state: IntegrationState,
|
|
789
|
+
message: string,
|
|
790
|
+
snapshotPath?: string,
|
|
791
|
+
): IntegrationObservationFailure {
|
|
792
|
+
return { reason, state, message, ...(snapshotPath ? { snapshotPath } : {}) };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* What preview and mutation are allowed to touch while looking.
|
|
797
|
+
*
|
|
798
|
+
* Both are false for a preview and both are true for a mutation, and neither defaults, because the
|
|
799
|
+
* difference is the whole safety argument. `maintenance` runs pending snapshot pruning, which
|
|
800
|
+
* writes; `recover` lets the Cline adapter repair a pending transaction, which also writes. A
|
|
801
|
+
* preview that quietly inherited either would be a mutation wearing a read's name.
|
|
802
|
+
*/
|
|
803
|
+
export interface ObservationEffects {
|
|
804
|
+
readonly maintenance: boolean;
|
|
805
|
+
readonly recover: boolean;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Detect, gate, read, parse and classify, once, for both preview and mutation.
|
|
810
|
+
*
|
|
811
|
+
* Extracted from the writer so a plan and the mutation it authorizes rest on the same
|
|
812
|
+
* classification rather than two independent reads that can disagree. The ordering of refusals is
|
|
813
|
+
* load-bearing and is preserved exactly as the writer had it.
|
|
814
|
+
*/
|
|
815
|
+
export function observeIntegration(input: IntegrationWriteInput, effects: ObservationEffects) {
|
|
816
|
+
const store = input.store ?? createIntegrationStateStore();
|
|
817
|
+
let io = input.io ?? defaultIntegrationIO(store);
|
|
818
|
+
const clientId = input.clientId;
|
|
819
|
+
const spec = INTEGRATION_CLIENTS[clientId];
|
|
820
|
+
const exportSpec = EXPORT_CLIENTS[clientId];
|
|
821
|
+
/*
|
|
822
|
+
* Resolution itself can refuse: a relative OPENCLAW_* selector is rejected
|
|
823
|
+
* because we cannot know the gateway's working directory. That is a refusal
|
|
824
|
+
* about the user's configuration, not an internal fault, so it must not
|
|
825
|
+
* escape as an exception — the collection route would answer 500 for the
|
|
826
|
+
* whole Integrations page because one client is misconfigured.
|
|
827
|
+
*/
|
|
828
|
+
let configPath: string;
|
|
829
|
+
let detectDir: string;
|
|
830
|
+
let effective: IntegrationTarget;
|
|
831
|
+
let stored: OwnershipRecord | null;
|
|
832
|
+
try {
|
|
833
|
+
/*
|
|
834
|
+
* Resolve the PAIR, never one half.
|
|
835
|
+
*
|
|
836
|
+
* The coordinated path hands us a frozen pair, but applyIntegration,
|
|
837
|
+
* refreshIntegration and disableIntegration are public and may be called
|
|
838
|
+
* without one. Resolving configPath here and detectDir separately later let
|
|
839
|
+
* an Aside account switch land between the two, so a direct apply could
|
|
840
|
+
* verify account 1 was installed and then write account 0's catalog.
|
|
841
|
+
*/
|
|
842
|
+
const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home);
|
|
843
|
+
detectDir = resolved.detectDir;
|
|
844
|
+
if (clientId === "cline") io = createClineIO(io, resolved.configPath, store, effects.recover);
|
|
845
|
+
/*
|
|
846
|
+
* A record proves ownership of the file it was written FOR, and it is also
|
|
847
|
+
* one of the inputs the target is chosen from: a block we already wrote to
|
|
848
|
+
* the config file keeps this operation on that file, so disable removes what
|
|
849
|
+
* we wrote from where we wrote it. Matching by path happens after the
|
|
850
|
+
* target is known, because that is the path it has to match.
|
|
851
|
+
*/
|
|
852
|
+
stored = store.readRecords()[clientId] ?? null;
|
|
853
|
+
/*
|
|
854
|
+
* Inside the same guard as resolution, because this resolver can refuse the
|
|
855
|
+
* same way: the store is named by a client env var, and a relative one is a
|
|
856
|
+
* misconfiguration to report rather than an exception to leak through the
|
|
857
|
+
* collection route.
|
|
858
|
+
*/
|
|
859
|
+
effective = resolveIntegrationTarget({
|
|
860
|
+
clientId, configPath: resolved.configPath, io, record: stored, env: input.env, home: input.home,
|
|
861
|
+
});
|
|
862
|
+
configPath = effective.configPath;
|
|
863
|
+
} catch (error) {
|
|
864
|
+
if (error instanceof ClineTransactionError) {
|
|
865
|
+
return { failed: { ...observationFailure("unsafe", "unsafe", error.message, error.snapshotPath), residual: true } } as const;
|
|
866
|
+
}
|
|
867
|
+
if (!(error instanceof ClientPathError)) throw error;
|
|
868
|
+
return { failed: observationFailure("unsafe", "unsafe", error.message) } as const;
|
|
869
|
+
}
|
|
870
|
+
// Pruning writes, so only a mutation may perform it. Preview reports the state it finds.
|
|
871
|
+
if (effects.maintenance) store.retryPendingPrunes();
|
|
872
|
+
|
|
873
|
+
const loaded = loadTarget(io, configPath);
|
|
874
|
+
if (!loaded.ok) {
|
|
875
|
+
return {
|
|
876
|
+
failed: observationFailure("unsafe", "unsafe",
|
|
877
|
+
loaded.why === "read-failed"
|
|
878
|
+
? `${configPath} exists but could not be read`
|
|
879
|
+
: `${configPath} is not a regular file`),
|
|
880
|
+
} as const;
|
|
881
|
+
}
|
|
882
|
+
const before = loaded.before;
|
|
883
|
+
const parsed = clientId === "cline" ? parseClineDocument(before) : parseConfig(before, effective.format);
|
|
884
|
+
if (parsed === PARSE_FAILED) {
|
|
885
|
+
return { failed: observationFailure("unsafe", "unsafe",
|
|
886
|
+
`${configPath} could not be parsed, or holds something opencodex cannot rewrite without changing it (a non-finite number, a large integer or a tiny one a rewrite would round, -0, a duplicate member, or nesting deeper than 1000 levels)`) } as const;
|
|
887
|
+
}
|
|
888
|
+
/*
|
|
889
|
+
* The shape the TARGET's reader understands, which is not always the
|
|
890
|
+
* client's config format: a client that moved its providers to another file
|
|
891
|
+
* reads a different document there, and a write in the config file's shape
|
|
892
|
+
* would be as unread as a write to the config file itself.
|
|
893
|
+
*/
|
|
894
|
+
const contribution = effective.buildContribution(exportContextOf(input));
|
|
895
|
+
// A record proves ownership of the file it was written FOR. Matching only by
|
|
896
|
+
// client id let a record for one home authorize a write to another whose
|
|
897
|
+
// bytes happened to hash the same — which deleted a config we never touched.
|
|
898
|
+
const record = stored && stored.clientId === clientId && stored.configPath === configPath
|
|
899
|
+
? stored
|
|
900
|
+
: null;
|
|
901
|
+
// `configPath`/`clientId` are load-bearing, not decoration: a record proves
|
|
902
|
+
// ownership of ONE file, and the writer mutates whatever path resolves NOW.
|
|
903
|
+
// Without them a record written for another home directory would grant
|
|
904
|
+
// ownership here and disable would delete fragments it never wrote.
|
|
905
|
+
const classified = classifyIntegration({
|
|
906
|
+
fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId,
|
|
907
|
+
format: effective.format,
|
|
908
|
+
});
|
|
909
|
+
return {
|
|
910
|
+
failed: undefined, store, io, clientId, spec, exportSpec, target: effective, configPath, detectDir,
|
|
911
|
+
/*
|
|
912
|
+
* One token for the plan, because the location alone is not the input: a
|
|
913
|
+
* store whose schema stops being one we recognise changes the answer while
|
|
914
|
+
* its path stays exactly the same.
|
|
915
|
+
*/
|
|
916
|
+
ineffectiveWrite: effective.ineffective === null
|
|
917
|
+
? null
|
|
918
|
+
: `${effective.ineffective.why}\u0000${effective.ineffective.store}`,
|
|
919
|
+
before, parsed, contribution, record, classified,
|
|
920
|
+
} as const;
|
|
921
|
+
}
|