Skip to content
Closed
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
9 changes: 8 additions & 1 deletion bridge/clients/desktop_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,21 @@ def match(executable):

class DesktopApp:
def __init__(self, executable, home):
self.executable = Path(executable).expanduser().resolve()
self.executable = Path(self.resolve_executable(executable)).expanduser().resolve()
if self.executable.suffix == '.app':
try:
self.executable = native_discovery('darwin').bundle_executable(self.executable)
except (OSError, ValueError, KeyError):
raise ValueError('请选择有效的 Codex / ChatGPT 应用程序包') from None
self.home = Path(home).resolve()

@staticmethod
def resolve_executable(executable):
if executable and sys.platform == 'win32':
from bridge.platforms.windows.discovery_ext import updated_codex_executable
return updated_codex_executable(executable)
return executable

@staticmethod
def discover(runtime):
if not runtime:
Expand Down
2 changes: 1 addition & 1 deletion bridge/clients/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ def _descriptor(self, provider):
def _codex_executable(self, accounts):
from bridge.clients.desktop_app import DesktopApp
# Windows may cache the CLI outside its Store-installed desktop bundle.
return (accounts.index.get('desktopExecutable') or DesktopApp.discover(accounts.runtime)
return DesktopApp.resolve_executable(accounts.index.get('desktopExecutable') or DesktopApp.discover(accounts.runtime)
or self.config.get('discovered', {}).get('codex', {}).get('executable', ''))

def _verified(self, provider):
Expand Down
2 changes: 1 addition & 1 deletion bridge/features/accounts/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ def public(self):
def desktop_status(self):
result = self.public()
with self.lock:
result.update(desktopExecutable=self.index.get('desktopExecutable') or DesktopApp.discover(self.runtime),
result.update(desktopExecutable=DesktopApp.resolve_executable(self.index.get('desktopExecutable') or DesktopApp.discover(self.runtime)),
enrollment=self.enrollment, discovery=self.discovery, codexHome=str(self.home))
return result

Expand Down
26 changes: 26 additions & 0 deletions bridge/platforms/windows/discovery_ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,32 @@ def windows_installations():
return {}


def updated_codex_executable(executable):
"""Follow a removed Store version only within its original package family."""
executable = Path(executable)
if executable.is_file():
return str(executable)
package = executable.parent.parent
match = re.fullmatch(r'OpenAI\.Codex_[\d.]+_(?:x64|x86|arm64|neutral)__([A-Za-z0-9]+)',
package.name, re.IGNORECASE)
if (not match or package.parent.name.casefold() != 'windowsapps'
or executable.parent.name.casefold() != 'app'
or executable.name.casefold() not in ('chatgpt.exe', 'codex.exe')):
return str(executable)
family = 'OpenAI.Codex_' + match.group(1)
matches = set()
inventory = windows_installations()
for row in _rows(inventory, 'packages'):
folder = _path(row.get('InstallLocation'))
if (str(row.get('Name', '')).casefold() != 'openai.codex' or not folder
or str(row.get('PackageFamilyName', '')).casefold() != family.casefold()):
continue
for candidate, _ in _package_entries(folder, ('ChatGPT.exe', 'Codex.exe')):
if candidate.is_file():
matches.add(str(candidate.resolve()))
return next(iter(matches)) if len(matches) == 1 else str(executable)


def _rows(inventory, key):
value = inventory.get(key, [])
if isinstance(value, dict):
Expand Down
13 changes: 13 additions & 0 deletions tests/test_client_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,19 @@ def test_explicit_codex_gui_keeps_precedence_even_when_missing(self):
self.assertFalse(descriptor['installed'])
discover.assert_not_called()

def test_status_resolves_updated_codex_without_rescan_or_launch(self):
selected = str(self.root/'removed-version.exe')
self.accounts.index['desktopExecutable'] = selected
replacement = self.manager.config['discovered']['codex']['executable']
with patch('bridge.clients.desktop_app.DesktopApp.resolve_executable', return_value=replacement):
rows = self.manager.clients(refresh=True)['clients']
self.assertEqual(self.manager._descriptor('codex')['executable'], replacement)
codex = next(row for row in rows if row['id'] == 'codex')
self.assertTrue(codex['running'])
self.assertTrue(codex['connected'])
self.assertEqual(self.accounts.index['desktopExecutable'], selected)
self.launch.assert_not_called()

def test_missing_scanned_codex_gui_does_not_report_configured(self):
self.accounts.index['desktopExecutable'] = ''
self.manager.config['discovered']['codex']['executable'] = str(self.root/'missing.exe')
Expand Down
27 changes: 27 additions & 0 deletions tests/test_windows_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,33 @@ def scan(self, inventory, preferences=None):
return discover_clients(preferences, platform='win32', home=self.home,
env=self.env, windows_inventory=inventory)

def test_removed_store_versions_follow_same_registered_package_repeatedly(self):
from bridge.platforms.windows.discovery_ext import updated_codex_executable
selected = self.root/'WindowsApps/OpenAI.Codex_1.0_x64__publisher/app/ChatGPT.exe'
for version in ('2.0', '3.0'):
gui = self.executable(f'WindowsApps/OpenAI.Codex_{version}_x64__publisher/app/ChatGPT.exe')
(gui.parent.parent/'AppxManifest.xml').write_text(
'<Package xmlns="urn:appx" xmlns:uap="urn:uap"><Applications>'
'<Application Id="App" Executable="app/ChatGPT.exe"><uap:VisualElements/>'
'</Application></Applications></Package>')
inventory = {'packages': [{'Name': 'OpenAI.Codex',
'InstallLocation': str(gui.parent.parent), 'PackageFamilyName': 'OpenAI.Codex_publisher'}]}
with patch('bridge.platforms.windows.discovery_ext.windows_installations', return_value=inventory):
self.assertEqual(updated_codex_executable(selected), str(gui))
gui.unlink()

def test_store_resolution_preserves_custom_paths_and_rejects_other_families(self):
from bridge.platforms.windows.discovery_ext import updated_codex_executable
custom = self.root/'selected.exe'
with patch('bridge.platforms.windows.discovery_ext.windows_installations') as inventory:
self.assertEqual(updated_codex_executable(custom), str(custom))
inventory.assert_not_called()
selected = self.root/'WindowsApps/OpenAI.Codex_1.0_x64__publisher/app/ChatGPT.exe'
with patch('bridge.platforms.windows.discovery_ext.windows_installations', return_value={
'packages': [{'Name': 'OpenAI.Codex', 'InstallLocation': str(self.root),
'PackageFamilyName': 'OpenAI.Codex_other'}]}):
self.assertEqual(updated_codex_executable(selected), str(selected))

def test_store_registration_finds_apps_without_listing_windowsapps(self):
codex = self.executable('Protected/OpenAI.Codex_1/app/ChatGPT.exe')
claude = self.executable('Protected/Claude_2/app/Claude.exe')
Expand Down
Loading