Skip to content

[WRONG BRANCH] feat(shadow-call): per-source-model replacement mapping (Plan B) - #1

Closed
yorkane wants to merge 298 commits into
mainfrom
codex/shadow-call-per-source-modelmap
Closed

[WRONG BRANCH] feat(shadow-call): per-source-model replacement mapping (Plan B)#1
yorkane wants to merge 298 commits into
mainfrom
codex/shadow-call-per-source-modelmap

Conversation

@yorkane

@yorkane yorkane commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Summary

Expands the shadow-call intercept from a single replacement target to per-source-model replacement mapping (Plan B), so each ChatGPT-native helper model (luna / sol / terra / 5.5 / 5.4-mini) can route to a different third-party model.

Background

The previous design had one shared shadowCallIntercept.model — every intercepted source model was rewritten to the same target. Default source coverage was just gpt-5.6-luna.

Changes

Defaults expanded — the default sourceModels list now covers the full ChatGPT-native helper lineup:

gpt-5.6-luna
gpt-5.6-sol
gpt-5.6-terra
gpt-5.5
gpt-5.4-mini

Per-source mapping — new shadowCallIntercept.modelMap: Record<string, string>:

  • Each key is a source prefix (e.g. "gpt-5.6-luna"), each value is the replacement model id.
  • A source prefix present in modelMap takes precedence over the shared model fallback.
  • A source absent from both modelMap and model is left native (not intercepted).

This lets an operator intercept luna → deepseek, sol → xai/grok, while leaving terra native — all in one config.

Routing (src/server/responses/core.ts) — the route point resolves the replacement via shadowCallReplacementFor(); when no replacement is configured for a source, the request passes through unmodified.

Management API (/api/shadow-call-settings) — GET/PUT now read/write modelMap; every replacement target is validated (resolves to a provider, does not self-intersect).

Migrations — legacy OpenAI-multi rewrite and provider-id rewrite both cover modelMap values.

GUI — the Models page renders a per-source replacement dropdown row for each source model when shadow-call is enabled, alongside the existing shared fallback dropdown.

Files changed

Area File
core src/lib/shadow-call.ts
types src/types/config.ts
routing src/server/responses/core.ts, src/server/responses.ts
management API src/server/management/config-routes.ts, src/server/management/shadow-call-validation.ts
migrations src/providers/openai-tiers.ts, src/providers/provider-id-rewrite.ts
GUI gui/src/pages/Models.tsx, gui/src/pages/dashboard-shared.ts, gui/src/pages/models-shared.ts, gui/src/pages/shadow-call-source.ts, gui/src/styles-models-workspace.css
tests tests/responses-shadow-intercept.test.ts

Backward compatibility

  • Existing configs with only shadowCallIntercept.model (no modelMap) keep working as the shared fallback.
  • modelMap is optional and additive.

Test plan

  • bun run typecheck — 0 errors
  • GUI tsc -b + vite build — passes
  • bun test ./tests/responses-shadow-intercept.test.ts — 23 pass / 0 fail
  • bun test ./tests/provider-id-rewrite.test.ts — 14 pass / 0 fail
  • bun test ./tests/openai-provider-option-migration.test.ts — 31 pass / 0 fail
  • Runtime: gpt-5.6-lunatencent/glm-5.2 end-to-end verified on a live 10101 instance

jun and others added 30 commits September 1, 2026 22:42
Connecting after `ocx start` is the ordinary path: routing is already injected
and the Codex journal is owned by the proxy process. Ownership never transfers
during connect, because writeJournal() refuses to overwrite a journal whose
config is already injected — so the process owner survives into the connected
state.

disconnectClient() read any non-matching owner as a conflict and refused. That
stranded the connection: the operator could not disconnect, and no action
available to them would make the check pass. The artifacts were preserved, so
nothing was lost, but the connected state had no exit.

A process-owned journal records the pre-injection baseline this same tool
wrote, so restoring it is exactly the right unwind. The genuine conflict is a
journal owned by a DIFFERENT client key, where restoring would tear down
another key's routing; that case still refuses, and its existing test still
passes.

Injected routing with no journal at all now gets its own message. Previously it
fell into the ownership error, which named the wrong cause: there is no
recorded baseline to restore, so unwinding would be guessing at the original
config rather than reading it.

The regression test drives the real shape — injected config plus a
process-owned journal — and fails against the previous refusal.
… exchange body

Two defects the review raised on this phase, both on the unauthenticated
pairing path.

Plaintext pairing is removed rather than gated. consumeGuiPairingGrant issued
an "insecure-http-pairing" session over non-loopback HTTP whenever
remoteGui.allowInsecureHttp was true. A reusable grant on plaintext is readable
by anything on the path and the session it mints is reusable, so the flag
recorded a risk the operator could not bound rather than controlling one. A
grant now crosses loopback or authenticated HTTPS only, and no configuration
re-opens it.

The scheme check also moved ahead of the grant lookup. It previously ran after
the grant was found and validated, so a refused exchange still consumed a
single-use code — an attacker who strips TLS termination could spend every code
the operator prints without ever authenticating. Refusing first leaves the
grant intact, which the regression test asserts by replaying the same unspent
grant successfully over HTTPS.

allowInsecureHttp stays in the schema, marked retired. The config schema is
strict, so deleting the key would make an existing config file fail to load
entirely; accepting and ignoring it is the smaller harm.

The exchange body bound now holds against caller-chosen framing. The endpoint
is reachable without a credential, and the pre-check read Content-Length, which
the caller controls: omit the header and Number(null ?? "0") is 0, or send
chunked and there is no header at all. Both passed the check and reached
req.text(), which buffers to completion — an unauthenticated caller decided how
much memory the process spent, and the post-check only measured a string it had
already been forced to hold. The read now stops at limit+1 bytes and cancels
the body rather than draining it. The regression test streams 512 KiB against
the 4 KiB limit with no Content-Length and asserts the server pulled fewer
chunks than were offered; it fails against the previous implementation.

Also repairs the rebase of tests/cli-dispatch.test.ts and
tests/cli-registry.test.ts, where dev and this phase appended different tests
at the same place. Both sides are kept.
…anch

tests/cli-transport-honesty.test.ts flags any runner that awaits a handler and
then returns a literal 0, because that erases a failure the handler recorded in
process.exitCode. The exemption list requires a verified reason rather than a
name, and the connected sync branch has none: handleConnectedSyncCatalogWrite
drives app-server restarts, so a failure there must survive.

Returns process.exitCode like every other runner. Node types it as
number | string; only a numeric code is meaningful to the dispatcher.
Tests that clear a rejected session reach the admin-token fallback, which calls
window.prompt. happy-dom does not implement it, so those tests died on a
TypeError instead of asserting the behavior they were written for. Most tests
in the file never reach the fallback, which is why it stayed hidden.

A null-returning stub is the honest stand-in: it means "the operator dismissed
the prompt", which is the path these tests want. Installed only when prompt is
genuinely missing, so a real implementation is never shadowed.
…uck profile a clean restore

Two rollback defects. Both let disconnect report that native Codex state was
restored while leaving the user worse off than before they connected.

Connect overwrites whatever catalog is already at DEFAULT_CATALOG_PATH. The
pre-connect bytes were snapshotted only into an in-memory `priorCatalog`, which
covers a connect that fails and rolls back in the same run — not a disconnect,
which is a different process on a different day. Durable state recorded only the
remote catalog's fingerprint, so disconnect deleted the remote catalog and left
the user with none. That is the one artifact a rollback cannot reconstruct from
anywhere else: the token can be reissued and the config is journaled, but a
catalog the user brought with them is simply gone.

The snapshot is now persisted on the connection as `priorCatalog` (base64, or
"" for "there genuinely was none") and disconnect writes it back. An older
connection with the field absent keeps the previous removal behavior, since
nothing recorded what to restore. Ownership is still checked first — a catalog
edited since connect belongs to the user and `changed` refuses rather than
overwriting it. The result gains `catalogRestored` so the two outcomes are
distinguishable instead of both reading as `catalogRemoved`.

restoreJournalState set profileRestored = true after a swallowed unlink. When
the original profile was absent, "delete the one we generated" failing meant the
function still reported complete, which deletes the journal — the only record
that the leftover profile is ours. The user is told native state was restored
while our profile stays on disk with nothing left pointing at it. Now only a
verified removal counts, with ENOENT treated as success because the file being
already gone is the outcome the removal wanted.

The catalog fix carries a runtime regression driven red against the previous
behavior. The profile fix is asserted source-level, and the test says why: making
unlink fail requires denying writes on the Codex home, which denies the atomic
config write earlier in the same function, so the branch is unreachable from a
test process. Asserting a fabricated runtime failure would prove less than
asserting the shape.
…he D1/D2 client side

Five defects on this phase, plus the client half of two contract changes the
earlier phases made on the server side.

/api/machine/* is declared. The seven routes this phase adds were absent from
the headless parity table, so tests/cli-headless-parity.test.ts failed on
undeclared endpoints. They are not undocumented: status/clients mirror
`ocx connect status`, sync mirrors `ocx sync`, shim mirrors the client
integration commands, and disconnect mirrors `ocx disconnect`. hub-relay is the
transport those commands select with --management-transport relay rather than a
verb of its own. Declared as one prefix with that mapping written down.

Relay actually works now. connectClient() threw "relay management transport is
not available before Remote Hub Phase 4" — but this IS phase 4, and the machine
listener plus hub-relay both land here. A documented option that always threw
was worse than an undocumented one.

A supervised client comes back after disconnect. scheduleStandaloneRecycle()
skipped its own respawn when OCX_SERVICE=1, correctly leaving the restart to
the supervisor, then exited 0. The real supervisor configs are failure-only
(systemd Restart=on-failure, WinSW onfailure, the Task Scheduler ERRORLEVEL
loop), so a clean exit reads as "finished" and nothing restarts — the client
stayed down until someone noticed. Now exits 1 under supervision, the same
policy the dashboard recycle already uses. launchd KeepAlive was fine either
way.

D1 client side: --allow-insecure-http is removed from the CLI, the connect
options, and the hub client. The hub refuses plaintext pairing outright now, so
keeping the flag would only spend a single-use grant against a certain
rejection. The client checks the same rule locally and refuses before sending.

D2 client side: the catalog fetch is unconditional. /v1/catalog emits no
validator, so If-None-Match had nothing to match and connect's "initial hub
catalog did not include a fresh ETag" check would have failed every connection.
The stored catalogEtag becomes catalogFingerprint — our own hash of the bytes we
wrote. That value was never a cache concern: it answers "is the file on disk
still ours" before disconnect removes it, which needs no server participation.

usage/summary.ts keeps dev's per-attribution filtering and this phase's
per-key entry slice; the comment now says which filter operates at which level,
because they are deliberately different.
… its test

The relayed pairing request went out unauthenticated. submitConnectPairing took
`fetchImpl: typeof fetch = fetch`, and a default parameter binds the global as
it was when the module was evaluated — the unwrapped original, not the wrapper
installApiAuthFetch puts on window.fetch. The relay needs the machine-session
headers that wrapper attaches, so the hub refused the exchange. Resolved at
call time now.

The transport also moves to its own module. One file exported both a transport
function and a component, which react-refresh/only-export-components flags for
good reason; the previous shape carried an eslint-disable instead. The two have
no reason to share a file: the transport is testable without React and the form
has no logic beyond calling it.

tests/connect-pairing.test.ts passed alone and failed in the full GUI run. App
calls installApiAuthFetch() at module scope, so it runs on first import only; a
later test importing App gets the cached module and no install, leaving the
wrapper bound to whichever window imported it first. The test now binds the
wrapper to its own window before mounting, and claude-toggle-race.test.tsx
clears the install latch in afterEach alongside the window it closes. Both are
test isolation rather than product behavior.
… machine plane

Three things a user who never enabled remote hub was paying for.

Every dashboard load fired GET /api/machine/status. Discovery ran
unconditionally and inferred standalone FROM the resulting 404, so the browser
announced the feature's existence on every paint of a plain install. The server
already injects session meta into the served document, so it now states the
runtime role there too and the client reads it instead of asking. A missing tag
reads as standalone, which covers an older server, a separately hosted GUI, and
the Vite dev server — all of which should make no remote-hub request.

The role meta is emitted independently of the session block. A standalone
install never issues a GUI session, so tying the role to session issuance would
have left exactly the case that needs it with nothing to read.

The page body was gated behind targetsSettled, so a standalone user saw
"Discovering local and shared targets…" before their own dashboard. Standalone
now starts settled: there is nothing to discover, so there is nothing to wait
for.

A failed discovery replaced the entire body with a machine-plane error. A slow
or restarting proxy cost a standalone user their dashboard over a plane they
never turned on. It is a banner now; the requests that actually need the machine
plane still report their own failures.

Regressions are driven red against the previous behavior: standalone discovery
makes zero fetches across null/standalone/hub roles, and a client role still
discovers, so the tag narrows who asks rather than removing discovery.
…lly reachable

tests/loopback-listener-admission.test.ts asserted that every non-hub role is
refused with "requires runtimeRole hub", looping over undefined, standalone,
and client. The client case is refused earlier, by the separate rule that a
client role needs a complete client connection block, so the assertion was
testing an ordering that two independent validation rules never promised.

Split rather than loosened. undefined and standalone still assert the exact
ingress message. client asserts only that it is refused, because that is the
guarantee the config actually makes for a role that is incomplete on its own.

A second case closes the gap this would otherwise open: with a COMPLETE client
connection, so the earlier rule no longer fires, the ingress rule is what
refuses. Without it, the weaker client assertion could pass even if the ingress
rule stopped applying to that role entirely.

Also keeps both sides of two rebase conflicts. This phase inserts a management
ingress pairing-exchange test ahead of the plaintext-pairing test that phase 2
rewrote, and structure/01_runtime.md gained a codex-cli-update sentence on dev
and a hub-management-listener clause here; both rows carry both.
jun and others added 15 commits September 4, 2026 10:17
The dashboard 1M toggle writes providerContextCaps.openai = 922000 for the whole native group, but narrowToLimits only RAISED a window for members of NATIVE_GPT56_FAMILY. Astra ships its own 272k/872k pair and was removed from that family so it would stop inheriting the measured 922k clamp, which silently took the opt-in path with it: the toggle moved every other native and left Astra pinned at 272k.

Read the opt-in ceiling per slug instead. The family keeps its measured 922k; a self-described native uses its own maxContextWindow, so the shared 922k lever raises Astra to 872k rather than advertising a ceiling the model does not have. Verified live: with the toggle on, /v1/models reports 922000 for gpt-5.6-sol and 872000 for gpt-6-astra.
…ptin-window

fix(codex): let the 1M opt-in raise gpt-6-astra to its own ceiling
…psed

The 260904 dashboard-minimal roadmap traded working controls for visual quiet, and the result cost real function. Reverts lidge-jun#3382 (sidebar footer), lidge-jun#3387 (dashboard home), lidge-jun#3390 (models catalog), lidge-jun#3395 (usage) and lidge-jun#3399 (the i18n prune that removed those surfaces keys).

What comes back: the labelled sidebar footer rows instead of two rows of unlabelled 28px orbs; the v1/base/v2 subagent surface switch inline on Models, which is a primary control and not an advanced disclosure; the ultra-mode effort controls; the sidecar and memory cards without their closed disclosure; and the Usage active-days card with its heatmap inline rather than behind a 일별 활동 details.

The v1/base/v2 switch is kept in BOTH homes: the revert restores Models, and UltraModeState/UltraModePatch keep multiAgentMode so the Subagents copy added by lidge-jun#3390 still reads and writes /api/v2. Three imports the revert left dangling (Tooltip, IconInfo, TKey) are restored alongside it.

dashboard-tabs.test.ts anchored its .page-tabs CSS lookup on a bare substring, which now matches an earlier descendant rule added after it was written; it reads the base rule at line start instead.
…-dashboard-affordances

revert(gui): restore the dashboard affordances the minimal pass collapsed
Reverts lidge-jun#3391. That change hid the tabs for uninstalled file clients behind a 다른 클라이언트 (N) button outside the tablist, and folded their overview cards under a closed 설치되지 않음 (N) details. Both are back inline: the full tab strip wraps to two rows and every client card is visible, along with the page subtitle and the summary last-change cell it also removed.
…-integrations

revert(gui): show every Integrations client without a disclosure
…t card (lidge-jun#3423)

* fix(codex,gui): restore the plan and ticket badges on the main account card

The main account card showed neither its plan badge nor its reset-credit ticket
badge, while every pool card showed both.

Two independent causes:

The plan badge was simply absent from the main card's badge row.
codex-account-pool-cards.tsx renders it for pool accounts; the main card never
did, even though the server has always sent `plan`.

The ticket badge had a data cause. `poolAccountDto` serializes the merged quota
store, because `commitPoolQuotaResponse` re-reads `getAccountQuota()` after
committing. The main DTO instead serialized the raw WHAM parse result and
reached into the store for `updatedAt` alone, so a `resetCredits` the store had
carried forward never reached the response. `/wham/usage` includes
`rate_limit_reset_credits` only intermittently, so the badge vanished on every
response that omitted it and `CodexTicketBadge` returned null.

The fix carries only `resetCredits`, and not from the store. `__main__` is an
alias: `auth.json` can be swapped for another account while the proxy is down,
and `reconcileMainCodexAccountRuntimeState` cannot purge alias-keyed state on
its first observation after a restart, so a disk-hydrated entry may belong to
the previous login. The carried count is therefore an in-process observation
tagged with the account id it was read from, released only while that identity
still matches. Window fields are untouched, so the monthly-only clearing
behaviour from lidge-jun#382 is unaffected.

Verification: bun test tests/codex-auth-api.test.ts 199 pass / 0 fail; both new
tests were driven red first (removing the DTO fix fails the carry test,
removing the identity guard fails the leak test). typecheck, lint:gui and
privacy:scan exit 0.

* docs(devlog): record the main-card badge parity audit outcome and render evidence

---------

Co-authored-by: jun <jun@lidge.dev>
…420-badges

[WRONG BRANCH] release: promote dev onto main for the main-account badge fix
Add a managementAuthDisabled config flag (loopback binds only) that
bypasses admin-token auth on /api/*, so the dashboard and management API
are directly accessible without credentials in a local single-user
deployment.

Changes:
- src/types/config.ts + src/config.ts: new optional boolean
  managementAuthDisabled (defaults false).
- src/server/management-auth.ts: requireManagementAuth returns null
  immediately when the flag is on and the bind is loopback.
- src/server/management/config-routes.ts: GET/PUT /api/settings
  expose the flag alongside the other settings.
- GUI: SettingsData carries the field; use-dashboard-data adds a
  toggleManagementAuth; dashboard-overview renders a switch panel;
  i18n keys added (en + zh canonical, others as English placeholders).

The flag is guarded to loopback hostname so it can never take effect on a
public listener. 10100 instance remains token-gated.
When managementAuthDisabled is on (loopback only), the management API
origin gate still required an exact match against the process-derived
origin (http://127.0.0.1:PORT). A browser visiting http://localhost:PORT
sends Origin: http://localhost:PORT, which did not match, so every async
dashboard request returned 403 "cross-origin request blocked" even though
auth was disabled.

Relax the origin check under managementAuthDisabled to accept any
loopback origin (localhost/127.0.0.1/::1), not just the exact process
origin. Non-loopback binds are unaffected by the guard.
Add a disableOriginCheck config flag that bypasses all origin/CORS
gates on both the data plane and management API, so an external reverse
proxy (e.g. https://10101-235.ai-t.wtvdev.com) can reach the dashboard and
API without 403 cross-origin blocks.

When enabled, isAllowedRequestOrigin and isAllowedManagementOrigin return
true immediately, accepting any Origin header. Also adds corsAllowOrigins
to the zod schema (it existed only in the type before) so it persists.

Exposes the flag on GET/PUT /api/settings and adds a dashboard toggle
alongside the management-auth switch. i18n keys added for all locales.

This is a security trade-off: only enable when you understand the proxy
is reachable by arbitrary origins. Pair with managementAuthDisabled for
fully open local access, or keep auth on and use corsAllowOrigins for a
narrower allowlist.
The per-source mapping UI was hard to read: the header row listed every
source model in a warning badge followed by one dropdown, which read as
"all models map to this target", and the per-source rows dimmed their
labels at 0.6 opacity so the source-to-replacement association was not
clear.

Restructure the section:
- header row keeps only the title, tooltip, and enable switch
- the shared fallback dropdown moves to its own row explicitly labeled
  "Fallback for unlisted source models" (new models.shadowCallFallback
  i18n key, all locales)
- each per-source row renders as a clear "<source> ->" label at full
  weight (models-shadow-source-name class) next to its replacement select

Also drops the now-unused shadowSourceModelBadge import.
Replace the shared fallback dropdown with user-defined custom mappings:
an operator can type any source model id and map it to a replacement,
not just the five built-in ChatGPT-native slugs.

Backend:
- shadowCallReplacementFor now matches against the configured
  sourceModels instead of the baked-in defaults, so custom source ids
  actually resolve their modelMap entry (previously a custom id fell
  through and was never intercepted).
- PUT /api/shadow-call-settings accepts sourceModels (array of non-empty
  strings) alongside modelMap, persisting custom source registration.

GUI:
- The five built-in source rows are fixed; the shared fallback row is
  removed. A new custom-mapping row (text input for the source id +
  replacement select + Add) appends entries rendered with a delete
  button. Adding also registers the source in sourceModels; deleting
  removes it from both.
# Conflicts:
#	src/server/management-auth.ts
#	src/server/management/config-routes.ts
#	src/server/responses/core.ts
@yorkane
yorkane force-pushed the codex/shadow-call-per-source-modelmap branch from fb0cb27 to 9a18fbb Compare September 4, 2026 09:58
@github-actions github-actions Bot added the enhancement New feature or request label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot changed the title feat(shadow-call): per-source-model replacement mapping (Plan B) [WRONG BRANCH] feat(shadow-call): per-source-model replacement mapping (Plan B) Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

⏳ DRAFT

  • wrong target branch (main); retarget to dev. UI screenshot required.

What to do

  • Retarget this PR to dev — all contributions go to dev.
  • Add a screenshot of the UI change to the PR description.

Its title has been prefixed with [WRONG BRANCH].
This pull request was already a draft. Its draft status will be preserved after every issue above is resolved.

@github-actions
github-actions Bot marked this pull request as draft September 4, 2026 09:58
…calls

Routed providers that hallucinate client tools they were never told
about (e.g. update_plan / collaboration__update_plan behind a shadow
route) previously failed the whole turn with a fail-closed 502 from the
lidge-jun#1700 undeclared-tool guard.

Providers may now declare undeclaredToolAllowlist: matching phantom
calls are silently dropped — streaming announcements, deltas, completions,
and the output_item entries inside response.completed snapshots — so the
turn finishes with only the legitimate tool calls. Names are matched both
bare and namespace-flattened (ns__name). Default off; everything else
keeps the fail-closed behavior.

The field is registered in the provider editor field policy, so it is
editable from the dashboard JSON editor without any GUI change.

Regression coverage: tests/undeclared-tool-phantom-allowlist.test.ts
A routed model that serializes a call to another tool (a JSON object with
quoted keys, or an XML parameter tag) into the freeform exec input hands
the client JavaScript that is illegal at program position: an object
literal with quoted keys opens a block statement with a string label, and
a program-leading parameter tag never parses. The client VM answers with
an opaque Unexpected-token error that weak routed models usually cannot
act on, so the turn silently ends mid-task.

Both bridges (streaming and batch) now replace dead-on-arrival exec
bodies with a directive throw that names the actual mistake and shows the
correct emission shape, so the model sees an actionable error and retries
properly instead of stalling the turn. Detection is deliberately narrow -
only a program-leading quoted-key object or parameter tag - and only for
the top-level functions-namespace exec tool. Every other freeform body,
including all genuine JavaScript, remains byte-identical; the
pass-through wire was checked and is left alone because its bytes go to
non-JS clients.

Regression coverage: tests/exec-envelope-repair.test.ts (detector shapes,
streaming repair + byte-exact twin, batch repair).
…back

A routed model that calls the client tool namespace itself (tools,
collaboration, ...) instead of a concrete tool is a container leak: the
intent is real, but the name never maps to a callable. Silently dropping
it loses both the intent and the learning signal, so the model keeps
repeating the mistake.

When such a name hits the phantom allowlist AND a declared freeform exec
channel exists, the phantom is now rewritten into a synthetic exec call
whose body throws a directive error naming the namespace and showing the
correct flattened namespace__toolname form. The client runs the call as
an ordinary tool execution and reports the thrown message back to the
model, which then retries correctly. Non-namespace phantoms still drop
silently; without a declared exec channel the fallback is the previous
silent drop.

Regression coverage: tests/exec-envelope-repair.test.ts gains a
namespace-leak describe block (sandbox namespace, collapsed prefix,
non-namespace twin, no-exec fallback, batch path).
…tom guard

Routed models frequently emit a Codex tool in a different naming form than
the request declared: the bare sub-agent name (spawn_agent for
collaboration__spawn_agent), the dotted namespace form
(collaboration.spawn_agent), the historical functions__ prefix, or a fully
namespaced name when only the bare form is declared. Each currently dies on
the undeclared-client-tool guard, failing a turn whose intent was valid.

repairEmittedToolName runs between normalization and the phantom guard in
both bridges: a declared name passes through; otherwise the candidates are
the functions-prefix strip, the dotted-to-flattened form, the unique
namespace__name suffix match for a bare name, and the bare tail of a
namespaced name. Exactly one match rewrites the call; zero or several leave
it for the existing guard. Declared names, ambiguous names, and unknown
names are byte-identical.

Regression coverage: tests/emitted-call-shape-repair.test.ts (unit matrix
plus streaming and batch bridge paths, including the fail-closed twin).
Q38-class routed models compose the exec sandbox namespace onto real tool
names (tools__web_run for the declared web__run). repairEmittedToolName now
strips the tools__/tools. prefix and retries against the declared set, with
a separator-insensitive fallback for the common __ -> _ collapse. When the
remainder is declared, the intent survives; when it is not, the call falls
through to the existing phantom rules unchanged.

Regression coverage: tests/emitted-call-shape-repair.test.ts gains a
sandbox-namespace describe block (prefix strip, dotted form,
remainder-undeclared twin).
Four separately-patched symptoms of routed models emitting a Codex tool by
the wrong name (shape repair, namespace leak, phantom drop, fail-closed)
each lived where it was first observed, spreading one decision across three
files and making the precedence between them implicit.

resolveEmittedCall() states that order once:

  1. shape repair    unique mapping back to a declared tool -> rewrite
  2. leak feedback   namespace container called as a tool -> directive error
  3. phantom drop    known hallucination for this provider -> remove
  4. fail closed     anything else -> 502, never relay an unknown call

Repair only fires on a UNIQUE match; zero or ambiguous matches fall through
untouched, since guessing between two real tools beats interrupting the turn
only in theory.

Pure refactor: the streaming and batch paths in bridge.ts now resolve one
verdict instead of re-deriving the decision, with no behaviour change.
Covered by the four pre-existing suites plus tests/emitted-call-guard.test.ts,
which pins the layer order (repair outranks drop; ambiguity never guesses).

An onDecision hook reports declared/repaired/namespace-leak/phantom-drop/
undeclared per call, turning the "add a name to the allowlist when someone
notices" loop into something measurable.
@yorkane

yorkane commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

关闭:误建的分支 PR([WRONG BRANCH]),源分支是日常开发分支,每次 push 都触发整套 CI 且必然红(目标分支不对)。有效提交走上游 PR lidge-jun#3451 / lidge-jun#3452

@yorkane yorkane closed this Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants