Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 123 additions & 4 deletions src/integrations/omp-yaml-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,20 @@ interface MissingEntry {
insertAt: number;
}

type LocatedPath = { kind: "existing"; entry: LocatedEntry } | { kind: "missing"; entry: MissingEntry };
interface ReplaceLineEntry {
lines: readonly SourceLine[];
index: number;
indent: number;
missingDepth: number;
}

type LocatedPath =
| { kind: "existing"; entry: LocatedEntry }
| { kind: "missing"; entry: MissingEntry }
// `key: {}` — an empty inline map the block-key scanner cannot see (#4260).
| { kind: "replace-line"; entry: ReplaceLineEntry }
// A populated flow container. Still refused, but nameable as its own cause.
| { kind: "unsupported-style" };

export type YamlFragmentMutation =
| { kind: "upsert"; value: unknown }
Expand Down Expand Up @@ -82,6 +95,61 @@ function isPlainBlockKey(line: string, indent: number, key: string): boolean {
return new RegExp(`^${regexpEscape(key)}:[ ]*(?:#.*)?$`, "u").test(rest);
}

/** The inline value written after `key:` on this line, or null if the key is not here. */
function inlineValueAfterKey(line: string, indent: number, key: string): string | null {
const spaces = leadingSpaces(line);
if (spaces !== indent) return null;
const rest = line.slice(indent);
const head = `${key}:`;
// Compared as text, not as a pattern: a path segment is arbitrary user data,
// and brace escaping inside a `u`-flag regex is its own hazard.
if (!rest.startsWith(head)) return null;
return rest.slice(head.length).trim();
}

/** Exactly `key: {}` (any inner spacing) — an empty inline map, no inline comment. */
function isEmptyInlineMapKey(line: string, indent: number, key: string): boolean {
const value = inlineValueAfterKey(line, indent, key);
if (value === null) return false;
return value.startsWith("{") && value.endsWith("}") && value.slice(1, -1).trim().length === 0;
}

/** `key: { ... }` or `key: [ ... ]` on one line: content we would have to re-render. */
function isPopulatedInlineFlowKey(line: string, indent: number, key: string): boolean {
const value = inlineValueAfterKey(line, indent, key);
if (value === null) return false;
if (!value.startsWith("{") && !value.startsWith("[")) return false;
return !isEmptyInlineMapKey(line, indent, key);
}

/**
* A plain block key whose first child opens a flow collection:
*
* providers:
* { native: { ... } }
*
* DSH writes this shape itself. The walk passes straight through it — the key
* line is a plain block key and `containerEnd` does not stop at `}` — so the
* refusal used to surface only as a failed re-parse at the very end and got
* reported as a comment or formatting problem that was not there (#4260).
*/
function firstChildOpensFlow(
lines: readonly SourceLine[],
start: number,
end: number,
parentIndent: number,
): boolean {
for (let index = start + 1; index < end; index += 1) {
const body = lines[index]!.body;
if (isBlank(body) || isComment(body)) continue;
const spaces = leadingSpaces(body);
if (spaces === null || spaces <= parentIndent) continue;
const trimmed = body.trimStart();
return trimmed.startsWith("{") || trimmed.startsWith("[");
}
return false;
}

function containerEnd(lines: readonly SourceLine[], start: number, indent: number): number | null {
for (let index = start + 1; index < lines.length; index += 1) {
const body = lines[index]!.body;
Expand Down Expand Up @@ -193,9 +261,24 @@ function locatePath(text: string, parsed: unknown, path: readonly string[]): Loc
if (matches.length > 1) return null;
prefix.push(path[depth]!);
if (matches.length === 0) {
const seen = readPath(parsed, prefix);
// An empty inline map is the one flow shape we can adopt: rewriting that
// single line into block form adds our subtree and re-renders nothing the
// user wrote, because there is nothing in it (#4260).
const inline: number[] = [];
const populatedFlow: number[] = [];
for (let index = rangeStart; index < rangeEnd; index += 1) {
const body = lines[index]!.body;
if (isEmptyInlineMapKey(body, indent, path[depth]!)) inline.push(index);
else if (isPopulatedInlineFlowKey(body, indent, path[depth]!)) populatedFlow.push(index);
}
if (inline.length === 1 && isPlainRecord(seen) && Object.keys(seen).length === 0) {
return { kind: "replace-line", entry: { lines, index: inline[0]!, indent, missingDepth: depth } };
}
if (populatedFlow.length === 1 && seen !== undefined) return { kind: "unsupported-style" };
// The parser saw this key through syntax we do not patch (quoted/flow,
// merge aliases, or an ambiguous indentation shape).
if (readPath(parsed, prefix) !== undefined) return null;
if (seen !== undefined) return null;
const insertAt = rangeEnd < lines.length ? lines[rangeEnd]!.start : text.length;
return { kind: "missing", entry: { lines, missingDepth: depth, indent, insertAt } };
}
Expand All @@ -209,7 +292,20 @@ function locatePath(text: string, parsed: unknown, path: readonly string[]): Loc
if (leafEnd === null) return null;
return { kind: "existing", entry: { lines, index, indent, endIndex: leafEnd } };
}
if (!isPlainRecord(readPath(parsed, prefix))) return null;
const container = readPath(parsed, prefix);
// `key:` with no children parses as null. The key line matched, so the
// missing-key branch above never runs, and `isPlainRecord(null)` is false —
// so an empty container used to refuse the whole document (#4260). Insert
// our subtree as its first child instead.
if (container === null) {
const insertAt = end < lines.length ? lines[end]!.start : text.length;
return {
kind: "missing",
entry: { lines, missingDepth: depth + 1, indent: indent + 2, insertAt },
};
}
if (!isPlainRecord(container)) return null;
if (firstChildOpensFlow(lines, index, end, indent)) return { kind: "unsupported-style" };
rangeStart = index + 1;
rangeEnd = end;
parentIndent = indent;
Expand Down Expand Up @@ -241,7 +337,7 @@ function upsertSource(
value: unknown,
): string | null {
const located = locatePath(text, parsed, path);
if (located === null) return null;
if (located === null || located.kind === "unsupported-style") return null;
const eol = lineEnding(text);
if (located.kind === "existing") {
const { lines, index, indent, endIndex } = located.entry;
Expand All @@ -250,6 +346,13 @@ function upsertSource(
const candidate = `${text.slice(0, startOffset)}${rendered({ [path[path.length - 1]!]: value }, indent, eol)}${text.slice(endOffset)}`;
return preserveFinalNewline(candidate, text, eol);
}
if (located.kind === "replace-line") {
const { lines, index, indent, missingDepth } = located.entry;
const startOffset = lines[index]!.start;
const endOffset = index + 1 < lines.length ? lines[index + 1]!.start : text.length;
const insertion = rendered(nestedValue(path.slice(missingDepth), value), indent, eol);
return preserveFinalNewline(`${text.slice(0, startOffset)}${insertion}${text.slice(endOffset)}`, text, eol);
}

const { missingDepth, indent, insertAt } = located.entry;
const prefix = insertAt > 0 && !text.slice(0, insertAt).endsWith("\n") ? eol : "";
Expand Down Expand Up @@ -338,6 +441,22 @@ export function patchYamlFragmentSource(
return patched !== null && semanticallyMatches(patched, expected) ? patched : null;
}

/**
* True when a refusal on this path is caused by a flow-style container rather
* than by comments or formatting we would have to re-render. DSH writes that
* shape itself, so naming it is the difference between an actionable message
* and one that sends the user hunting for a comment that is not there (#4260).
*/
export function yamlFragmentUnsupportedStyle(text: string, path: readonly string[]): boolean {
let parsed: unknown;
try {
parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text);
} catch {
return false;
}
return locatePath(text, parsed, path)?.kind === "unsupported-style";
}

/** Backward-compatible OMP wrapper around the generic path patcher. */
export function patchOmpYamlSource(
text: string,
Expand Down
30 changes: 26 additions & 4 deletions src/integrations/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,29 @@ import { serializeDocument, UnserializableValueError } from "./serialize";
import { ClientPathError } from "../clients/config-export";
import { matchesOperationResult, newOpId, type JournalEntry } from "./journal";
import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
import { patchYamlFragmentSource, sourcePrunableYamlContainers } from "./omp-yaml-source";
import {
patchYamlFragmentSource,
sourcePrunableYamlContainers,
yamlFragmentUnsupportedStyle,
} from "./omp-yaml-source";

/**
* "comments or formatting" used to be the only refusal this path could report.
* For a flow-style container that names a cause which is not in the file, and
* DSH writes that shape itself, so the misdirection was routine rather than
* exotic: users went looking for a comment that was never there (#4260).
*/
function yamlRefusalReason(
source: string,
path: readonly string[],
configPath: string,
outcome: string,
): string {
if (yamlFragmentUnsupportedStyle(source, path)) {
return `${configPath} writes ${path.join(".")} as a flow mapping or sequence, a YAML style opencodex will not re-render, so ${outcome}`;
}
return `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so ${outcome}`;
}
import { withIntegrationWriterLock, type IntegrationWriterLockSeams } from "./writer-lock";

export type RefusalReason =
Expand Down Expand Up @@ -399,7 +421,7 @@ function applyOrRefreshIntegration(
);
if (patched === null) {
return refuse(clientId, "unsafe", "unsafe",
`${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so it was left alone`);
yamlRefusalReason(before, spec.sourcePreservingYaml.path, configPath, "it was left alone"));
}
text = patched;
} else {
Expand Down Expand Up @@ -536,7 +558,7 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
: recordedCreated;
if (prunableCreated === null) {
return refuse(clientId, "unsafe", "unsafe",
`${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so nothing was removed`);
yamlRefusalReason(before ?? "", spec.sourcePreservingYaml!.path, configPath, "nothing was removed"));
}
let doc: unknown;
let removed: boolean;
Expand All @@ -559,7 +581,7 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
}, doc);
if (patched === null) {
return refuse(clientId, "unsafe", "unsafe",
`${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so nothing was removed`);
yamlRefusalReason(before, spec.sourcePreservingYaml.path, configPath, "nothing was removed"));
}
text = patched;
} else {
Expand Down
9 changes: 8 additions & 1 deletion src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1994,7 +1994,14 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
// 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API
// evidence from ai.google.dev does not establish Vertex publisher availability.
{ id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] },
{ id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
// Antigravity discovers models with a POST to the CCA `:fetchAvailableModels` RPC, which
// `buildModelsRequest` already built by hand. Declaring it here changes no request URL — the
// relative path resolves to the same destination — but it lets `isRegistryModelDiscoveryUrl`
// prove that URL, which is what admits a Clash/Surge/Mihomo TUN fake-IP answer (#4261). The
// path must stay RELATIVE: this row sets `allowBaseUrlOverride`, and an absolute `url` would
// retarget a user's custom base back to Google. A leading `./` is required because a bare
// `v1internal:` reads as a URL scheme and `providerModelDiscoverySpecError` rejects it.
{ id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"], modelDiscovery: { path: "./v1internal:fetchAvailableModels" } },
{ id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" },
{ id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
{ id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
Expand Down
Loading
Loading