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
60 changes: 57 additions & 3 deletions bridges/codex_voice/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
Keep running Codex in your usual Linux SSH terminal. F8 opens the native Windows
microphone and speakers on your PC. A short-lived SSH process connects Codex's
helper control pipe to the matching Windows helper; audio uses the helper's
native WebRTC connection. No audio recording, listener, service, scheduled task,
or Genesis session registration is added.
native WebRTC connection. Manual installation adds no audio recording, listener,
service, scheduled task, or Genesis session registration. The optional managed
mode below adds a background build updater; audio still runs only on demand.

This is an opt-in adapter for **Codex 0.163.0's private helper protocol**. Both
hosts must have the same build commit. Other releases are refused until tested.
Expand Down Expand Up @@ -68,7 +69,60 @@ follows `CODEX_HOME` (or `~/.codex` when unset/empty). `--socket` can select
another local app-server Unix socket. A final spoken F8 test verifies perception
and playback. Enumeration alone is deliberately not called an audio success.

## Updates and uninstall
## Automatic updates with immediate text startup

Managed mode is opt-in. It keeps the globally installed Codex package untouched
and prepares a private copy of that exact release, paired with an immutable
Windows helper build. It extends the same relay and native protocol checks;
it does not install a separate audio engine or change your Codex configuration.
Windows needs Node, npm, and the existing key-based SSH connection.

```sh
python3 bridges/codex_voice/bridge.py manage \
--host windows-audio --codex /path/to/npm/codex/bin/codex.js \
--launcher "$HOME/.local/bin/codex"
python3 bridges/codex_voice/bridge.py status
```

Select the real npm entry point for `--codex`, not the generated launcher. If
that launcher already exists, installation refuses unless
`--replace-launcher-sha256` supplies its exact SHA-256; the original contents
and mode are retained for rollback. An existing manual installation needs a
different `--state-dir` or must be uninstalled first. Use the chosen state
directory on each management command; the generated launcher remembers it.

After an upgrade, `codex` opens immediately for text while a systemd user
oneshot quietly prepares matching builds. A new version alone produces no
startup warning; a recorded failure for the current build, a damaged prepared
build, or an unavailable updater does. Transient network or sleeping-PC failures
retry every five minutes; incompatible manifests, protocol failures and foreign
changes are held for inspection without repeated downloads. `status` reports
the current release's readiness. Relaunch after it reports ready to use voice.
Preparation verifies voice resource hashes, build identity, native handshake,
runtime initialization and device enumeration. It never opens the microphone
or speakers. New helper protocols that fail those checks are not activated.

The existing `doctor --audio` applies to the prepared build's `relay` state
directory shown by `status`. Run the muted device test and a spoken F8 check
when validating a new release: background qualification alone does not prove
a working conversation. Previous immutable builds remain available to active
sessions and are retained for rollback.

```sh
python3 bridges/codex_voice/bridge.py unmanage
```

This stops the updater, restores the previous launcher (or removes the generated
one if none existed), and retains prepared builds. Modified launchers or units
are preserved rather than overwritten. No Genesis session registration or
lifecycle hook is introduced.

Manager source files are fixed within a state directory. Repeating the same
setup is safe; upgrading the manager code itself requires a new state directory
and an explicit launcher replacement hash. This avoids partial source updates
changing a running updater. Native Codex release matching remains automatic.

## Manual updates and uninstall

An npm update can replace the launcher. Update Codex on **both** hosts and rerun
`install`; the installer checks version/build compatibility before patching.
Expand Down
75 changes: 60 additions & 15 deletions bridges/codex_voice/bridge.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Opt-in Linux Codex -> SSH -> native Windows audio helper. No resident service."""
"""Opt-in native Windows audio relay, with optional managed build updates."""
import argparse
import asyncio
import base64
Expand Down Expand Up @@ -58,7 +58,7 @@ def atomic_write(path, data, mode=0o600):
os.unlink(temporary)


def package_at(directory=None):
def package_at(directory=None, *, probe=False):
if directory:
candidates = [Path(directory).expanduser().resolve()]
else:
Expand All @@ -72,7 +72,7 @@ def package_at(directory=None):
raise ValueError('Cannot uniquely discover the physical Codex package; pass --package')
root = matches[0]
manifest = json.loads((root / VOICE / 'manifest.json').read_text())
if manifest['schemaVersion'] != 1 or manifest['appVersion'] not in SUPPORTED:
if manifest['schemaVersion'] != 1 or (not probe and manifest['appVersion'] not in SUPPORTED):
raise ValueError('Untested Codex helper protocol/version; supported: ' + ', '.join(sorted(SUPPORTED)))
if not re.fullmatch(r'[a-f0-9]{40}', manifest['buildCommit']):
raise ValueError('Invalid Codex build commit')
Expand Down Expand Up @@ -106,16 +106,24 @@ def remote_command(asset):
f"'Genesis','CodexVoice','{asset}','host.cjs'))\"")


def setup_windows(ssh, windows_package):
def setup_windows(ssh, windows_package, *, provision=None):
source = Path(__file__).with_name('host.cjs').read_bytes()
options = base64.b64encode(json.dumps({'package': windows_package}).encode()).decode()
options = base64.b64encode(json.dumps({'package': windows_package, **(provision or {})}).encode()).decode()
script = Path(__file__).with_name('setup.ps1').read_text()
script = script.replace('__OPTIONS__', options).replace('__SOURCE__', base64.b64encode(source).decode())
loader = '& ([ScriptBlock]::Create([Console]::In.ReadToEnd()))'
encoded = base64.b64encode(loader.encode('utf-16-le')).decode()
result = subprocess.run(ssh + ['powershell.exe -NoProfile -NonInteractive -EncodedCommand ' + encoded],
input=script.encode('utf-8'), capture_output=True, timeout=45, env=helper_environment())
input=script.encode('utf-8'), capture_output=True,
timeout=7200 if provision else 45, env=helper_environment())
if result.returncode:
if provision:
try:
failure = json.loads(result.stdout.decode('utf-8-sig'))
except (ValueError, UnicodeDecodeError):
failure = {}
if result.returncode == 255 or not isinstance(failure, dict) or failure.get('held') is not True:
raise OSError('Windows provisioning unavailable; retrying the matching build')
raise ValueError('Windows setup failed in Codex helper environment (SSH_AUTH_SOCK is not inherited; '
'use a file identity or explicit SSH IdentityAgent): ' + result.stderr.decode(errors='replace'))
return json.loads(result.stdout.decode('utf-8-sig'))
Expand All @@ -135,7 +143,8 @@ def load_state(state):

def install(args):
state = args.state_dir.expanduser().resolve()
root, manifest = package_at(args.package)
managed = getattr(args, 'managed', False)
root, manifest = package_at(args.package, probe=managed)
helper = root / VOICE / 'bin/codex-voice-host'
if helper.resolve() != helper:
raise ValueError('The packaged helper must be a physical file')
Expand All @@ -154,7 +163,8 @@ def install(args):
if digest(current) != expected and not ours:
raise ValueError('Helper has a foreign modification or another state directory owns it')
ssh = ssh_args(args.host, args.identity)
remote = setup_windows(ssh, args.windows_package)
remote = (setup_windows(ssh, args.windows_package, provision=args.provision) if managed
else setup_windows(ssh, args.windows_package))
if (remote['buildCommit'], remote['appVersion']) != (manifest['buildCommit'], manifest['appVersion']):
raise ValueError('Linux and Windows Codex builds differ; update both before installing')
command = remote_command(remote['asset'])
Expand All @@ -166,10 +176,12 @@ def install(args):
raise ValueError('Original helper backup is missing; refusing to adopt the relay as backup')
else:
atomic_write(backup, current, stat.S_IMODE(helper.stat().st_mode))
script = launcher(state)
config = {'application': 'genesis-codex-voice', 'package': str(root), 'ssh': ssh,
script = launcher(getattr(args, 'runtime_state', state))
config = {'application': 'genesis-codex-voice', 'package': str(getattr(args, 'runtime_package', root)), 'ssh': ssh,
'command': command, 'buildCommit': manifest['buildCommit'], 'originalHash': expected,
'originalMode': stat.S_IMODE(backup.stat().st_mode), 'launcher': script.decode()}
if managed:
config['managedBuild'] = manifest['buildCommit']
atomic_write(state / 'config.json', json.dumps(config).encode())
atomic_write(state / 'bridge.py', Path(__file__).read_bytes())
atomic_write(helper, script, config['originalMode'])
Expand Down Expand Up @@ -199,7 +211,7 @@ def uninstall(state):

def run(state):
config = load_state(state)
_, manifest = package_at(config['package'])
_, manifest = package_at(config['package'], probe=config.get('managedBuild') == config['buildCommit'])
if manifest['buildCommit'] != config['buildCommit']:
raise ValueError('Codex changed builds; reinstall the bridge after updating both hosts')
os.execv(config['ssh'][0], config['ssh'] + [config['command']])
Expand Down Expand Up @@ -286,6 +298,11 @@ async def doctor(args):
process = await asyncio.create_subprocess_exec(str(helper), stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
env=helper_environment())
await inspect_process(process, config, args)


async def inspect_process(process, config, args):
"""Shared native protocol qualification; device opening remains opt-in."""
try:
for message, expected, deadline in [({'type': 'hello', 'protocol': 1, 'buildCommit': config['buildCommit']}, 'ready', 5),
({'type': 'initializeRuntime'}, 'runtimeReady', 30)]:
Expand All @@ -305,16 +322,25 @@ async def doctor(args):
await asyncio.wait_for(process.wait(), 5)
if process.returncode:
raise ValueError('Helper exited unsuccessfully')
except EOFError:
# EOF can precede delivery of SSH's process-exit notification. Let that
# finish before cleanup so the updater can distinguish transport exit
# 255 from a native/protocol failure. Reuse the normal close deadline.
try:
await asyncio.wait_for(process.wait(), 5)
except asyncio.TimeoutError:
pass
raise
finally:
if process.returncode is None:
process.kill()
await process.wait()


def main():
def parse_args():
parser = argparse.ArgumentParser(description=__doc__)
actions = parser.add_subparsers(dest='action', required=True)
for name in ('install', 'run', 'uninstall', 'doctor'):
for name in ('install', 'run', 'uninstall', 'doctor', 'manage', 'launch', 'sync', 'status', 'unmanage'):
command = actions.add_parser(name)
command.add_argument('--state-dir', type=Path, default=DEFAULT_STATE)
if name == 'install':
Expand All @@ -326,9 +352,28 @@ def main():
command.add_argument('--audio', action='store_true', help='Negotiate live WebRTC and open devices muted')
codex_home = Path(os.environ.get('CODEX_HOME') or Path.home() / '.codex')
command.add_argument('--socket', type=Path, default=codex_home / 'app-server-control/app-server-control.sock')
args = parser.parse_args()
if name in ('manage', 'launch'):
command.add_argument('--codex', required=True, help='Real npm Codex entry point, not the voice launcher')
if name == 'manage':
command.add_argument('--host', required=True)
command.add_argument('--identity')
command.add_argument('--launcher', type=Path, required=True)
command.add_argument('--replace-launcher-sha256', help='Explicit ownership check when replacing an existing launcher')
if name == 'launch':
command.add_argument('arguments', nargs=argparse.REMAINDER)
return parser.parse_args()


def main():
args = parse_args()
try:
if args.action == 'install':
if args.action in ('manage', 'launch', 'sync', 'status', 'unmanage'):
if __package__:
from . import managed
else:
import managed
return getattr(managed, args.action)(args)
elif args.action == 'install':
install(args)
elif args.action == 'uninstall':
uninstall(args.state_dir.expanduser().resolve())
Expand Down
Loading
Loading