@bitkyc08/opencodex 2.11.1 → 2.12.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/README.md +1 -1
- package/bin/ocx.mjs +4 -0
- package/gui/dist/assets/index-BNVYzdn0.css +1 -0
- package/gui/dist/assets/index-Cw1_mxO8.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/command-code.ts +94 -5
- package/src/adapters/cursor/live-transport.ts +14 -1
- package/src/adapters/google-tool-schema.ts +80 -15
- package/src/adapters/google.ts +24 -7
- package/src/adapters/openai-chat.ts +233 -305
- package/src/adapters/openai-responses.ts +62 -0
- package/src/adapters/tool-catalog-nudge.ts +2 -11
- package/src/claude/outbound.ts +42 -14
- package/src/cli/claude.ts +61 -9
- package/src/cli/codex-shim-autorestore.ts +2 -0
- package/src/cli/codex-shim-readiness.ts +8 -1
- package/src/cli/config-command.ts +4 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/help.ts +15 -0
- package/src/cli/index.ts +27 -11
- package/src/cli/lab.ts +367 -0
- package/src/cli/provider.ts +9 -1
- package/src/clients/config-export.ts +100 -3
- package/src/codex/app-server-processes.ts +11 -8
- package/src/codex/auth-api.ts +92 -28
- package/src/codex/auth-collision.ts +5 -3
- package/src/codex/catalog/metadata.ts +7 -3
- package/src/codex/catalog/provider-fetch.ts +191 -47
- package/src/codex/catalog.ts +2 -2
- package/src/codex/history-job.ts +83 -18
- package/src/codex/history-migration-guardian.ts +9 -16
- package/src/codex/history-provider.ts +178 -1
- package/src/codex/history-transition.ts +5 -5
- package/src/codex/history-worker.ts +22 -2
- package/src/codex/native-profile-store.ts +1 -1
- package/src/codex/plan.ts +15 -0
- package/src/codex/quota.ts +6 -6
- package/src/codex/routing.ts +3 -3
- package/src/codex/subagent-model-fallback.ts +246 -22
- package/src/codex/user-identity.ts +63 -12
- package/src/config.ts +265 -32
- package/src/images/loop.ts +3 -1
- package/src/images/plan.ts +23 -13
- package/src/integrations/omp-yaml-source.ts +225 -0
- package/src/integrations/ownership.ts +5 -5
- package/src/integrations/state.ts +45 -6
- package/src/integrations/writer.ts +38 -3
- package/src/lab/artifacts/sanitize.ts +586 -0
- package/src/lab/artifacts/secure-fs.ts +475 -0
- package/src/lab/artifacts/store.ts +310 -0
- package/src/lab/conformance/assertion.ts +325 -0
- package/src/lab/conformance/digest.ts +22 -0
- package/src/lab/conformance/executor.ts +741 -0
- package/src/lab/conformance/fixture-provider.ts +27 -0
- package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
- package/src/lab/conformance/harness-budget.ts +47 -0
- package/src/lab/conformance/index.ts +5 -0
- package/src/lab/conformance/jcs.ts +24 -0
- package/src/lab/conformance/json-pointer.ts +39 -0
- package/src/lab/conformance/manifest.ts +180 -0
- package/src/lab/conformance/mcp-stub.ts +179 -0
- package/src/lab/conformance/negative-controls.ts +160 -0
- package/src/lab/conformance/observation.ts +355 -0
- package/src/lab/conformance/runner.ts +57 -0
- package/src/lab/conformance/sse-normalize.ts +59 -0
- package/src/lab/conformance/suite-manifest.ts +78 -0
- package/src/lab/conformance/types.ts +191 -0
- package/src/lab/constants.ts +126 -0
- package/src/lab/digest.ts +64 -0
- package/src/lab/events/errors.ts +9 -0
- package/src/lab/events/limits.ts +117 -0
- package/src/lab/events/types.ts +228 -0
- package/src/lab/events/validate.ts +780 -0
- package/src/lab/index.ts +36 -0
- package/src/lab/ledger/artifact-refs.ts +127 -0
- package/src/lab/ledger/invalidation.ts +136 -0
- package/src/lab/ledger/purge.ts +241 -0
- package/src/lab/ledger/store.ts +263 -0
- package/src/lab/live/credential-lease.ts +53 -0
- package/src/lab/live/destination.ts +155 -0
- package/src/lab/live/executor.ts +311 -0
- package/src/lab/live/inert-tools.ts +56 -0
- package/src/lab/live/manifest.ts +85 -0
- package/src/lab/live/mcp-loopback.ts +57 -0
- package/src/lab/live/runner.ts +19 -0
- package/src/lab/live/sandbox.ts +61 -0
- package/src/lab/live/suite-manifest.ts +41 -0
- package/src/lab/live/transport.ts +116 -0
- package/src/lab/live/types.ts +197 -0
- package/src/lab/observe/from-conformance.ts +344 -0
- package/src/lab/observe/from-live.ts +103 -0
- package/src/lab/paths.ts +65 -0
- package/src/lab/projection/rebuild.ts +477 -0
- package/src/lab/projection/schema.ts +122 -0
- package/src/lab/projection/verdicts.ts +438 -0
- package/src/lab/projection/verification.ts +332 -0
- package/src/lab/query/catalog.ts +72 -0
- package/src/lab/query/connection.ts +107 -0
- package/src/lab/query/constants.ts +4 -0
- package/src/lab/query/cursor.ts +132 -0
- package/src/lab/query/dto-map.ts +277 -0
- package/src/lab/query/errors.ts +22 -0
- package/src/lab/query/index.ts +34 -0
- package/src/lab/query/queries.ts +444 -0
- package/src/lab/query/types.ts +266 -0
- package/src/lab/subject/behavior-fingerprint.ts +77 -0
- package/src/lab/subject/installation-salt.ts +81 -0
- package/src/lab/subject/route-subject.ts +73 -0
- package/src/lib/bounded-body.ts +104 -0
- package/src/lib/lab-live-execution-authority.ts +13 -0
- package/src/lib/lab-live-host.ts +30 -0
- package/src/lib/lab-live-pinned-sender.ts +45 -0
- package/src/lib/pinned-http.ts +36 -7
- package/src/oauth/index.ts +20 -1
- package/src/oauth/local-token-detect.ts +11 -2
- package/src/oauth/login-cli.ts +38 -4
- package/src/providers/alibaba-region-migration.ts +1 -1
- package/src/providers/codex-capacity.ts +7 -4
- package/src/providers/command-code-efforts.ts +5 -2
- package/src/providers/derive.ts +105 -0
- package/src/providers/label.ts +1 -1
- package/src/providers/openai-tiers.ts +59 -3
- package/src/providers/opencode-zen-rate-limit.ts +102 -0
- package/src/providers/quota.ts +67 -21
- package/src/providers/registry.ts +118 -5
- package/src/router.ts +15 -1
- package/src/routing/analytics.ts +8 -7
- package/src/routing/evaluator.ts +51 -5
- package/src/routing/profile.ts +10 -1
- package/src/routing/trace.ts +42 -6
- package/src/server/auth-cors.ts +15 -0
- package/src/server/background-lifecycle.ts +169 -0
- package/src/server/chat-completions.ts +18 -3
- package/src/server/claude-messages.ts +1 -0
- package/src/server/images.ts +78 -11
- package/src/server/index.ts +72 -18
- package/src/server/lifecycle.ts +6 -0
- package/src/server/live.ts +5 -1
- package/src/server/management/agent-settings-routes.ts +41 -24
- package/src/server/management/body.ts +6 -0
- package/src/server/management/config-routes.ts +51 -8
- package/src/server/management/lab-routes.ts +388 -0
- package/src/server/management/logs-usage-routes.ts +19 -1
- package/src/server/management/model-rows.ts +25 -11
- package/src/server/management/native-integration-routes.ts +3 -3
- package/src/server/management/provider-routes.ts +5 -0
- package/src/server/management/shared.ts +8 -1
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management/vision-sidecar-options.ts +125 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/request-decompress.ts +106 -5
- package/src/server/responses/collaboration.ts +4 -1
- package/src/server/responses/core.ts +117 -31
- package/src/server/responses-json-events.ts +50 -12
- package/src/server/search.ts +23 -7
- package/src/server/ws-bridge.ts +3 -1
- package/src/service.ts +40 -20
- package/src/types.ts +74 -0
- package/src/update/job.ts +10 -2
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +9 -0
- package/src/usage/log.ts +9 -9
- package/src/usage/user-cost-overlay-reconciler.ts +313 -0
- package/src/usage/user-cost-overlays.ts +314 -0
- package/src/vision/describe.ts +2 -2
- package/src/vision/eligibility.ts +229 -0
- package/src/vision/index.ts +20 -2
- package/src/web-search/executor.ts +2 -2
- package/src/web-search/index.ts +4 -2
- package/src/web-search/loop.ts +3 -1
- package/src/web-search/parse.ts +72 -3
- package/gui/dist/assets/index-BA1dgo4q.css +0 -1
- package/gui/dist/assets/index-DDZpgzKk.js +0 -70
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source-preserving mutation for the one YAML fragment managed by OMP.
|
|
3
|
+
*
|
|
4
|
+
* The general integration writer operates on parsed documents. Re-rendering a
|
|
5
|
+
* shared YAML file would preserve values but destroy comments and formatting
|
|
6
|
+
* outside `providers.opencodex`. OMP is the only client whose ownership model
|
|
7
|
+
* deliberately permits those unrelated source edits, so its writer replaces
|
|
8
|
+
* or removes only the exact block-style mapping entry it owns.
|
|
9
|
+
*
|
|
10
|
+
* Unsupported or ambiguous YAML returns `null`. The caller treats that as an
|
|
11
|
+
* unsafe refusal instead of falling back to whole-document serialization.
|
|
12
|
+
*/
|
|
13
|
+
import { renderYaml } from "./serialize";
|
|
14
|
+
|
|
15
|
+
interface SourceLine {
|
|
16
|
+
start: number;
|
|
17
|
+
end: number;
|
|
18
|
+
body: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type OmpYamlMutation =
|
|
22
|
+
| { kind: "upsert"; value: unknown }
|
|
23
|
+
| { kind: "remove"; removeEmptyProviders: boolean };
|
|
24
|
+
|
|
25
|
+
function sourceLines(text: string): SourceLine[] {
|
|
26
|
+
const lines: SourceLine[] = [];
|
|
27
|
+
const matcher = /[^\r\n]*(?:\r\n|\n|$)/gu;
|
|
28
|
+
for (const match of text.matchAll(matcher)) {
|
|
29
|
+
const raw = match[0];
|
|
30
|
+
if (raw.length === 0) continue;
|
|
31
|
+
const start = match.index;
|
|
32
|
+
const body = raw.endsWith("\r\n")
|
|
33
|
+
? raw.slice(0, -2)
|
|
34
|
+
: raw.endsWith("\n") ? raw.slice(0, -1) : raw;
|
|
35
|
+
lines.push({ start, end: start + raw.length, body });
|
|
36
|
+
}
|
|
37
|
+
return lines;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function leadingSpaces(line: string): number | null {
|
|
41
|
+
const leading = line.match(/^[ \t]*/u)?.[0] ?? "";
|
|
42
|
+
return leading.includes("\t") ? null : leading.length;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isBlank(line: string): boolean {
|
|
46
|
+
return line.trim().length === 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isComment(line: string): boolean {
|
|
50
|
+
return line.trimStart().startsWith("#");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hasInlineComment(line: string): boolean {
|
|
54
|
+
return line.includes("#");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isPlainBlockKey(line: string, indent: number, key: string): boolean {
|
|
58
|
+
const spaces = leadingSpaces(line);
|
|
59
|
+
if (spaces !== indent) return false;
|
|
60
|
+
const rest = line.slice(indent);
|
|
61
|
+
return new RegExp(`^${key}:[ ]*(?:#.*)?$`, "u").test(rest);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function containerEnd(lines: readonly SourceLine[], start: number, indent: number): number | null {
|
|
65
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
66
|
+
const body = lines[index]!.body;
|
|
67
|
+
const spaces = leadingSpaces(body);
|
|
68
|
+
if (spaces === null) return null;
|
|
69
|
+
if (isBlank(body)) continue;
|
|
70
|
+
if (isComment(body)) {
|
|
71
|
+
if (spaces <= indent) return index;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (spaces <= indent) return index;
|
|
75
|
+
}
|
|
76
|
+
return lines.length;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function childEnd(
|
|
80
|
+
lines: readonly SourceLine[],
|
|
81
|
+
start: number,
|
|
82
|
+
parentEnd: number,
|
|
83
|
+
indent: number,
|
|
84
|
+
): number | null {
|
|
85
|
+
for (let index = start + 1; index < parentEnd; index += 1) {
|
|
86
|
+
const body = lines[index]!.body;
|
|
87
|
+
const spaces = leadingSpaces(body);
|
|
88
|
+
if (spaces === null) return null;
|
|
89
|
+
// Blank lines and same-level comments are conservatively outside the
|
|
90
|
+
// managed entry. Deeper comments would be destroyed by replacement, so
|
|
91
|
+
// refuse rather than guessing whether the user meant to keep them.
|
|
92
|
+
if (isBlank(body)) return index;
|
|
93
|
+
if (isComment(body)) return spaces <= indent ? index : null;
|
|
94
|
+
if (spaces <= indent) return index;
|
|
95
|
+
if (hasInlineComment(body)) return null;
|
|
96
|
+
}
|
|
97
|
+
return parentEnd;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function canonicalValue(value: unknown): unknown {
|
|
101
|
+
if (Array.isArray(value)) return value.map(canonicalValue);
|
|
102
|
+
if (value && typeof value === "object") {
|
|
103
|
+
return Object.fromEntries(
|
|
104
|
+
Object.entries(value as Record<string, unknown>)
|
|
105
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
106
|
+
.map(([key, child]) => [key, canonicalValue(child)]),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function semanticallyMatches(text: string, expected: unknown): boolean {
|
|
113
|
+
try {
|
|
114
|
+
const parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text);
|
|
115
|
+
return JSON.stringify(canonicalValue(parsed))
|
|
116
|
+
=== JSON.stringify(canonicalValue(expected));
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function lineEnding(text: string): "\n" | "\r\n" {
|
|
123
|
+
return text.includes("\r\n") ? "\r\n" : "\n";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function renderedEntry(value: unknown, indent: number, eol: "\n" | "\r\n"): string {
|
|
127
|
+
return renderYaml({ opencodex: value }, indent).replaceAll("\n", eol);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Patch `providers.opencodex` while preserving every byte outside that entry.
|
|
132
|
+
* The returned text is parsed and compared with `expected` before it is
|
|
133
|
+
* accepted, so a scanner mistake fails closed rather than writing bad YAML.
|
|
134
|
+
*/
|
|
135
|
+
export function patchOmpYamlSource(
|
|
136
|
+
text: string,
|
|
137
|
+
mutation: OmpYamlMutation,
|
|
138
|
+
expected: unknown,
|
|
139
|
+
): string | null {
|
|
140
|
+
const lines = sourceLines(text);
|
|
141
|
+
const eol = lineEnding(text);
|
|
142
|
+
const providerIndexes = lines
|
|
143
|
+
.map((line, index) => isPlainBlockKey(line.body, 0, "providers") ? index : -1)
|
|
144
|
+
.filter(index => index >= 0);
|
|
145
|
+
|
|
146
|
+
if (providerIndexes.length === 0) {
|
|
147
|
+
if (mutation.kind === "remove") return null;
|
|
148
|
+
const separator = text.length === 0 || text.endsWith("\n") ? "" : eol;
|
|
149
|
+
const patched = `${text}${separator}providers:${eol}${renderedEntry(mutation.value, 2, eol)}`;
|
|
150
|
+
return semanticallyMatches(patched, expected) ? patched : null;
|
|
151
|
+
}
|
|
152
|
+
if (providerIndexes.length !== 1) return null;
|
|
153
|
+
|
|
154
|
+
const providersIndex = providerIndexes[0]!;
|
|
155
|
+
const providersEnd = containerEnd(lines, providersIndex, 0);
|
|
156
|
+
if (providersEnd === null) return null;
|
|
157
|
+
|
|
158
|
+
let inferredIndent: number | null = null;
|
|
159
|
+
for (let index = providersIndex + 1; index < providersEnd; index += 1) {
|
|
160
|
+
const body = lines[index]!.body;
|
|
161
|
+
const spaces = leadingSpaces(body);
|
|
162
|
+
if (spaces === null) return null;
|
|
163
|
+
if (!isBlank(body) && !isComment(body) && spaces > 0) {
|
|
164
|
+
inferredIndent = inferredIndent === null ? spaces : Math.min(inferredIndent, spaces);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const childIndexes = inferredIndent === null
|
|
168
|
+
? []
|
|
169
|
+
: lines.slice(providersIndex + 1, providersEnd)
|
|
170
|
+
.map((line, offset) => (
|
|
171
|
+
isPlainBlockKey(line.body, inferredIndent!, "opencodex")
|
|
172
|
+
? providersIndex + 1 + offset
|
|
173
|
+
: -1
|
|
174
|
+
))
|
|
175
|
+
.filter(index => index >= 0);
|
|
176
|
+
if (childIndexes.length > 1) return null;
|
|
177
|
+
|
|
178
|
+
const childIndex = childIndexes[0];
|
|
179
|
+
if (childIndex === undefined) {
|
|
180
|
+
if (mutation.kind === "remove") return null;
|
|
181
|
+
const indent = inferredIndent ?? 2;
|
|
182
|
+
const insertAt = providersEnd < lines.length ? lines[providersEnd]!.start : text.length;
|
|
183
|
+
const prefix = insertAt > 0 && !text.slice(0, insertAt).endsWith("\n") ? eol : "";
|
|
184
|
+
const patched = `${text.slice(0, insertAt)}${prefix}${renderedEntry(mutation.value, indent, eol)}${text.slice(insertAt)}`;
|
|
185
|
+
return semanticallyMatches(patched, expected) ? patched : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const childIndent = leadingSpaces(lines[childIndex]!.body);
|
|
189
|
+
if (childIndent === null || childIndent <= 0) return null;
|
|
190
|
+
if (hasInlineComment(lines[childIndex]!.body)) return null;
|
|
191
|
+
const endIndex = childEnd(lines, childIndex, providersEnd, childIndent);
|
|
192
|
+
if (endIndex === null) return null;
|
|
193
|
+
const startOffset = lines[childIndex]!.start;
|
|
194
|
+
const endOffset = endIndex < lines.length ? lines[endIndex]!.start : text.length;
|
|
195
|
+
|
|
196
|
+
if (mutation.kind === "upsert") {
|
|
197
|
+
const patched = `${text.slice(0, startOffset)}${renderedEntry(mutation.value, childIndent, eol)}${text.slice(endOffset)}`;
|
|
198
|
+
return semanticallyMatches(patched, expected) ? patched : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let patched = `${text.slice(0, startOffset)}${text.slice(endOffset)}`;
|
|
202
|
+
if (mutation.removeEmptyProviders) {
|
|
203
|
+
const remaining = Bun.YAML.parse(patched) as { providers?: unknown } | null;
|
|
204
|
+
if (remaining && Object.hasOwn(remaining, "providers")) {
|
|
205
|
+
const provider = remaining.providers;
|
|
206
|
+
const empty = provider === null || (
|
|
207
|
+
provider && typeof provider === "object" && !Array.isArray(provider)
|
|
208
|
+
&& Object.keys(provider as Record<string, unknown>).length === 0
|
|
209
|
+
);
|
|
210
|
+
if (!empty) return semanticallyMatches(patched, expected) ? patched : null;
|
|
211
|
+
if (hasInlineComment(lines[providersIndex]!.body)) return null;
|
|
212
|
+
|
|
213
|
+
const providerStart = lines[providersIndex]!.start;
|
|
214
|
+
// Removing a container we created is safe only when nothing but our
|
|
215
|
+
// entry occupied its source range. Comments or blank formatting make
|
|
216
|
+
// that range user-owned and therefore ambiguous.
|
|
217
|
+
for (let index = providersIndex + 1; index < providersEnd; index += 1) {
|
|
218
|
+
if (index >= childIndex && index < endIndex) continue;
|
|
219
|
+
if (lines[index]!.body.length > 0) return null;
|
|
220
|
+
}
|
|
221
|
+
patched = `${text.slice(0, providerStart)}${text.slice(endOffset)}`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return semanticallyMatches(patched, expected) ? patched : null;
|
|
225
|
+
}
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* What opencodex remembers about a client between operations.
|
|
3
3
|
*
|
|
4
4
|
* Two hashes, because the two questions are genuinely independent: the FILE
|
|
5
|
-
* hash
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* hash identifies the exact result for restore and for clients whose writer
|
|
6
|
+
* re-serializes the whole document. The BLOCK hash identifies OMP's surgically
|
|
7
|
+
* patched fragment and detects catalog drift. One hash cannot safely answer
|
|
8
|
+
* both questions in a shared client config.
|
|
9
9
|
*
|
|
10
10
|
* Design of record: devlog/_fin/260802_client_toggle_api/021 §2.
|
|
11
11
|
*/
|
|
@@ -37,7 +37,7 @@ export function canonicalContribution(contribution: ManagedContribution): string
|
|
|
37
37
|
export interface OwnershipRecord {
|
|
38
38
|
clientId: IntegrationClientId;
|
|
39
39
|
configPath: string;
|
|
40
|
-
/** Hash of the WHOLE file as we left it —
|
|
40
|
+
/** Hash of the WHOLE file as we left it — protects whole-file restore. */
|
|
41
41
|
fileFingerprint: string;
|
|
42
42
|
/** Hash of our contribution — detects catalog/port drift. */
|
|
43
43
|
blockFingerprint: string;
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* "What is on disk, and did we put it there?"
|
|
3
3
|
*
|
|
4
4
|
* The classifier is deliberately ordered, and the order is load-bearing: an
|
|
5
|
-
* unreadable file can never be reported as absent, and
|
|
6
|
-
* be reported as ordinary drift. Getting that wrong would let
|
|
7
|
-
* a user's own edits.
|
|
5
|
+
* unreadable file can never be reported as absent, and an edit to a fragment
|
|
6
|
+
* we own can never be reported as ordinary drift. Getting that wrong would let
|
|
7
|
+
* `disable` delete a user's own edits.
|
|
8
8
|
*
|
|
9
9
|
* Design of record: devlog/_fin/260802_client_toggle_api/021 §3.
|
|
10
10
|
*/
|
|
@@ -100,8 +100,43 @@ export function blockedContainerPath(
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
103
|
+
* Fingerprint the recorded fragments as they appear in the document now.
|
|
104
|
+
*
|
|
105
|
+
* The record intentionally names every path we own. Comparing just those
|
|
106
|
+
* values lets another integration or a user add a sibling without blocking a
|
|
107
|
+
* later refresh, while a change inside our block still fails closed.
|
|
108
|
+
*/
|
|
109
|
+
function recordedFragmentFingerprint(
|
|
110
|
+
doc: unknown,
|
|
111
|
+
record: OwnershipRecord,
|
|
112
|
+
): string | null {
|
|
113
|
+
if (
|
|
114
|
+
!Array.isArray(record.fragmentPaths)
|
|
115
|
+
|| record.fragmentPaths.length === 0
|
|
116
|
+
|| !record.fragmentPaths.every(path => (
|
|
117
|
+
Array.isArray(path)
|
|
118
|
+
&& path.length > 0
|
|
119
|
+
&& path.every(key => typeof key === "string")
|
|
120
|
+
))
|
|
121
|
+
) return null;
|
|
122
|
+
const fragments = [];
|
|
123
|
+
for (const path of record.fragmentPaths) {
|
|
124
|
+
const value = readPath(doc, path);
|
|
125
|
+
if (value === undefined) return null;
|
|
126
|
+
fragments.push({ path, value });
|
|
127
|
+
}
|
|
128
|
+
return fingerprint(canonicalContribution({
|
|
129
|
+
clientId: record.clientId,
|
|
130
|
+
fragments,
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The two-axis rule: the recorded bytes or fragments prove nobody changed
|
|
136
|
+
* what we may rewrite, and the contribution hash proves our catalog has not
|
|
137
|
+
* moved on. OMP is the sole fragment-scoped client because its writer patches
|
|
138
|
+
* only `providers.opencodex`; every whole-document serializer retains the
|
|
139
|
+
* whole-file fingerprint guard.
|
|
105
140
|
*/
|
|
106
141
|
export function classifyIntegration(input: {
|
|
107
142
|
fileText: string | null;
|
|
@@ -144,7 +179,11 @@ export function classifyIntegration(input: {
|
|
|
144
179
|
if (input.configPath !== undefined && input.record.configPath !== input.configPath) {
|
|
145
180
|
return { state: "conflict", reason: "unowned-key" };
|
|
146
181
|
}
|
|
147
|
-
|
|
182
|
+
const clientId = input.clientId ?? input.record.clientId;
|
|
183
|
+
if (clientId !== "omp" && fingerprint(input.fileText ?? "") !== input.record.fileFingerprint) {
|
|
184
|
+
return { state: "conflict", reason: "foreign-edit" };
|
|
185
|
+
}
|
|
186
|
+
if (recordedFragmentFingerprint(input.parsed, input.record) !== input.record.blockFingerprint) {
|
|
148
187
|
return { state: "conflict", reason: "foreign-edit" };
|
|
149
188
|
}
|
|
150
189
|
return input.record.blockFingerprint === fingerprint(canonicalContribution(input.contribution))
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Design of record: devlog/_fin/260802_client_toggle_api/030 and 031.
|
|
11
11
|
*/
|
|
12
12
|
import { dirname } from "node:path";
|
|
13
|
-
import { EXPORT_CLIENTS, type ExportModel } from "../clients/config-export";
|
|
13
|
+
import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
|
|
14
14
|
import { isLoopbackHostname } from "../codex/inject";
|
|
15
15
|
import type { OcxConfig } from "../types";
|
|
16
16
|
import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
|
|
@@ -23,6 +23,7 @@ import { serializeDocument, UnserializableValueError } from "./serialize";
|
|
|
23
23
|
import { ClientPathError } from "../clients/config-export";
|
|
24
24
|
import { matchesOperationResult, newOpId, type JournalEntry } from "./journal";
|
|
25
25
|
import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
|
|
26
|
+
import { patchOmpYamlSource } from "./omp-yaml-source";
|
|
26
27
|
|
|
27
28
|
export type RefusalReason =
|
|
28
29
|
| "not_installed"
|
|
@@ -168,6 +169,15 @@ function snapshotAbsPath(store: IntegrationStateStore, entry: JournalEntry): str
|
|
|
168
169
|
return snapshot.kind === "stored" ? snapshot.path : undefined;
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
function ompFragmentValue(contribution: ManagedContribution): unknown | undefined {
|
|
173
|
+
const fragment = contribution.fragments.find(item => (
|
|
174
|
+
item.path.length === 2
|
|
175
|
+
&& item.path[0] === "providers"
|
|
176
|
+
&& item.path[1] === "opencodex"
|
|
177
|
+
));
|
|
178
|
+
return fragment?.value;
|
|
179
|
+
}
|
|
180
|
+
|
|
171
181
|
/** Shared preflight: detect, gate, read, parse and classify. */
|
|
172
182
|
function preflight(input: IntegrationWriteInput) {
|
|
173
183
|
const store = input.store ?? createIntegrationStateStore();
|
|
@@ -280,9 +290,22 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome {
|
|
|
280
290
|
* user as a 500 with no path and no advice; it is a refusal like any other,
|
|
281
291
|
* and the file is untouched because this happens before any write.
|
|
282
292
|
*/
|
|
293
|
+
const nextDocument = mergeContribution(base, contribution);
|
|
283
294
|
let text: string;
|
|
284
295
|
try {
|
|
285
|
-
|
|
296
|
+
if (clientId === "omp" && before !== null) {
|
|
297
|
+
const value = ompFragmentValue(contribution);
|
|
298
|
+
const patched = value === undefined
|
|
299
|
+
? null
|
|
300
|
+
: patchOmpYamlSource(before, { kind: "upsert", value }, nextDocument);
|
|
301
|
+
if (patched === null) {
|
|
302
|
+
return refuse(clientId, "unsafe", "unsafe",
|
|
303
|
+
`${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so it was left alone`);
|
|
304
|
+
}
|
|
305
|
+
text = patched;
|
|
306
|
+
} else {
|
|
307
|
+
text = serializeDocument(nextDocument, exportSpec.format);
|
|
308
|
+
}
|
|
286
309
|
} catch (error) {
|
|
287
310
|
if (!(error instanceof UnserializableValueError)) throw error;
|
|
288
311
|
return refuse(clientId, "unsafe", "unsafe",
|
|
@@ -357,7 +380,19 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
|
|
|
357
380
|
}
|
|
358
381
|
let text: string;
|
|
359
382
|
try {
|
|
360
|
-
|
|
383
|
+
if (clientId === "omp" && before !== null) {
|
|
384
|
+
const patched = patchOmpYamlSource(before, {
|
|
385
|
+
kind: "remove",
|
|
386
|
+
removeEmptyProviders: record!.createdContainers?.includes("providers") === true,
|
|
387
|
+
}, doc);
|
|
388
|
+
if (patched === null) {
|
|
389
|
+
return refuse(clientId, "unsafe", "unsafe",
|
|
390
|
+
`${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so nothing was removed`);
|
|
391
|
+
}
|
|
392
|
+
text = patched;
|
|
393
|
+
} else {
|
|
394
|
+
text = serializeDocument(doc, exportSpec.format);
|
|
395
|
+
}
|
|
361
396
|
} catch (error) {
|
|
362
397
|
if (!(error instanceof UnserializableValueError)) throw error;
|
|
363
398
|
return refuse(clientId, "unsafe", "unsafe",
|