fix(codex-bridge): close the gaps an independent review found
Server CI and Windows package / verify (push) Successful in 3m23s
Server CI and Windows package / windows-package (push) Successful in 7m9s

- One decision per real turn: a turn starts with a user message after the
  previous final answer; tool steps, messages typed mid-turn, a lone "."
  and compaction keep the combo. Works without prompt_cache_key too.
- The virtual Otomatik name never reaches the gateway (stage off or error
  -> last choice or targets.zor); prefilter.target may not be it.
- finishCheck: at most once per turn; a broken, empty or refused retry
  delivers the already approved first answer.
- Layer window filter: only trusted windows, never the last layer.
- Correction channel only on always-pass targets while checks are on;
  Sesli-Hizli-Zeki stays always-pass by default; a >256 KB SSE frame
  passes unfiltered instead of breaking the stream.
- Turn-start questions wait in the local queue no longer than their
  timeout; decision-turn memory under a lock with refreshed order.
- Dify fetch refuses redirects (the app key never moves host); the
  rules file write retries a Windows sharing violation.
- An unreadable settings file no longer breaks every request.
- TypeScript accepts only https:// URLs the bridge accepts;
  finishCheck.timeoutSeconds (unused) removed.

Tests: 310 bridge tests, 36 electron tests; side-bridge live run for the
turn, prefilter (incl. decision timeout) and finish-check paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
yilsemandClaude Opus 5.5 committed 2026-10-14 08:14:38 +03:00
1 parent 7c4ef18df0
commit a7ba300448
10 files changed
+353 -91

No files matched your search

@@ -23,7 +23,7 @@ export interface StagedTransitionSettings {
thresholds: { zor: number; arastirma: number }
targets: { basit: string; zor: string; arastirma: string }
}
finishCheck: { enabled: boolean; question: string; threshold: number; note: string; timeoutSeconds: number }
finishCheck: { enabled: boolean; question: string; threshold: number; note: string }
verifierCheck: { enabled: boolean; question: string; threshold: number }
policy: { source: 'local' | 'dify'; endpoint: string; refreshMinutes: number }
}
@@ -106,5 +106,12 @@ describe('staged transition settings', () => {
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, endpoint: 'http://dify.example' } })).toThrow()
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, source: 'web' } })).toThrow()
expect(() => validateStagedTransition({ ...base, watchModels: ['a', 'a'] })).toThrow()
for (const endpoint of ['https:dify.example/v1', 'https:/dify.example', 'https:///dify.example', 'ftp://dify.example']) {
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, endpoint } })).toThrow()
expect(() => validateStagedTransition({ ...base, endpoint })).toThrow()
}
expect(() =>
validateStagedTransition({ ...base, prefilter: { ...base.prefilter, target: base.autoRoute.model } })
).toThrow()
})
})
+14 -13
View File
@@ -53,13 +53,21 @@ const probability = (n: unknown): n is number => typeof n === 'number' && Number
const integer = (n: unknown, min: number, max: number): n is number =>
typeof n === 'number' && Number.isInteger(n) && n >= min && n <= max
// Same acceptance as the bridge (urllib.parse.urlsplit): the literal https:// prefix and a host. WHATWG URL alone
// also accepts "https:host" and "https:/host", which the bridge rejects.
function https(value: unknown): value is string {
if (!text(value, 2048)) {
if (!text(value, 2048) || !/^https:\/\/[^/?#\s]/i.test(value.trim())) {
return false
}
const url = new URL(value)
let url: URL
return url.protocol === 'https:' && !url.username && !url.password && !url.hash
try {
url = new URL(value.trim())
} catch {
return false
}
return url.protocol === 'https:' && Boolean(url.hostname) && !url.username && !url.password && !url.hash
}
export function validateStagedTransition(input: unknown): StagedTransitionSettings {
@@ -160,7 +168,7 @@ export function validateStagedTransition(input: unknown): StagedTransitionSettin
throw new Error('Invalid automatic routing')
}
if (Object.values(a.targets).some(t => t.trim() === a.model.trim())) {
if (Object.values(a.targets).some(t => t.trim() === a.model.trim()) || p.target.trim() === a.model.trim()) {
throw new Error('Automatic routing cannot target itself')
}
@@ -170,8 +178,7 @@ export function validateStagedTransition(input: unknown): StagedTransitionSettin
typeof f?.enabled !== 'boolean' ||
!text(f.question) ||
!probability(f.threshold) ||
!text(f.note, 4000) ||
!integer(f.timeoutSeconds, 1, 60)
!text(f.note, 4000)
) {
throw new Error('Invalid finish check')
}
@@ -214,13 +221,7 @@ export function validateStagedTransition(input: unknown): StagedTransitionSettin
thresholds: { zor: a.thresholds.zor, arastirma: a.thresholds.arastirma },
targets: { basit: a.targets.basit.trim(), zor: a.targets.zor.trim(), arastirma: a.targets.arastirma.trim() }
},
finishCheck: {
enabled: f.enabled,
question: f.question,
threshold: f.threshold,
note: f.note,
timeoutSeconds: f.timeoutSeconds
},
finishCheck: { enabled: f.enabled, question: f.question, threshold: f.threshold, note: f.note },
verifierCheck: { enabled: c.enabled, question: c.question, threshold: c.threshold },
policy: { source: pol.source, endpoint: pol.endpoint.trim(), refreshMinutes: pol.refreshMinutes }
}