Skip to content

Commit c666058

Browse files
authored
Merge pull request #988 from markus-li/bridge-socket
Add an opt-in shared app-server socket for Desktop and SSH clients
2 parents 79d7303 + 0cf8838 commit c666058

5 files changed

Lines changed: 999 additions & 0 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Shared App-Server Socket
2+
3+
This opt-in feature makes the Codex app-server used by Desktop available on a
4+
user-private Unix socket. It does not implement, inspect, filter, or translate
5+
the app-server protocol.
6+
7+
From an SSH client's point of view, this behaves like an ordinary Codex SSH
8+
app-server connection. The remote `codex app-server proxy` command still
9+
provides the same stdio/WebSocket byte stream and the same app-server methods,
10+
notifications, approvals, and thread authority. The only difference is that the
11+
proxy attaches to Desktop's existing authority instead of starting a separate
12+
app-server with a separate thread namespace.
13+
14+
Desktop owns one selected Codex CLI child running `app-server --listen
15+
unix://PATH`. Desktop connects through the CLI's stock `app-server proxy --sock
16+
PATH` byte tunnel and its existing WebSocket transport. Other local clients use
17+
the same stock proxy command to attach to the Unix socket and receive the normal
18+
WebSocket `/rpc` byte stream. Closing Desktop stops the authority.
19+
20+
The default socket is scoped by Linux app id under `XDG_RUNTIME_DIR`, preventing
21+
side-by-side Desktop instances from sharing an authority accidentally. Override
22+
it with `CODEX_LINUX_APP_SERVER_BRIDGE_SOCKET` when a stable path is required.
23+
The Codex app-server creates the socket with user-only permissions. A shell
24+
wrapper may route bare `codex app-server proxy` SSH sessions to this path.
25+
Keep the socket in a directory accessible only to the owning user. It is a local
26+
control endpoint and must not be exposed directly over TCP or forwarded as a
27+
network service.
28+
29+
Authority startup is serialized by an owner-only lock next to the socket. The
30+
feature fails closed if either path already exists; it never guesses that an
31+
existing socket or lock is stale. After an abnormal Desktop termination, verify
32+
that no authority still owns the configured endpoint before removing stale
33+
paths and restarting Desktop.
34+
35+
## SSH setup
36+
37+
Use a stable socket path when the Desktop instance will be reached over SSH:
38+
39+
```bash
40+
export CODEX_LINUX_APP_SERVER_BRIDGE_SOCKET="$HOME/.codex/app-server-control/app-server-control.sock"
41+
codex-desktop
42+
```
43+
44+
Then place a small `codex` wrapper earlier in the SSH user's `PATH`. Set
45+
`real_codex` to the actual CLI executable, not to the wrapper itself:
46+
47+
```bash
48+
#!/usr/bin/env bash
49+
set -eu
50+
51+
real_codex="/absolute/path/to/real/codex"
52+
desktop_socket="$HOME/.codex/app-server-control/app-server-control.sock"
53+
54+
if [ "$#" -eq 2 ] && [ "$1" = "app-server" ] && [ "$2" = "proxy" ]; then
55+
exec "$real_codex" app-server proxy --sock "$desktop_socket"
56+
fi
57+
58+
exec "$real_codex" "$@"
59+
```
60+
61+
The upstream SSH transport normally starts its own authority before invoking
62+
the proxy. Configure the **remote account's login-shell environment** to skip
63+
that bootstrap when this wrapper is used:
64+
65+
```bash
66+
export CODEX_SSH_SKIP_APP_SERVER_BOOT=true
67+
```
68+
69+
Put that export in the startup file read by the account's SSH login shell (for
70+
example `~/.profile` when that is the active login profile). This is remote
71+
account configuration; setting it only in the local Desktop launcher does not
72+
propagate it through SSH. Use it only for an account whose wrapper is dedicated
73+
to this Desktop-owned socket.
74+
75+
Make the wrapper executable and verify that non-interactive SSH resolves it:
76+
77+
```bash
78+
chmod 0755 "$HOME/.local/bin/codex"
79+
ssh host 'command -v codex'
80+
ssh host 'printf "%s\n" "$CODEX_SSH_SKIP_APP_SERVER_BOOT"'
81+
```
82+
83+
Codex SSH clients can then connect normally; no client-side protocol option or
84+
special method allowlist is required. Only the exact two-argument proxy command
85+
is redirected. Interactive CLI commands and all other subcommands continue to
86+
use the real CLI normally. `CODEX_CLI_PATH` used to launch Desktop must also
87+
point to the real CLI so Desktop cannot recursively invoke the wrapper.
88+
89+
Enable the feature in the ignored `linux-features/features.json` file:
90+
91+
```json
92+
{
93+
"enabled": ["shared-app-server-socket"]
94+
}
95+
```
96+
97+
Then rebuild and launch the app. The feature is disabled by default and does
98+
not run independently of Desktop.
99+
100+
Run focused tests with:
101+
102+
```bash
103+
node --test linux-features/shared-app-server-socket/test.js
104+
```
105+
106+
Set `CODEX_CLI_PATH` to include the stock authority/socket/proxy lifecycle test:
107+
108+
```bash
109+
CODEX_CLI_PATH="/absolute/path/to/real/codex" node --test linux-features/shared-app-server-socket/test.js
110+
```
111+
112+
The feature depends on upstream's current local transport factory, WebSocket
113+
adapter, and `app-server proxy` command. Bundle drift causes the optional patch
114+
to warn and skip instead of modifying an unknown surface.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"id": "shared-app-server-socket",
3+
"title": "Shared App-Server Socket",
4+
"description": "Opt-in protocol-transparent Unix socket shared by Codex Desktop and one or more ordinary app-server clients.",
5+
"defaultEnabled": false,
6+
"entrypoints": {
7+
"patchDescriptors": "./patch.js"
8+
},
9+
"runtimeHooks": {
10+
"launcher": {
11+
"source": "socket-env.sh",
12+
"name": "socket-env.sh",
13+
"mode": "0755"
14+
}
15+
}
16+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"use strict";
2+
3+
const IDENT = "[A-Za-z_$][\\w$]*";
4+
5+
function findTransportSymbols(source) {
6+
const classMatch = source.match(new RegExp(`var (${IDENT})=class\\{kind=\\\`websocket\\\``));
7+
const selectionLogIndex = source.indexOf("selected app-server transport");
8+
if (classMatch == null || selectionLogIndex < 0 || classMatch.index >= selectionLogIndex) return null;
9+
10+
const sshClassSource = source.slice(classMatch.index, selectionLogIndex);
11+
const webSocketMatch = sshClassSource.match(
12+
new RegExp(`new (${IDENT})\\.(${IDENT})\\((${IDENT}),\\{perMessageDeflate:!1,createConnection:`),
13+
);
14+
if (webSocketMatch == null) return null;
15+
const [, namespace, webSocketClass, webSocketUrl] = webSocketMatch;
16+
const lifecycleMatch = sshClassSource.match(
17+
new RegExp(
18+
`return ${namespace}\\.(${IDENT})\\((${IDENT}),\\{onPongTimeout:[\\s\\S]{0,160}?\\}\\),new ${namespace}\\.(${IDENT})\\(\\2\\)`,
19+
),
20+
);
21+
if (lifecycleMatch == null) return null;
22+
23+
return {
24+
namespace,
25+
webSocketClass,
26+
webSocketUrl,
27+
adapterClass: lifecycleMatch[3],
28+
keepAlive: lifecycleMatch[1],
29+
};
30+
}
31+
32+
function sharedTransportClassSource(symbols) {
33+
return (
34+
"class CodexLinuxSharedAppServerSocketTransport{" +
35+
"kind=`websocket`;proxyStreams=new Set;authority=null;authorityError=null;authorityReady=null;lockIdentity=null;socketIdentity=null;" +
36+
"constructor(e){this.socketPath=e;this.lockPath=`${e}.lock`}" +
37+
"supportsReconnect(){return!0}" +
38+
"sameIdentity(e,t){return e!=null&&t.dev===e.dev&&t.ino===e.ino}" +
39+
"releaseOwnedPaths(e=!1){let t=require(`node:fs`),n=[];if(this.socketIdentity)try{let e=t.lstatSync(this.socketPath);this.sameIdentity(this.socketIdentity,e)&&t.unlinkSync(this.socketPath),this.socketIdentity=null}catch(e){e?.code===`ENOENT`?this.socketIdentity=null:n.push(e)}if(this.lockIdentity)try{let e=t.lstatSync(this.lockPath);this.sameIdentity(this.lockIdentity,e)&&t.unlinkSync(this.lockPath),this.lockIdentity=null}catch(e){e?.code===`ENOENT`?this.lockIdentity=null:n.push(e)}if(n.length&&!e)throw n[0];n.length&&console.warn(`WARN: shared app-server socket cleanup failed: ${n[0].message}`)}" +
40+
"dispose(){for(let e of this.proxyStreams)e.destroy();this.proxyStreams.clear();let e=this.authority;this.authority=null;if(e&&e.exitCode==null&&e.signalCode==null){let t=()=>this.releaseOwnedPaths(!0);e.once(`close`,t);try{e.kill()}catch(e){console.warn(`WARN: shared app-server authority stop failed: ${e.message}`)}}else this.releaseOwnedPaths(!0)}" +
41+
"acquireOwnership(){let e=require(`node:fs`),t=require(`node:path`);e.mkdirSync(t.dirname(this.socketPath),{recursive:!0,mode:448});let n;try{n=e.openSync(this.lockPath,`wx`,384),this.lockIdentity=e.fstatSync(n)}catch(e){if(e?.code===`EEXIST`)throw Error(`shared app-server socket is already owned: ${this.socketPath}`);throw e}finally{n!=null&&e.closeSync(n)}try{e.lstatSync(this.socketPath);throw Error(`shared app-server socket path already exists: ${this.socketPath}`)}catch(e){if(e?.code!==`ENOENT`){this.releaseOwnedPaths();throw e}}}" +
42+
"stopAuthority(e){return new Promise(t=>{if(!e||e.exitCode!=null||e.signalCode!=null)return t(!0);let n=!1,r=i=>{if(n)return;n=!0,clearTimeout(a),e.off(`close`,o),e.off(`exit`,o),e.off(`error`,s),t(i)},o=()=>r(!0),s=e=>{this.authorityError??=e},a=setTimeout(()=>r(!1),2e3);a.unref?.(),e.once(`close`,o),e.once(`exit`,o),e.on(`error`,s);try{e.kill()}catch(e){this.authorityError??=e,r(!1)}})}" +
43+
"async ensureAuthority(){if(this.authorityReady)return this.authorityReady;if(this.authority&&this.authority.exitCode==null&&this.authority.signalCode==null){if(this.authorityError)throw this.authorityError;return}let e=this.startAuthority();this.authorityReady=e;try{return await e}finally{this.authorityReady===e&&(this.authorityReady=null)}}" +
44+
"async startAuthority(){let e=process.env.CODEX_CLI_PATH;if(!e)throw Error(`shared app-server socket requires CODEX_CLI_PATH`);this.authorityError=null,this.acquireOwnership();let t=require(`node:fs`),n;try{n=require(`node:child_process`).spawn(e,[`app-server`,`--listen`,`unix://${this.socketPath}`],{env:process.env,stdio:`ignore`}),this.authority=n}catch(e){this.releaseOwnedPaths();throw e}try{await new Promise((e,r)=>{let i=!1,a,o=()=>{clearTimeout(a),clearTimeout(u),n.off(`error`,s),n.off(`exit`,l),n.off(`close`,l)},c=(t,u)=>{if(i)return;i=!0,o(),t?r(t):e(u)},s=e=>{this.authorityError=e,c(e)},l=()=>c(Error(`shared app-server authority exited before socket creation`)),h=()=>{if(i)return;try{let e=t.lstatSync(this.socketPath);if(e.isSocket()){if(typeof process.getuid==`function`&&e.uid!==process.getuid())return c(Error(`shared app-server socket has unexpected owner`));this.socketIdentity={dev:e.dev,ino:e.ino};return c(null)}}catch(e){if(e?.code!==`ENOENT`)return c(e)}a=setTimeout(h,100),a.unref?.()},u=setTimeout(()=>c(Error(`shared app-server socket creation timed out`)),1e4);n.once(`error`,s),n.once(`exit`,l),n.once(`close`,l),h(),u.unref?.()}),n.on(`error`,e=>{this.authorityError=e;for(let t of this.proxyStreams)t.destroy(e)}),n.once(`exit`,()=>{this.authority===n&&(this.authority=null,this.releaseOwnedPaths(!0))})}catch(e){this.authority=null;(await this.stopAuthority(n))&&this.releaseOwnedPaths();throw e}}" +
45+
"createProxyStream(){let c=process.env.CODEX_CLI_PATH;if(!c)throw Error(`shared app-server socket requires CODEX_CLI_PATH`);let e=require(`node:child_process`).spawn(c,[`app-server`,`proxy`,`--sock`,this.socketPath],{env:process.env,stdio:[`pipe`,`pipe`,`pipe`]}),t=e.stdin,n=e.stdout,r=e.stderr;if(t==null||n==null||r==null)throw e.kill(),Error(`shared app-server proxy stdio was unavailable`);let i=``;r.on(`data`,e=>{i=`${i}${e.toString(`utf8`)}`.slice(-4000)});let a=new(require(`node:stream`).Duplex)({read(){n.resume()},write(e,n,r){t.write(e,n,r)},final(e){t.end(),e()},destroy(t,n){e.kill(),n(t)}});Object.assign(a,{setKeepAlive:()=>a,setNoDelay:()=>a,setTimeout:()=>a});let o=e=>a.destroy(e);t.on(`error`,o),n.on(`data`,e=>{a.push(e)||n.pause()}),n.on(`end`,()=>a.push(null)),e.on(`error`,o),e.on(`close`,(e,n)=>{t.removeListener(`error`,o),e===0?a.push(null):a.destroy(Error(`shared app-server proxy exited (${e??n??`unknown`}): ${i.trim()}`))}),this.proxyStreams.add(a),a.once(`close`,()=>this.proxyStreams.delete(a));return a}" +
46+
`async connect(){await this.ensureAuthority();let e={current:null},t=new ${symbols.namespace}.${symbols.webSocketClass}(${symbols.webSocketUrl},{perMessageDeflate:!1,createConnection:()=>(e.current=this.createProxyStream(),e.current)});t.once(\`close\`,()=>e.current?.destroy());try{await new Promise((n,r)=>{let i=setTimeout(()=>o(Error(\`shared app-server websocket open timed out\`)),3e4);i.unref();let a=()=>{clearTimeout(i),t.off(\`error\`,o),t.off(\`close\`,s)},o=e=>{a(),r(e)},s=()=>o(Error(\`shared app-server websocket closed before opening\`));t.once(\`open\`,()=>{a(),n()}),t.once(\`error\`,o),t.once(\`close\`,s)})}catch(n){e.current?.destroy(),t.terminate(),await new Promise(e=>setTimeout(e,0));throw n}${symbols.namespace}.${symbols.keepAlive}(t,{onPongTimeout:()=>t.terminate()});return new ${symbols.namespace}.${symbols.adapterClass}(t)}}`
47+
);
48+
}
49+
50+
function applySharedAppServerSocketPatch(source) {
51+
if (source.includes("class CodexLinuxSharedAppServerSocketTransport")) return source;
52+
53+
const symbols = findTransportSymbols(source);
54+
if (symbols == null) {
55+
console.warn("WARN: Could not find SSH WebSocket transport for shared app-server socket patch");
56+
return source;
57+
}
58+
59+
const selectionLogIndex = source.indexOf("selected app-server transport");
60+
const factoryStart = source.lastIndexOf("function ", selectionLogIndex);
61+
const factoryEnd = source.indexOf("function ", selectionLogIndex + 1);
62+
if (selectionLogIndex < 0 || factoryStart < 0 || factoryEnd < 0) {
63+
console.warn("WARN: Could not find local transport factory for shared app-server socket patch");
64+
return source;
65+
}
66+
const factorySource = source.slice(factoryStart, factoryEnd);
67+
const localFallbackPattern = new RegExp(
68+
`(if\\(${symbols.namespace}\\.(${IDENT})\\(e\\.hostConfig\\)\\)return [^;]+;)(let (${IDENT})=(${IDENT})\\(e\\.hostConfig\\);return \\4\\?)`,
69+
);
70+
const localFallbackMatch = factorySource.match(localFallbackPattern);
71+
if (localFallbackMatch == null) {
72+
console.warn("WARN: Could not find local transport fallback for shared app-server socket patch");
73+
return source;
74+
}
75+
76+
const classSource = sharedTransportClassSource(symbols);
77+
78+
const patchedFactory = factorySource.replace(
79+
localFallbackPattern,
80+
`$1if(process.env.CODEX_LINUX_APP_SERVER_BRIDGE_SOCKET&&e.hostConfig.kind===\`local\`)return new CodexLinuxSharedAppServerSocketTransport(process.env.CODEX_LINUX_APP_SERVER_BRIDGE_SOCKET);$3`,
81+
);
82+
return source.slice(0, factoryStart) + classSource + patchedFactory + source.slice(factoryEnd);
83+
}
84+
85+
const descriptors = [
86+
{
87+
id: "main-process-shared-app-server-socket",
88+
phase: "main-bundle",
89+
order: 140,
90+
ciPolicy: "optional",
91+
apply: applySharedAppServerSocketPatch,
92+
},
93+
];
94+
95+
module.exports = {
96+
applySharedAppServerSocketPatch,
97+
descriptors,
98+
findTransportSymbols,
99+
sharedTransportClassSource,
100+
};
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#!/usr/bin/env bash
2+
set -eu
3+
4+
runtime_root="${XDG_RUNTIME_DIR:-${CODEX_LINUX_APP_STATE_DIR:?}}"
5+
runtime_dir="$runtime_root/${CODEX_LINUX_APP_ID:-codex-desktop}/app-server-bridge"
6+
socket_path="${CODEX_LINUX_APP_SERVER_BRIDGE_SOCKET:-$runtime_dir/app-server.sock}"
7+
printf 'env CODEX_LINUX_APP_SERVER_BRIDGE_SOCKET=%s\n' "$socket_path"

0 commit comments

Comments
 (0)