@bitkyc08/opencodex 2.77.0 → 2.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ocx.mjs +11 -10
- package/gui/dist/assets/App-BtfG5id-.js +54 -0
- package/gui/dist/assets/App-lCIslZ1L.css +1 -0
- package/gui/dist/assets/{Tray-gloIYNaW.js → Tray-DiJpSub0.js} +1 -1
- package/gui/dist/assets/index-Btc3Ogw4.js +94 -0
- package/gui/dist/assets/index-DmQB8D_R.css +1 -0
- package/gui/dist/assets/quota-summary-B_a96nXj.js +1 -0
- package/gui/dist/index.html +2 -2
- 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/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 +35 -19
- package/src/claude/intercept/client-hello.ts +103 -0
- package/src/claude/intercept/model-bindings.ts +3 -2
- package/src/claude/intercept/picker-listener.ts +279 -66
- 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 +30 -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 +34 -8
- package/src/cli/agent-runtime-settings.ts +130 -0
- package/src/cli/agent-settings.ts +101 -0
- package/src/cli/agent.ts +19 -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 -1199
- package/src/cli/capability-types.ts +54 -0
- package/src/cli/catalog-command-result.ts +57 -0
- package/src/cli/chatgpt-command.ts +25 -2
- 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/codex-shim-autorestore.ts +2 -1
- package/src/cli/codex-shim-readiness.ts +23 -3
- 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/dispatch.ts +52 -79
- package/src/cli/doctor.ts +22 -4
- package/src/cli/help-catalog.ts +31 -14
- package/src/cli/help-recovery.ts +2 -2
- package/src/cli/help.ts +25 -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 +7 -0
- 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/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 +112 -34
- package/src/cli/registry.ts +62 -20
- 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 +12 -0
- package/src/cli/route-policy-write.ts +137 -0
- package/src/cli/route-policy.ts +8 -0
- package/src/cli/runtime-api.ts +56 -79
- 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/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/dsh.ts +21 -0
- package/src/clients/config-export.ts +7 -1
- 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/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 +58 -10
- 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/codex-home-owner.ts +124 -0
- package/src/codex/convergence.ts +55 -15
- 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/management-convergence.ts +3 -0
- package/src/codex/quota-types.ts +3 -0
- package/src/codex/quota.ts +13 -3
- package/src/codex/refresh.ts +20 -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 +14 -2
- package/src/codex/shim-probe.ts +4 -3
- package/src/codex/shim-state-file.ts +93 -7
- package/src/codex/shim.ts +15 -68
- package/src/codex/sync.ts +24 -4
- package/src/combos/failover.ts +19 -0
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +12 -0
- package/src/config/atomic-write.ts +13 -8
- package/src/config/diagnostics.ts +19 -1
- package/src/config/load-degrade.ts +3 -0
- package/src/config/schema/chatgpt-desktop.ts +22 -0
- package/src/config/schema/config-schema.ts +32 -14
- package/src/config/schema/leaf-validators.ts +3 -2
- package/src/generated/compatibility-version.json +445 -129
- package/src/integrations/merge.ts +13 -5
- package/src/integrations/mutation-plan.ts +46 -3
- 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 +46 -21
- package/src/integrations/target.ts +25 -2
- package/src/integrations/writer.ts +89 -31
- package/src/lib/package-tree-integrity.ts +42 -1
- 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-routing.ts +17 -12
- package/src/oauth/anthropic.ts +27 -2
- package/src/oauth/index.ts +6 -8
- package/src/oauth/pool-settings-capability.ts +9 -2
- package/src/protocols/plan-snapshot.ts +1 -1
- package/src/protocols/settings.ts +28 -4
- package/src/server/caller-session-identity.ts +32 -0
- package/src/server/claude-messages.ts +11 -4
- package/src/server/index/serve-options.ts +12 -7
- package/src/server/index.ts +1 -1
- package/src/server/management/agent-settings-routes.ts +6 -5
- 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 +5 -5
- package/src/server/management/model-routes.ts +3 -3
- 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 +25 -24
- 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/responses/adapter-dispatch.ts +53 -28
- package/src/server/responses/core-auth.ts +10 -4
- package/src/server/responses/core-combo.ts +46 -6
- 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/types/config.ts +2 -0
- package/src/types/provider.ts +10 -10
- package/src/update/index.ts +2 -1
- package/src/update/update-failure-guidance.d.mts +6 -0
- package/src/update/update-failure-guidance.mjs +30 -5
- package/gui/dist/assets/App-COdEeqSj.css +0 -1
- package/gui/dist/assets/App-DEpCJEP_.js +0 -54
- package/gui/dist/assets/index-Cd7ittMv.css +0 -1
- package/gui/dist/assets/index-DwJKFJm5.js +0 -94
- package/gui/dist/assets/quota-summary-DGcEJU3h.js +0 -1
|
@@ -184,9 +184,11 @@ export function setPath(doc: unknown, path: readonly string[], value: unknown):
|
|
|
184
184
|
/*
|
|
185
185
|
* `parent[slot]` is the position the segment just consumed addresses. The
|
|
186
186
|
* root sits in a one-key holder so the first segment needs no special case:
|
|
187
|
-
* a
|
|
187
|
+
* a document that is neither a record nor a sequence is replaced by `{}`
|
|
188
|
+
* exactly as before. A sequence root survives so a leading selector can
|
|
189
|
+
* address one of its entries (a DSH profile patch is a top-level list).
|
|
188
190
|
*/
|
|
189
|
-
const holder: Record<string, unknown> = { root: isPlainRecord(doc) ? clone(doc) : {} };
|
|
191
|
+
const holder: Record<string, unknown> = { root: isPlainRecord(doc) || Array.isArray(doc) ? clone(doc) : {} };
|
|
190
192
|
let parent: Record<string, unknown> | unknown[] = holder;
|
|
191
193
|
let slot: string | number = "root";
|
|
192
194
|
const read = (): unknown => (Array.isArray(parent) ? parent[slot as number] : parent[slot as string]);
|
|
@@ -244,8 +246,8 @@ export function deletePath(
|
|
|
244
246
|
path: readonly string[],
|
|
245
247
|
createdContainers: ReadonlySet<string> = new Set(),
|
|
246
248
|
): { doc: unknown; removed: boolean } {
|
|
247
|
-
if (!isPlainRecord(doc) || path.length === 0) return { doc, removed: false };
|
|
248
|
-
const root = clone(doc) as Record<string, unknown
|
|
249
|
+
if ((!isPlainRecord(doc) && !Array.isArray(doc)) || path.length === 0) return { doc, removed: false };
|
|
250
|
+
const root = clone(doc) as Record<string, unknown> | unknown[];
|
|
249
251
|
// `chain[i]` is the container segment `i` is resolved against; `slots[i]` is
|
|
250
252
|
// the key or index it resolved to, so the prune walk can delete by position.
|
|
251
253
|
const chain: (Record<string, unknown> | unknown[])[] = [root];
|
|
@@ -288,7 +290,13 @@ export function deletePath(
|
|
|
288
290
|
*/
|
|
289
291
|
for (let index = path.length - 1; index >= 1; index -= 1) {
|
|
290
292
|
const container = chain[index]!;
|
|
291
|
-
|
|
293
|
+
// An element we pushed for a selector is empty once only the fields that seeded it remain.
|
|
294
|
+
const segment = parseSegment(path[index - 1]!);
|
|
295
|
+
const empty = Array.isArray(container)
|
|
296
|
+
? container.length === 0
|
|
297
|
+
: segment.kind === "select"
|
|
298
|
+
? Object.keys(container).length === segment.criteria.length && selectIndex([container], segment.criteria) === 0
|
|
299
|
+
: Object.keys(container).length === 0;
|
|
292
300
|
if (!empty) break;
|
|
293
301
|
const containerPath = path.slice(0, index).join("\u0000");
|
|
294
302
|
if (!createdContainers.has(containerPath)) break;
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* preview route.
|
|
15
15
|
*/
|
|
16
16
|
import { createHash } from "node:crypto";
|
|
17
|
+
import { dirname } from "node:path";
|
|
17
18
|
import { canonicalContribution, fingerprint, type OwnershipRecord } from "./ownership";
|
|
18
19
|
import { ClientPathError, EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
|
|
19
20
|
import { OPENCODE_PROVIDER_ID } from "../clients/config-export/constants";
|
|
@@ -34,7 +35,7 @@ import {
|
|
|
34
35
|
restoreOwnershipCollision,
|
|
35
36
|
type IntegrationClientId,
|
|
36
37
|
} from "./registry";
|
|
37
|
-
import { declaredIntegrationTarget, resolveIntegrationTarget, type IntegrationTarget } from "./target";
|
|
38
|
+
import { declaredIntegrationTarget, resolveIntegrationTarget, type IneffectiveWrite, type IneffectiveWriteReason, type IntegrationTarget } from "./target";
|
|
38
39
|
import { shouldInjectApiAuthHeader } from "../codex/inject";
|
|
39
40
|
import { buildIntegrationContribution, classifyIntegration, exportContextOf, readPath, type IntegrationState, type StateReason } from "./state";
|
|
40
41
|
import { inspectKiloCandidates } from "./kilo-candidates";
|
|
@@ -99,6 +100,13 @@ export interface IntegrationMutationPlan {
|
|
|
99
100
|
*/
|
|
100
101
|
readonly willChange: boolean;
|
|
101
102
|
readonly refusalReason?: RefusalReason;
|
|
103
|
+
/**
|
|
104
|
+
* `superseded_store` only: why the store the client reads is not written. The store's location
|
|
105
|
+
* stays on the status row (`supersededBy`); the plan names no file.
|
|
106
|
+
*/
|
|
107
|
+
readonly supersededReason?: IneffectiveWriteReason;
|
|
108
|
+
/** `missing-store` only: the document that recreates the store, so the remedy can name it. */
|
|
109
|
+
readonly missingStoreDocument?: string;
|
|
102
110
|
readonly profileId?: number;
|
|
103
111
|
}
|
|
104
112
|
|
|
@@ -129,7 +137,7 @@ const CLIENT_MANAGED_PATHS = {
|
|
|
129
137
|
openclaw: [["models", "providers", OPENCODE_PROVIDER_ID]],
|
|
130
138
|
kimi: [["providers", OPENCODE_PROVIDER_ID], ["models", DYNAMIC_SEGMENT]],
|
|
131
139
|
gajae: [["providers", OPENCODE_PROVIDER_ID]],
|
|
132
|
-
dsh: [["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID]],
|
|
140
|
+
dsh: [["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID], ["[id=llm-pi-ai]", "config", "providers", OPENCODE_PROVIDER_ID]],
|
|
133
141
|
mcode: [["custom_provider", OPENCODE_PROVIDER_ID]],
|
|
134
142
|
zcode: [
|
|
135
143
|
["provider", OPENCODE_PROVIDER_ID],
|
|
@@ -245,6 +253,11 @@ export interface PlanFingerprintInput {
|
|
|
245
253
|
* flip as a store appearing.
|
|
246
254
|
*/
|
|
247
255
|
readonly ineffectiveWrite: string | null;
|
|
256
|
+
/**
|
|
257
|
+
* The same finding as `ineffectiveWrite`, unflattened, for the refusal's description. It is
|
|
258
|
+
* descriptive only: the bound token above is what decides and what the fingerprint covers.
|
|
259
|
+
*/
|
|
260
|
+
readonly ineffective?: IneffectiveWrite | null;
|
|
248
261
|
/** Exact current bytes, or null when the target is missing. Missing and empty are not equal. */
|
|
249
262
|
readonly before: string | null;
|
|
250
263
|
readonly contribution: ManagedContribution | null;
|
|
@@ -497,6 +510,12 @@ export function buildMutationPlan(input: PlanInput): IntegrationMutationPlan {
|
|
|
497
510
|
canApply: outcome.kind !== "refuse",
|
|
498
511
|
willChange: outcome.kind === "change",
|
|
499
512
|
...(outcome.kind === "refuse" ? { refusalReason: outcome.reason } : {}),
|
|
513
|
+
...(outcome.kind === "refuse" && outcome.reason === "superseded_store" && input.ineffective
|
|
514
|
+
? {
|
|
515
|
+
supersededReason: input.ineffective.why,
|
|
516
|
+
...(input.ineffective.emptyDocument === undefined ? {} : { missingStoreDocument: input.ineffective.emptyDocument }),
|
|
517
|
+
}
|
|
518
|
+
: {}),
|
|
500
519
|
...(input.profileId === undefined ? {} : { profileId: input.profileId }),
|
|
501
520
|
});
|
|
502
521
|
}
|
|
@@ -531,6 +550,25 @@ function unboundPlan(
|
|
|
531
550
|
});
|
|
532
551
|
}
|
|
533
552
|
|
|
553
|
+
/** A restore must not recreate a declared store without its client's lock. */
|
|
554
|
+
export function restoreStoreDirectoryRefusal(
|
|
555
|
+
input: IntegrationWriteInput,
|
|
556
|
+
configPath: string,
|
|
557
|
+
io: IntegrationIO,
|
|
558
|
+
): string | null {
|
|
559
|
+
const declared = INTEGRATION_CLIENTS[input.clientId].currentStore;
|
|
560
|
+
if (!declared?.lockFile) return null;
|
|
561
|
+
let storePath: string;
|
|
562
|
+
try {
|
|
563
|
+
storePath = declared.path(input.env, input.home);
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (error instanceof ClientPathError) return null;
|
|
566
|
+
throw error;
|
|
567
|
+
}
|
|
568
|
+
return configPath === storePath && io.statKind(dirname(storePath)) !== "dir"
|
|
569
|
+
? "the client store directory is missing; restore will not create it" : null;
|
|
570
|
+
}
|
|
571
|
+
|
|
534
572
|
/**
|
|
535
573
|
* Restore reads a different specification, so it gets its own observation.
|
|
536
574
|
*
|
|
@@ -592,6 +630,10 @@ export function observeRestore(
|
|
|
592
630
|
failed: observationFailure("conflict", "conflict", "that operation was recorded for a different location"),
|
|
593
631
|
} as const;
|
|
594
632
|
}
|
|
633
|
+
const directoryRefusal = restoreStoreDirectoryRefusal(input, configPath, io);
|
|
634
|
+
if (directoryRefusal !== null) {
|
|
635
|
+
return { failed: observationFailure("unsafe", "unsafe", directoryRefusal) } as const;
|
|
636
|
+
}
|
|
595
637
|
/*
|
|
596
638
|
* A legal historical path is not enough. Another candidate can already own
|
|
597
639
|
* the single record, and committing this row's prior record would orphan the
|
|
@@ -678,6 +720,7 @@ export function previewIntegration(input: IntegrationWriteInput, request: Previe
|
|
|
678
720
|
// Loopback-only clients cannot carry the admission header a non-loopback bind requires.
|
|
679
721
|
admissionBlocked: isLoopbackOnly(observed.clientId) && shouldInjectApiAuthHeader(input.config),
|
|
680
722
|
ineffectiveWrite: observed.ineffectiveWrite,
|
|
723
|
+
ineffective: observed.target.ineffective,
|
|
681
724
|
before: observed.before,
|
|
682
725
|
contribution: observed.contribution,
|
|
683
726
|
record: observed.record,
|
|
@@ -954,7 +997,7 @@ export function observeIntegration(
|
|
|
954
997
|
// ownership here and disable would delete fragments it never wrote.
|
|
955
998
|
const classified = classifyIntegration({
|
|
956
999
|
fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId,
|
|
957
|
-
format: effective.format,
|
|
1000
|
+
format: effective.format, sourcePreservingYaml: effective.sourcePreservingYaml !== null,
|
|
958
1001
|
});
|
|
959
1002
|
if (clientId === "droid" && record && (classified.state === "current" || classified.state === "stale")) {
|
|
960
1003
|
try { assertDroidRecordedSettingsUnambiguous(detectDir, parsed, record); }
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* expected document; unsupported YAML fails closed and never falls back to a
|
|
8
8
|
* whole-document serializer.
|
|
9
9
|
*/
|
|
10
|
+
import { parseSegment, selectIndex, type SelectorCriterion } from "./merge";
|
|
10
11
|
import { renderYaml } from "./serialize";
|
|
11
12
|
|
|
12
13
|
interface SourceLine {
|
|
@@ -442,6 +443,7 @@ function upsertSource(
|
|
|
442
443
|
path: readonly string[],
|
|
443
444
|
value: unknown,
|
|
444
445
|
): string | null {
|
|
446
|
+
if (leadingSelector(path) !== null) return upsertSequenceEntry(text, parsed, path, value);
|
|
445
447
|
const located = locatePath(text, parsed, path);
|
|
446
448
|
if (located === null || located.kind === "unsupported-style") return null;
|
|
447
449
|
const eol = lineEnding(text);
|
|
@@ -467,6 +469,178 @@ function upsertSource(
|
|
|
467
469
|
return preserveFinalNewline(candidate, text, eol);
|
|
468
470
|
}
|
|
469
471
|
|
|
472
|
+
/**
|
|
473
|
+
* The criteria of a path whose first segment selects one entry of a top-level
|
|
474
|
+
* sequence, or null for an ordinary block-map path.
|
|
475
|
+
*
|
|
476
|
+
* DSH 0.1.7+ keeps settings in a profile patch: a YAML list of loader rows,
|
|
477
|
+
* `- id: llm-pi-ai` among them. The entry is patched as the block map it is
|
|
478
|
+
* once its `- ` indicator and two-space continuation indent are set aside, and
|
|
479
|
+
* both are put back byte for byte. Every other list shape fails closed.
|
|
480
|
+
*/
|
|
481
|
+
function leadingSelector(path: readonly string[]): readonly SelectorCriterion[] | null {
|
|
482
|
+
if (path.length === 0) return null;
|
|
483
|
+
const segment = parseSegment(path[0]!);
|
|
484
|
+
return segment.kind === "select" ? segment.criteria : null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Line ranges of a block sequence's top-level entries, or null for any other document shape. */
|
|
488
|
+
function topLevelEntries(lines: readonly SourceLine[]): Array<{ start: number; end: number }> | null {
|
|
489
|
+
const entries: Array<{ start: number; end: number }> = [];
|
|
490
|
+
let open = false;
|
|
491
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
492
|
+
const body = lines[index]!.body;
|
|
493
|
+
if (isBlank(body)) continue;
|
|
494
|
+
const spaces = leadingSpaces(body);
|
|
495
|
+
if (spaces === null) return null;
|
|
496
|
+
if (spaces > 0) {
|
|
497
|
+
// Continuation of the open entry; deeper text anywhere else is not a list we can place.
|
|
498
|
+
if (!open) return null;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (open) entries[entries.length - 1]!.end = index;
|
|
502
|
+
open = false;
|
|
503
|
+
if (isComment(body)) continue;
|
|
504
|
+
if (!/^- \S/u.test(body)) return null;
|
|
505
|
+
entries.push({ start: index, end: lines.length });
|
|
506
|
+
open = true;
|
|
507
|
+
}
|
|
508
|
+
return entries;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** The entry's text as the block map it holds, or null when it is not in the two-space form. */
|
|
512
|
+
function entrySource(text: string, lines: readonly SourceLine[], start: number, end: number): string | null {
|
|
513
|
+
let source = "";
|
|
514
|
+
for (let index = start; index < end; index += 1) {
|
|
515
|
+
const line = lines[index]!;
|
|
516
|
+
const raw = text.slice(line.start, line.end);
|
|
517
|
+
if (index === start) source += raw.slice(2);
|
|
518
|
+
else if (isBlank(line.body)) source += raw;
|
|
519
|
+
else if ((leadingSpaces(line.body) ?? 0) >= 2) source += raw.slice(2);
|
|
520
|
+
else return null;
|
|
521
|
+
}
|
|
522
|
+
return source;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Put an entry back: `- ` on its first line, two spaces on every other non-blank line. */
|
|
526
|
+
function reindentEntry(source: string): string {
|
|
527
|
+
return sourceLines(source).map((line, index) => {
|
|
528
|
+
const raw = source.slice(line.start, line.end);
|
|
529
|
+
if (index === 0) return `- ${raw}`;
|
|
530
|
+
return isBlank(line.body) ? raw : ` ${raw}`;
|
|
531
|
+
}).join("");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function firstLine(text: string): string {
|
|
535
|
+
return text.split(/\r?\n/u, 1)[0] ?? "";
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
type SequenceEntry =
|
|
539
|
+
| {
|
|
540
|
+
kind: "found";
|
|
541
|
+
criteria: readonly SelectorCriterion[];
|
|
542
|
+
rest: readonly string[];
|
|
543
|
+
/** Byte range of the entry, trailing blank lines included. */
|
|
544
|
+
startOffset: number;
|
|
545
|
+
endOffset: number;
|
|
546
|
+
source: string;
|
|
547
|
+
parsed: Record<string, unknown>;
|
|
548
|
+
/** Entries the list keeps if this one is removed. */
|
|
549
|
+
siblings: number;
|
|
550
|
+
}
|
|
551
|
+
| {
|
|
552
|
+
kind: "missing";
|
|
553
|
+
criteria: readonly SelectorCriterion[];
|
|
554
|
+
rest: readonly string[];
|
|
555
|
+
/** Where a new entry goes, and the end of the `[]` line it replaces, if any. */
|
|
556
|
+
insertAt: number;
|
|
557
|
+
replaceEnd: number | null;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
function sequenceEntry(text: string, parsed: unknown, path: readonly string[]): SequenceEntry | null {
|
|
561
|
+
const criteria = leadingSelector(path);
|
|
562
|
+
if (criteria === null || !Array.isArray(parsed)) return null;
|
|
563
|
+
const lines = sourceLines(text);
|
|
564
|
+
const rest = path.slice(1);
|
|
565
|
+
const found = selectIndex(parsed, criteria);
|
|
566
|
+
const entries = topLevelEntries(lines);
|
|
567
|
+
if (entries === null) {
|
|
568
|
+
// `[]` is how DSH writes a profile patch with no rows yet: the one flow form adopted.
|
|
569
|
+
const content = lines.filter(line => !isBlank(line.body) && !isComment(line.body));
|
|
570
|
+
if (found >= 0 || parsed.length !== 0 || content.length !== 1 || content[0]!.body.trimEnd() !== "[]") return null;
|
|
571
|
+
return { kind: "missing", criteria, rest, insertAt: content[0]!.start, replaceEnd: content[0]!.end };
|
|
572
|
+
}
|
|
573
|
+
if (entries.length !== parsed.length) return null;
|
|
574
|
+
if (found < 0) return { kind: "missing", criteria, rest, insertAt: text.length, replaceEnd: null };
|
|
575
|
+
const element = parsed[found];
|
|
576
|
+
const { start, end } = entries[found]!;
|
|
577
|
+
const source = entrySource(text, lines, start, end);
|
|
578
|
+
if (!isPlainRecord(element) || source === null) return null;
|
|
579
|
+
return {
|
|
580
|
+
kind: "found",
|
|
581
|
+
criteria,
|
|
582
|
+
rest,
|
|
583
|
+
startOffset: lines[start]!.start,
|
|
584
|
+
endOffset: end < lines.length ? lines[end]!.start : text.length,
|
|
585
|
+
source,
|
|
586
|
+
parsed: element,
|
|
587
|
+
siblings: entries.length - 1,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function spliceEntry(text: string, entry: Extract<SequenceEntry, { kind: "found" }>, replacement: string): string {
|
|
592
|
+
return `${text.slice(0, entry.startOffset)}${replacement}${text.slice(entry.endOffset)}`;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function upsertSequenceEntry(text: string, parsed: unknown, path: readonly string[], value: unknown): string | null {
|
|
596
|
+
const entry = sequenceEntry(text, parsed, path);
|
|
597
|
+
if (entry === null) return null;
|
|
598
|
+
const eol = lineEnding(text);
|
|
599
|
+
if (entry.kind === "found") {
|
|
600
|
+
if (entry.rest.length === 0) return null;
|
|
601
|
+
const patched = upsertSource(entry.source, entry.parsed, entry.rest, value);
|
|
602
|
+
// The `- ` belongs to the entry's first line; a patch that rewrote that line cannot take it back.
|
|
603
|
+
if (patched === null || firstLine(patched) !== firstLine(entry.source)) return null;
|
|
604
|
+
return preserveFinalNewline(spliceEntry(text, entry, reindentEntry(patched)), text, eol);
|
|
605
|
+
}
|
|
606
|
+
// The same element `setPath` seeds: the selector's own fields, then the fragment beneath them.
|
|
607
|
+
const seeded = Object.fromEntries(entry.criteria.map(criterion => [criterion.field, criterion.value]));
|
|
608
|
+
const element = entry.rest.length === 0 ? value : { ...seeded, ...nestedValue(entry.rest, value) };
|
|
609
|
+
if (!isPlainRecord(element)) return null;
|
|
610
|
+
const item = reindentEntry(rendered(element, 0, eol));
|
|
611
|
+
if (entry.replaceEnd !== null) {
|
|
612
|
+
return preserveFinalNewline(`${text.slice(0, entry.insertAt)}${item}${text.slice(entry.replaceEnd)}`, text, eol);
|
|
613
|
+
}
|
|
614
|
+
const prefix = text.length > 0 && !text.endsWith("\n") ? eol : "";
|
|
615
|
+
return preserveFinalNewline(`${text}${prefix}${item}`, text, eol);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function removeSequenceEntryPath(
|
|
619
|
+
text: string,
|
|
620
|
+
parsed: unknown,
|
|
621
|
+
path: readonly string[],
|
|
622
|
+
requireEmpty: boolean,
|
|
623
|
+
): string | null {
|
|
624
|
+
const entry = sequenceEntry(text, parsed, path);
|
|
625
|
+
if (entry === null || entry.kind !== "found") return null;
|
|
626
|
+
if (entry.rest.length > 0) {
|
|
627
|
+
const patched = removeExactPath(entry.source, entry.rest, requireEmpty);
|
|
628
|
+
if (patched === null || firstLine(patched) !== firstLine(entry.source)) return null;
|
|
629
|
+
return spliceEntry(text, entry, reindentEntry(patched));
|
|
630
|
+
}
|
|
631
|
+
if (requireEmpty) {
|
|
632
|
+
// A pruned entry may still hold what its selector seeded, and nothing else.
|
|
633
|
+
const seededOnly = Object.keys(entry.parsed).length === entry.criteria.length
|
|
634
|
+
&& selectIndex([entry.parsed], entry.criteria) === 0;
|
|
635
|
+
if (!seededOnly || sourceLines(entry.source).some(line => hasInlineComment(line.body) || isComment(line.body))) {
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
// A list left with no entries goes back to the `[]` DSH writes for an empty one.
|
|
640
|
+
const eol = lineEnding(text);
|
|
641
|
+
return preserveFinalNewline(spliceEntry(text, entry, entry.siblings === 0 ? `[]${eol}` : ""), text, eol);
|
|
642
|
+
}
|
|
643
|
+
|
|
470
644
|
function removeExactPath(text: string, path: readonly string[], requireEmpty: boolean): string | null {
|
|
471
645
|
let parsed: unknown;
|
|
472
646
|
try {
|
|
@@ -474,6 +648,7 @@ function removeExactPath(text: string, path: readonly string[], requireEmpty: bo
|
|
|
474
648
|
} catch {
|
|
475
649
|
return null;
|
|
476
650
|
}
|
|
651
|
+
if (leadingSelector(path) !== null) return removeSequenceEntryPath(text, parsed, path, requireEmpty);
|
|
477
652
|
const located = locatePath(text, parsed, path);
|
|
478
653
|
if (located === null || located.kind !== "existing") return null;
|
|
479
654
|
const { lines, index, endIndex } = located.entry;
|
|
@@ -506,7 +681,7 @@ function planSourceRemoval(
|
|
|
506
681
|
next = pruned;
|
|
507
682
|
prunedContainers.push(encoded);
|
|
508
683
|
}
|
|
509
|
-
return { text: next, prunedContainers };
|
|
684
|
+
return { text: preserveFinalNewline(next, text, lineEnding(text)), prunedContainers };
|
|
510
685
|
}
|
|
511
686
|
|
|
512
687
|
/** Containers whose source ranges are still empty and safe to prune. */
|
|
@@ -560,6 +735,11 @@ export function yamlFragmentUnsupportedStyle(text: string, path: readonly string
|
|
|
560
735
|
} catch {
|
|
561
736
|
return false;
|
|
562
737
|
}
|
|
738
|
+
if (leadingSelector(path) !== null) {
|
|
739
|
+
const entry = sequenceEntry(text, parsed, path);
|
|
740
|
+
return entry?.kind === "found" && entry.rest.length > 0
|
|
741
|
+
&& locatePath(entry.source, entry.parsed, entry.rest)?.kind === "unsupported-style";
|
|
742
|
+
}
|
|
563
743
|
return locatePath(text, parsed, path)?.kind === "unsupported-style";
|
|
564
744
|
}
|
|
565
745
|
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "../clients/config-export";
|
|
15
15
|
import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership";
|
|
16
16
|
import { readPath } from "./merge";
|
|
17
|
+
import { serializeDocument } from "./serialize";
|
|
17
18
|
|
|
18
19
|
type JsonObject = Record<string, unknown>;
|
|
19
20
|
|
|
@@ -51,6 +52,71 @@ export function isHermesAffinityUpgrade(
|
|
|
51
52
|
&& fingerprint(semanticContribution(predecessor)) === record.semanticBlockFingerprint);
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
function removeDroidNormalizationFields(row: Record<string, unknown>): boolean {
|
|
56
|
+
let projected = false;
|
|
57
|
+
if (Object.hasOwn(row, "id")) {
|
|
58
|
+
delete row.id;
|
|
59
|
+
projected = true;
|
|
60
|
+
}
|
|
61
|
+
if (Object.hasOwn(row, "index")) {
|
|
62
|
+
delete row.index;
|
|
63
|
+
projected = true;
|
|
64
|
+
}
|
|
65
|
+
return projected;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function projectObservedDroidContribution(
|
|
69
|
+
contribution: ManagedContribution,
|
|
70
|
+
): ManagedContribution {
|
|
71
|
+
if (contribution.clientId !== "droid") return contribution;
|
|
72
|
+
let projected = false;
|
|
73
|
+
const fragments = contribution.fragments.map(fragment => {
|
|
74
|
+
if (
|
|
75
|
+
fragment.path.length !== 2
|
|
76
|
+
|| fragment.path[0] !== "customModels"
|
|
77
|
+
|| !isObject(fragment.value)
|
|
78
|
+
) return fragment;
|
|
79
|
+
const value = { ...fragment.value };
|
|
80
|
+
if (!removeDroidNormalizationFields(value)) return fragment;
|
|
81
|
+
projected = true;
|
|
82
|
+
return { ...fragment, value };
|
|
83
|
+
});
|
|
84
|
+
return projected ? { ...contribution, fragments } : contribution;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function droidNormalizedContributionMatchesRecord(
|
|
88
|
+
observed: ManagedContribution,
|
|
89
|
+
record: OwnershipRecord,
|
|
90
|
+
): boolean {
|
|
91
|
+
const projectedObserved = projectObservedDroidContribution(observed);
|
|
92
|
+
return projectedObserved !== observed
|
|
93
|
+
&& (
|
|
94
|
+
fingerprint(canonicalContribution(projectedObserved)) === record.blockFingerprint
|
|
95
|
+
|| (typeof record.semanticBlockFingerprint === "string"
|
|
96
|
+
&& fingerprint(semanticContribution(projectedObserved)) === record.semanticBlockFingerprint)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function droidNormalizedFileMatchesRecord(
|
|
101
|
+
document: unknown,
|
|
102
|
+
record: OwnershipRecord,
|
|
103
|
+
): boolean {
|
|
104
|
+
if (record.clientId !== "droid") return false;
|
|
105
|
+
try {
|
|
106
|
+
const normalized = structuredClone(document);
|
|
107
|
+
let projected = false;
|
|
108
|
+
for (const path of record.fragmentPaths) {
|
|
109
|
+
if (path.length !== 2 || path[0] !== "customModels") return false;
|
|
110
|
+
const row = readPath(normalized, path);
|
|
111
|
+
if (!isObject(row)) return false;
|
|
112
|
+
if (removeDroidNormalizationFields(row)) projected = true;
|
|
113
|
+
}
|
|
114
|
+
return projected && fingerprint(serializeDocument(normalized, "json")) === record.fileFingerprint;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
54
120
|
function pathStartsWith(path: readonly string[], prefix: readonly string[]): boolean {
|
|
55
121
|
return prefix.length <= path.length && prefix.every((part, index) => path[index] === part);
|
|
56
122
|
}
|
|
@@ -12,7 +12,7 @@ import { homedir } from "node:os";
|
|
|
12
12
|
import { assertDroidSettingsUnambiguous } from "./droid-settings";
|
|
13
13
|
import { readPath } from "./merge";
|
|
14
14
|
import type { OwnershipRecord } from "./ownership";
|
|
15
|
-
import { join } from "node:path";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
16
|
import {
|
|
17
17
|
ClientPathError,
|
|
18
18
|
buildDroidContribution,
|
|
@@ -52,6 +52,10 @@ import {
|
|
|
52
52
|
zcodeProviderStorePath,
|
|
53
53
|
buildZcodeStoreContribution,
|
|
54
54
|
zcodeStoreSchemaEstablished,
|
|
55
|
+
dshProfilePatchPath,
|
|
56
|
+
dshProfilePatchEstablished,
|
|
57
|
+
buildDshProfilePatchContribution,
|
|
58
|
+
DSH_PROFILE_PROVIDER_PATH,
|
|
55
59
|
type BuildContribution,
|
|
56
60
|
type ConfigFormat,
|
|
57
61
|
kiloConfigPath,
|
|
@@ -96,6 +100,29 @@ export interface IntegrationClientSpec {
|
|
|
96
100
|
format: ConfigFormat;
|
|
97
101
|
establishes: (parsed: unknown) => boolean;
|
|
98
102
|
buildContribution: BuildContribution;
|
|
103
|
+
/** Patch only this leaf of the store, as `sourcePreservingYaml` does for the config file. */
|
|
104
|
+
sourcePreservingYaml?: { path: readonly string[] };
|
|
105
|
+
/**
|
|
106
|
+
* The file whose `<file>.lock` sibling the client's own writer holds while
|
|
107
|
+
* it rewrites the store. Taken after the config file's lock, whenever the
|
|
108
|
+
* store's directory exists.
|
|
109
|
+
*/
|
|
110
|
+
lockFile?: (storePath: string) => string;
|
|
111
|
+
/**
|
|
112
|
+
* A missing store the client already manages. Writing the config file instead would not stay
|
|
113
|
+
* where opencodex records it, so the target is reported as an ineffective write with this
|
|
114
|
+
* remedy rather than silently writing the config file.
|
|
115
|
+
*/
|
|
116
|
+
missingStore?: {
|
|
117
|
+
readsStore: (storePath: string, statKind: (path: string) => string) => boolean;
|
|
118
|
+
/**
|
|
119
|
+
* The document the client writes into a new store. Creating the store with exactly this is
|
|
120
|
+
* the remedy, and it is published on its own so a surface that localizes the remedy text
|
|
121
|
+
* can still name what to write.
|
|
122
|
+
*/
|
|
123
|
+
emptyDocument: string;
|
|
124
|
+
remedy: string;
|
|
125
|
+
};
|
|
99
126
|
};
|
|
100
127
|
/** Patch only this block-map YAML leaf; never re-render the shared file. */
|
|
101
128
|
sourcePreservingYaml?: { path: readonly string[] };
|
|
@@ -297,6 +324,28 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
|
|
|
297
324
|
detectDir: (env = process.env, home = homedir()) => dshHomeDir(env, home),
|
|
298
325
|
sourcePreservingYaml: { path: ["llm-pi-ai", "providers", "opencodex"] },
|
|
299
326
|
writerLock: { suffix: ".lock" },
|
|
327
|
+
/*
|
|
328
|
+
* DSH 0.1.7+ imports `settings.yaml` once into the first profile that boots
|
|
329
|
+
* and renames it; what it reads afterwards is the Desktop profile's patch.
|
|
330
|
+
*/
|
|
331
|
+
currentStore: {
|
|
332
|
+
path: (env = process.env, home = homedir()) => dshProfilePatchPath(env, home),
|
|
333
|
+
format: "yaml",
|
|
334
|
+
establishes: dshProfilePatchEstablished,
|
|
335
|
+
buildContribution: buildDshProfilePatchContribution,
|
|
336
|
+
sourcePreservingYaml: { path: DSH_PROFILE_PROVIDER_PATH },
|
|
337
|
+
// DSH's config editor serializes profile edits on the profile manifest's lock.
|
|
338
|
+
lockFile: store => join(dirname(store), "package.json"),
|
|
339
|
+
// A Desktop profile manifest without its patch is a profile DSH manages. On each startup
|
|
340
|
+
// DSH's importLegacyDocument renames `settings.yaml` to `settings.yaml.imported` and imports
|
|
341
|
+
// it into the active profile, so a block written there would leave opencodex's ownership
|
|
342
|
+
// record pointing at a file that no longer exists. Refuse it and name the remedy instead.
|
|
343
|
+
missingStore: {
|
|
344
|
+
readsStore: (store, statKind) => statKind(join(dirname(store), "package.json")) === "file",
|
|
345
|
+
emptyDocument: "[]",
|
|
346
|
+
remedy: "Create it containing `[]` (the empty patch DSH writes for a new profile), then enable the integration again.",
|
|
347
|
+
},
|
|
348
|
+
},
|
|
300
349
|
},
|
|
301
350
|
mcode: {
|
|
302
351
|
id: "mcode",
|
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
} from "./merge";
|
|
26
26
|
import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership";
|
|
27
27
|
import {
|
|
28
|
+
droidNormalizedContributionMatchesRecord,
|
|
29
|
+
droidNormalizedFileMatchesRecord,
|
|
28
30
|
isHermesAffinityUpgrade,
|
|
29
31
|
protectedContributionFingerprint,
|
|
30
32
|
refreshablePathsOf,
|
|
@@ -40,7 +42,7 @@ import {
|
|
|
40
42
|
unresolvedPathHintFor,
|
|
41
43
|
type IntegrationClientId,
|
|
42
44
|
} from "./registry";
|
|
43
|
-
import { resolveIntegrationTarget, type IntegrationTarget } from "./target";
|
|
45
|
+
import { resolveIntegrationTarget, type IneffectiveWriteReason, type IntegrationTarget } from "./target";
|
|
44
46
|
import { inspectKiloCandidates } from "./kilo-candidates";
|
|
45
47
|
import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
|
|
46
48
|
|
|
@@ -84,6 +86,13 @@ export interface IntegrationStatus {
|
|
|
84
86
|
* `current` has to be able to report this beside it.
|
|
85
87
|
*/
|
|
86
88
|
supersededBy?: string;
|
|
89
|
+
/** Why `supersededBy` is not written; present exactly when it is. */
|
|
90
|
+
supersededReason?: IneffectiveWriteReason;
|
|
91
|
+
/**
|
|
92
|
+
* `missing-store` only: the document that recreates the store. A surface that localizes the
|
|
93
|
+
* remedy needs the content to name, not the writer's English sentence.
|
|
94
|
+
*/
|
|
95
|
+
missingStoreDocument?: string;
|
|
87
96
|
/** Snapshot files retained for this client; -1 when they cannot be inspected. */
|
|
88
97
|
snapshotCount: number;
|
|
89
98
|
/** Pruning is behind, so older (possibly credential-bearing) snapshots remain. */
|
|
@@ -208,6 +217,18 @@ function recordedContribution(
|
|
|
208
217
|
};
|
|
209
218
|
}
|
|
210
219
|
|
|
220
|
+
function observedContributionMatchesRecord(
|
|
221
|
+
observed: ManagedContribution,
|
|
222
|
+
record: OwnershipRecord,
|
|
223
|
+
): boolean {
|
|
224
|
+
if (fingerprint(canonicalContribution(observed)) === record.blockFingerprint) return true;
|
|
225
|
+
if (
|
|
226
|
+
typeof record.semanticBlockFingerprint === "string"
|
|
227
|
+
&& fingerprint(semanticContribution(observed)) === record.semanticBlockFingerprint
|
|
228
|
+
) return true;
|
|
229
|
+
return droidNormalizedContributionMatchesRecord(observed, record);
|
|
230
|
+
}
|
|
231
|
+
|
|
211
232
|
/**
|
|
212
233
|
* Prove that every protected field still matches what OpenCodex wrote.
|
|
213
234
|
*
|
|
@@ -223,13 +244,8 @@ function recordedBlockIsOwned(
|
|
|
223
244
|
): boolean {
|
|
224
245
|
const observed = recordedContribution(doc, record);
|
|
225
246
|
if (!observed) return false;
|
|
226
|
-
if (
|
|
227
|
-
|
|
247
|
+
if (observedContributionMatchesRecord(observed, record)) return true;
|
|
228
248
|
const observedSemanticFingerprint = fingerprint(semanticContribution(observed));
|
|
229
|
-
if (
|
|
230
|
-
typeof record.semanticBlockFingerprint === "string"
|
|
231
|
-
&& observedSemanticFingerprint === record.semanticBlockFingerprint
|
|
232
|
-
) return true;
|
|
233
249
|
|
|
234
250
|
const desiredFingerprint = fingerprint(canonicalContribution(desired));
|
|
235
251
|
if (
|
|
@@ -304,6 +320,12 @@ export function classifyIntegration(input: {
|
|
|
304
320
|
* the wrong way.
|
|
305
321
|
*/
|
|
306
322
|
format?: ConfigFormat;
|
|
323
|
+
/**
|
|
324
|
+
* Whether the file being classified is patched in place, which makes a
|
|
325
|
+
* sibling edit harmless. Like `format`, it belongs to the target; omitted, the
|
|
326
|
+
* client's config-file declaration answers.
|
|
327
|
+
*/
|
|
328
|
+
sourcePreservingYaml?: boolean;
|
|
307
329
|
}): { state: IntegrationState; reason?: StateReason } {
|
|
308
330
|
if (input.fileText !== null && !input.fileIsRegular) {
|
|
309
331
|
return { state: "unsafe", reason: "not-regular-file" };
|
|
@@ -405,12 +427,16 @@ export function classifyIntegration(input: {
|
|
|
405
427
|
&& !isHermesAffinityUpgrade(input.parsed, input.record, input.contribution)) {
|
|
406
428
|
return { state: "conflict", reason: "foreign-edit" };
|
|
407
429
|
}
|
|
408
|
-
if (!INTEGRATION_CLIENTS[clientId].sourcePreservingYaml
|
|
409
|
-
&& fingerprint(input.fileText ?? "") !== input.record.fileFingerprint
|
|
430
|
+
if (!(input.sourcePreservingYaml ?? INTEGRATION_CLIENTS[clientId].sourcePreservingYaml !== undefined)
|
|
431
|
+
&& fingerprint(input.fileText ?? "") !== input.record.fileFingerprint
|
|
432
|
+
&& !droidNormalizedFileMatchesRecord(input.parsed, input.record)) {
|
|
410
433
|
/*
|
|
411
|
-
* The file changed since we wrote it
|
|
412
|
-
* byte-for-byte what we put there
|
|
413
|
-
*
|
|
434
|
+
* The file changed since we wrote it. Every fragment we own is still
|
|
435
|
+
* byte-for-byte what we put there, so this is a sibling edit rather than
|
|
436
|
+
* tampering. Known Droid row normalization is accepted before this branch
|
|
437
|
+
* only when removing its two client fields reproduces the recorded file.
|
|
438
|
+
* Other drift reaches this branch. Apply rewrites the WHOLE document, so
|
|
439
|
+
* for comment-capable formats (yaml,
|
|
414
440
|
* json5, toml) it would drop comments the user wrote next to us: fail
|
|
415
441
|
* closed there. Strict JSON cannot carry comments — a commented file
|
|
416
442
|
* never reaches this branch because parsing already failed — so the only
|
|
@@ -500,14 +526,8 @@ export function buildIntegrationContribution(
|
|
|
500
526
|
|
|
501
527
|
function recordedDroidContributionMatches(document: unknown, record: OwnershipRecord): boolean {
|
|
502
528
|
try {
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
return value === undefined ? [] : [{ path, value }];
|
|
506
|
-
});
|
|
507
|
-
if (fragments.length !== record.fragmentPaths.length) return false;
|
|
508
|
-
const observed: ManagedContribution = { clientId: "droid", fragments };
|
|
509
|
-
return record.semanticBlockFingerprint === fingerprint(semanticContribution(observed))
|
|
510
|
-
|| record.blockFingerprint === fingerprint(canonicalContribution(observed));
|
|
529
|
+
const observed = recordedContribution(document, record);
|
|
530
|
+
return observed !== null && observedContributionMatchesRecord(observed, record);
|
|
511
531
|
} catch {
|
|
512
532
|
return false;
|
|
513
533
|
}
|
|
@@ -655,6 +675,7 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
|
|
|
655
675
|
configPath,
|
|
656
676
|
clientId: input.clientId,
|
|
657
677
|
format: effective.format,
|
|
678
|
+
sourcePreservingYaml: effective.sourcePreservingYaml !== null,
|
|
658
679
|
});
|
|
659
680
|
if (input.clientId === "droid" && record && (state === "current" || state === "stale")) {
|
|
660
681
|
try { assertDroidRecordedSettingsUnambiguous(spec.detectDir(input.env, input.home), parsed, record); }
|
|
@@ -676,7 +697,11 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
|
|
|
676
697
|
* about. A store we are writing needs no notice; the path already names it.
|
|
677
698
|
*/
|
|
678
699
|
...(effective.ineffective && effective.ineffective.store !== configPath
|
|
679
|
-
? {
|
|
700
|
+
? {
|
|
701
|
+
supersededBy: effective.ineffective.store,
|
|
702
|
+
supersededReason: effective.ineffective.why,
|
|
703
|
+
...(effective.ineffective.emptyDocument === undefined ? {} : { missingStoreDocument: effective.ineffective.emptyDocument }),
|
|
704
|
+
}
|
|
680
705
|
: {}),
|
|
681
706
|
...(record ? { appliedAt: record.appliedAt, lastOpId: record.opId } : {}),
|
|
682
707
|
...retention,
|