Skip to content

Commit 8976c11

Browse files
committed
feat: add shared app-server socket bridge
1 parent 9c44807 commit 8976c11

5 files changed

Lines changed: 441 additions & 0 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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+
## SSH setup
30+
31+
Use a stable socket path when the Desktop instance will be reached over SSH:
32+
33+
```bash
34+
export CODEX_LINUX_APP_SERVER_BRIDGE_SOCKET="$HOME/.codex/app-server-control/app-server-control.sock"
35+
codex-desktop
36+
```
37+
38+
Then place a small `codex` wrapper earlier in the SSH user's `PATH`. Set
39+
`real_codex` to the actual CLI executable, not to the wrapper itself:
40+
41+
```bash
42+
#!/usr/bin/env bash
43+
set -eu
44+
45+
real_codex="/absolute/path/to/real/codex"
46+
desktop_socket="$HOME/.codex/app-server-control/app-server-control.sock"
47+
48+
if [ "$#" -eq 2 ] && [ "$1" = "app-server" ] && [ "$2" = "proxy" ]; then
49+
exec "$real_codex" app-server proxy --sock "$desktop_socket"
50+
fi
51+
52+
exec "$real_codex" "$@"
53+
```
54+
55+
Make the wrapper executable and verify that non-interactive SSH resolves it:
56+
57+
```bash
58+
chmod 0755 "$HOME/.local/bin/codex"
59+
ssh host 'command -v codex'
60+
```
61+
62+
Codex SSH clients can then connect normally; no client-side protocol option or
63+
special method allowlist is required. Only the exact two-argument proxy command
64+
is redirected. Interactive CLI commands and all other subcommands continue to
65+
use the real CLI normally. `CODEX_CLI_PATH` used to launch Desktop must also
66+
point to the real CLI so Desktop cannot recursively invoke the wrapper.
67+
68+
Enable the feature in the ignored `linux-features/features.json` file:
69+
70+
```json
71+
{
72+
"enabled": ["shared-app-server-socket"]
73+
}
74+
```
75+
76+
Then rebuild and launch the app. The feature is disabled by default and does
77+
not run independently of Desktop.
78+
79+
Run focused tests with:
80+
81+
```bash
82+
node --test linux-features/shared-app-server-socket/test.js
83+
```
84+
85+
Set `CODEX_CLI_PATH` to include the stock authority/socket/proxy lifecycle test:
86+
87+
```bash
88+
CODEX_CLI_PATH="/absolute/path/to/real/codex" node --test linux-features/shared-app-server-socket/test.js
89+
```
90+
91+
The feature depends on upstream's current local transport factory, WebSocket
92+
adapter, and `app-server proxy` command. Bundle drift causes the optional patch
93+
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: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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 applySharedAppServerSocketPatch(source) {
33+
if (source.includes("class CodexLinuxSharedAppServerSocketTransport")) return source;
34+
35+
const symbols = findTransportSymbols(source);
36+
if (symbols == null) {
37+
console.warn("WARN: Could not find SSH WebSocket transport for shared app-server socket patch");
38+
return source;
39+
}
40+
41+
const selectionLogIndex = source.indexOf("selected app-server transport");
42+
const factoryStart = source.lastIndexOf("function ", selectionLogIndex);
43+
const factoryEnd = source.indexOf("function ", selectionLogIndex + 1);
44+
if (selectionLogIndex < 0 || factoryStart < 0 || factoryEnd < 0) {
45+
console.warn("WARN: Could not find local transport factory for shared app-server socket patch");
46+
return source;
47+
}
48+
const factorySource = source.slice(factoryStart, factoryEnd);
49+
const localFallbackPattern = new RegExp(
50+
`(if\\(${symbols.namespace}\\.(${IDENT})\\(e\\.hostConfig\\)\\)return [^;]+;)(let (${IDENT})=(${IDENT})\\(e\\.hostConfig\\);return \\4\\?)`,
51+
);
52+
const localFallbackMatch = factorySource.match(localFallbackPattern);
53+
if (localFallbackMatch == null) {
54+
console.warn("WARN: Could not find local transport fallback for shared app-server socket patch");
55+
return source;
56+
}
57+
58+
const classSource =
59+
"class CodexLinuxSharedAppServerSocketTransport{" +
60+
"kind=`websocket`;proxyStreams=new Set;authority=null;" +
61+
"constructor(e){this.socketPath=e}" +
62+
"supportsReconnect(){return!0}" +
63+
"dispose(){for(let e of this.proxyStreams)e.destroy();this.proxyStreams.clear();this.authority?.kill();this.authority=null;try{require(`node:fs`).unlinkSync(this.socketPath)}catch(e){if(e?.code!==`ENOENT`)throw e}}" +
64+
"async ensureAuthority(){if(this.authority&&this.authority.exitCode==null)return;let e=process.env.CODEX_CLI_PATH;if(!e)throw Error(`shared app-server socket requires CODEX_CLI_PATH`);let t=require(`node:fs`),n=require(`node:path`);t.mkdirSync(n.dirname(this.socketPath),{recursive:!0,mode:448});try{t.unlinkSync(this.socketPath)}catch(e){if(e?.code!==`ENOENT`)throw e}this.authority=require(`node:child_process`).spawn(e,[`app-server`,`--listen`,`unix://${this.socketPath}`],{env:process.env,stdio:`ignore`});for(let e=0;e<100;e++){if(this.authority.exitCode!=null)throw Error(`shared app-server authority exited before socket creation`);try{if(t.statSync(this.socketPath).isSocket())return}catch(e){if(e?.code!==`ENOENT`)throw e}await new Promise(e=>setTimeout(e,100))}this.authority.kill();this.authority=null;throw Error(`shared app-server socket creation timed out`)}" +
65+
"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}" +
66+
`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());await new Promise((n,r)=>{let i=setTimeout(()=>{r(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=()=>{a(),r(Error(\`shared app-server websocket closed before opening\`))};t.once(\`open\`,()=>{a(),n()}),t.once(\`error\`,o),t.once(\`close\`,s)});${symbols.namespace}.${symbols.keepAlive}(t,{onPongTimeout:()=>t.terminate()});return new ${symbols.namespace}.${symbols.adapterClass}(t)}}`;
67+
68+
const patchedFactory = factorySource.replace(
69+
localFallbackPattern,
70+
`$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`,
71+
);
72+
return source.slice(0, factoryStart) + classSource + patchedFactory + source.slice(factoryEnd);
73+
}
74+
75+
const descriptors = [
76+
{
77+
id: "main-process-shared-app-server-socket",
78+
phase: "main-bundle",
79+
order: 140,
80+
ciPolicy: "optional",
81+
apply: applySharedAppServerSocketPatch,
82+
},
83+
];
84+
85+
module.exports = {
86+
applySharedAppServerSocketPatch,
87+
descriptors,
88+
findTransportSymbols,
89+
};
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)