@btatum5/codex-bridge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/bin/codex-bridge.js +105 -0
- package/package.json +24 -0
- package/server.mjs +290 -0
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Codex Desktop Companion
|
|
2
|
+
|
|
3
|
+
This is the Windows-side HTTP stub that the iOS `Codex` app can talk to.
|
|
4
|
+
|
|
5
|
+
## Run
|
|
6
|
+
|
|
7
|
+
The publishable npm package for this scaffold is `@btatum5/codex-bridge`. After publishing, you can install and run it with:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @btatum5/codex-bridge
|
|
11
|
+
codex-bridge up
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
You can also run the in-repo companion directly:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
cd desktop-companion
|
|
18
|
+
npm start
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
From the repo root on Windows, you can also use:
|
|
22
|
+
|
|
23
|
+
```powershell
|
|
24
|
+
.\run-local-codex.ps1
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The server listens on `http://127.0.0.1:8787` by default.
|
|
28
|
+
|
|
29
|
+
## Endpoints
|
|
30
|
+
|
|
31
|
+
- `GET /health`
|
|
32
|
+
- `GET /v1/companion/session`
|
|
33
|
+
- `POST /v1/companion/settings`
|
|
34
|
+
- `POST /v1/companion/actions`
|
|
35
|
+
- `POST /v1/companion/chat`
|
|
36
|
+
|
|
37
|
+
## Important limitation
|
|
38
|
+
|
|
39
|
+
This server does not automate the real ChatGPT Codex Windows app yet. It mirrors session state, approvals, workspace files, terminal lines, and accepts remote actions so the iOS controller has a concrete desktop companion shape to target.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const args = process.argv.slice(2);
|
|
4
|
+
const command = resolveCommand(args[0]);
|
|
5
|
+
const commandArgs = command === "up" ? args : args.slice(1);
|
|
6
|
+
|
|
7
|
+
switch (command) {
|
|
8
|
+
case "up":
|
|
9
|
+
case "start":
|
|
10
|
+
runServer(commandArgs);
|
|
11
|
+
break;
|
|
12
|
+
case "help":
|
|
13
|
+
case "--help":
|
|
14
|
+
case "-h":
|
|
15
|
+
printHelp();
|
|
16
|
+
break;
|
|
17
|
+
case "version":
|
|
18
|
+
case "--version":
|
|
19
|
+
case "-v":
|
|
20
|
+
printVersion();
|
|
21
|
+
break;
|
|
22
|
+
default:
|
|
23
|
+
console.error(`[codex-bridge] Unknown command: ${command}`);
|
|
24
|
+
printHelp(1);
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function runServer(args) {
|
|
29
|
+
const options = parseArgs(args);
|
|
30
|
+
|
|
31
|
+
if (options.port) {
|
|
32
|
+
process.env.PORT = String(options.port);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (options.host) {
|
|
36
|
+
process.env.HOST = options.host;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
import(new URL("../server.mjs", import.meta.url).href);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseArgs(args) {
|
|
43
|
+
const options = {
|
|
44
|
+
host: "",
|
|
45
|
+
port: ""
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
49
|
+
const value = args[index];
|
|
50
|
+
|
|
51
|
+
if (value === "up" || value === "start") {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (value === "--host" && args[index + 1]) {
|
|
56
|
+
options.host = args[index + 1];
|
|
57
|
+
index += 1;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if ((value === "--port" || value === "-p") && args[index + 1]) {
|
|
62
|
+
options.port = args[index + 1];
|
|
63
|
+
index += 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return options;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveCommand(firstArg) {
|
|
71
|
+
if (!firstArg) {
|
|
72
|
+
return "up";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
switch (firstArg) {
|
|
76
|
+
case "help":
|
|
77
|
+
case "--help":
|
|
78
|
+
case "-h":
|
|
79
|
+
return "help";
|
|
80
|
+
case "version":
|
|
81
|
+
case "--version":
|
|
82
|
+
case "-v":
|
|
83
|
+
return "version";
|
|
84
|
+
default:
|
|
85
|
+
return firstArg.startsWith("-") ? "up" : firstArg;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function printHelp(exitCode = 0) {
|
|
90
|
+
const lines = [
|
|
91
|
+
"Usage:",
|
|
92
|
+
" codex-bridge up [--host 127.0.0.1] [--port 8787]",
|
|
93
|
+
" codex-bridge start [--host 127.0.0.1] [--port 8787]",
|
|
94
|
+
" codex-bridge help",
|
|
95
|
+
" codex-bridge version"
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const stream = exitCode === 0 ? process.stdout : process.stderr;
|
|
99
|
+
stream.write(`${lines.join("\n")}\n`);
|
|
100
|
+
process.exit(exitCode);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function printVersion() {
|
|
104
|
+
process.stdout.write("0.1.0\n");
|
|
105
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@btatum5/codex-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Codex desktop companion bridge for the iOS controller app.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"codex-bridge": "bin/codex-bridge.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"server.mjs",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"start": "node ./bin/codex-bridge.js up"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/server.mjs
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
|
|
3
|
+
const args = process.argv.slice(2);
|
|
4
|
+
const host = resolveHost(args);
|
|
5
|
+
const port = Number(process.env.PORT || 8787);
|
|
6
|
+
|
|
7
|
+
const state = {
|
|
8
|
+
status: "ready",
|
|
9
|
+
desktopName: "Windows 11 Codex",
|
|
10
|
+
sessionName: "Primary Session",
|
|
11
|
+
workspacePath: "C:\\Users\\bobby\\source\\project",
|
|
12
|
+
model: "gpt-5.4",
|
|
13
|
+
reasoningEffort: "high",
|
|
14
|
+
approvalMode: "confirm_sensitive",
|
|
15
|
+
fileAccess: "workspace_only",
|
|
16
|
+
allowShellCommands: true,
|
|
17
|
+
networkAccess: true,
|
|
18
|
+
syncDesktopTranscript: true,
|
|
19
|
+
activeTask: "Waiting for instructions from iPhone.",
|
|
20
|
+
lastAssistantReply: "Desktop companion online.",
|
|
21
|
+
lastMessage: null,
|
|
22
|
+
lastUpdatedAt: new Date().toISOString(),
|
|
23
|
+
pendingApprovals: [
|
|
24
|
+
{
|
|
25
|
+
id: "approval-write-workspace",
|
|
26
|
+
title: "Approve Workspace Write",
|
|
27
|
+
summary: "Codex wants permission to write changes into the active workspace.",
|
|
28
|
+
command: "apply_patch workspace files",
|
|
29
|
+
risk: "medium"
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
openFiles: [
|
|
33
|
+
{ path: "C:\\Users\\bobby\\source\\project\\README.md", status: "active" },
|
|
34
|
+
{ path: "C:\\Users\\bobby\\source\\project\\src\\App.tsx", status: "modified" },
|
|
35
|
+
{ path: "C:\\Users\\bobby\\source\\project\\package.json", status: "tracked" }
|
|
36
|
+
],
|
|
37
|
+
terminalLines: [
|
|
38
|
+
line("info", "$ codex --session \"Primary Session\""),
|
|
39
|
+
line("info", "Desktop companion booted."),
|
|
40
|
+
line("success", "Waiting for remote controller input.")
|
|
41
|
+
]
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const server = createServer(async (req, res) => {
|
|
45
|
+
try {
|
|
46
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
47
|
+
|
|
48
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
49
|
+
return sendJson(res, 200, {
|
|
50
|
+
status: state.status,
|
|
51
|
+
message: "desktop companion online",
|
|
52
|
+
session: state.sessionName,
|
|
53
|
+
desktopName: state.desktopName,
|
|
54
|
+
workspacePath: state.workspacePath,
|
|
55
|
+
activeTask: state.activeTask,
|
|
56
|
+
pendingApprovals: state.pendingApprovals.length
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (req.method === "GET" && url.pathname === "/v1/companion/session") {
|
|
61
|
+
return sendJson(res, 200, snapshot());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (req.method === "POST" && url.pathname === "/v1/companion/settings") {
|
|
65
|
+
const body = await readJson(req);
|
|
66
|
+
applySettings(body);
|
|
67
|
+
state.status = "ready";
|
|
68
|
+
state.activeTask = "Desktop settings updated from iPhone.";
|
|
69
|
+
state.lastAssistantReply = "Settings synced to the desktop companion.";
|
|
70
|
+
appendTerminal("success", "Applied configuration update from iPhone.");
|
|
71
|
+
|
|
72
|
+
return sendJson(res, 200, {
|
|
73
|
+
message: "desktop companion applied the settings update",
|
|
74
|
+
state: snapshot()
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (req.method === "POST" && url.pathname === "/v1/companion/actions") {
|
|
79
|
+
const body = await readJson(req);
|
|
80
|
+
const result = applyAction(body);
|
|
81
|
+
|
|
82
|
+
return sendJson(res, 200, {
|
|
83
|
+
message: result,
|
|
84
|
+
state: snapshot()
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (req.method === "POST" && url.pathname === "/v1/companion/chat") {
|
|
89
|
+
const body = await readJson(req);
|
|
90
|
+
applySettings(body);
|
|
91
|
+
|
|
92
|
+
state.status = "working";
|
|
93
|
+
state.lastMessage = body.message || null;
|
|
94
|
+
state.activeTask = body.message || "Executing desktop task from iPhone.";
|
|
95
|
+
state.lastAssistantReply = "Desktop companion synced the latest controller instruction.";
|
|
96
|
+
state.lastUpdatedAt = new Date().toISOString();
|
|
97
|
+
|
|
98
|
+
if (body.message) {
|
|
99
|
+
appendTerminal("info", `$ codex prompt "${body.message}"`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
maybeQueueApproval(body.message);
|
|
103
|
+
|
|
104
|
+
const reply = [
|
|
105
|
+
`Desktop target: ${state.desktopName}`,
|
|
106
|
+
`Workspace: ${state.workspacePath}`,
|
|
107
|
+
`Model: ${state.model}`,
|
|
108
|
+
`Reasoning: ${state.reasoningEffort}`,
|
|
109
|
+
`Approval: ${state.approvalMode}`,
|
|
110
|
+
`Files: ${state.fileAccess}`,
|
|
111
|
+
`Shell: ${state.allowShellCommands ? "enabled" : "disabled"}`,
|
|
112
|
+
`Network: ${state.networkAccess ? "enabled" : "disabled"}`,
|
|
113
|
+
`Pending approvals: ${state.pendingApprovals.length}`,
|
|
114
|
+
"",
|
|
115
|
+
`Received: ${body.message || "No message"}`
|
|
116
|
+
].join("\n");
|
|
117
|
+
|
|
118
|
+
return sendJson(res, 200, {
|
|
119
|
+
reply,
|
|
120
|
+
session: state.sessionName,
|
|
121
|
+
message: "desktop companion synced the live controller state",
|
|
122
|
+
state: snapshot()
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
sendJson(res, 404, { error: "not_found" });
|
|
127
|
+
} catch (error) {
|
|
128
|
+
sendJson(res, 500, {
|
|
129
|
+
error: "internal_error",
|
|
130
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
server.listen(port, host, () => {
|
|
136
|
+
console.log(`Codex desktop companion listening on http://${host}:${port}`);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
function resolveHost(args) {
|
|
140
|
+
const fromEnv = process.env.HOST?.trim();
|
|
141
|
+
if (fromEnv) {
|
|
142
|
+
return fromEnv;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const hostFlagIndex = args.findIndex((value) => value === "--host");
|
|
146
|
+
if (hostFlagIndex >= 0 && args[hostFlagIndex + 1]) {
|
|
147
|
+
return args[hostFlagIndex + 1];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return "127.0.0.1";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function snapshot() {
|
|
154
|
+
return {
|
|
155
|
+
status: state.status,
|
|
156
|
+
desktopName: state.desktopName,
|
|
157
|
+
sessionName: state.sessionName,
|
|
158
|
+
workspacePath: state.workspacePath,
|
|
159
|
+
model: state.model,
|
|
160
|
+
reasoningEffort: state.reasoningEffort,
|
|
161
|
+
approvalMode: state.approvalMode,
|
|
162
|
+
fileAccess: state.fileAccess,
|
|
163
|
+
allowShellCommands: state.allowShellCommands,
|
|
164
|
+
networkAccess: state.networkAccess,
|
|
165
|
+
syncDesktopTranscript: state.syncDesktopTranscript,
|
|
166
|
+
activeTask: state.activeTask,
|
|
167
|
+
lastAssistantReply: state.lastAssistantReply,
|
|
168
|
+
lastUpdatedAt: state.lastUpdatedAt,
|
|
169
|
+
pendingApprovals: state.pendingApprovals,
|
|
170
|
+
openFiles: state.openFiles,
|
|
171
|
+
terminalLines: state.terminalLines
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function applySettings(body) {
|
|
176
|
+
state.desktopName = body.desktopName || state.desktopName;
|
|
177
|
+
state.sessionName = body.sessionName || state.sessionName;
|
|
178
|
+
state.workspacePath = body.workspacePath || state.workspacePath;
|
|
179
|
+
state.model = body.model || state.model;
|
|
180
|
+
state.reasoningEffort = body.reasoningEffort || state.reasoningEffort;
|
|
181
|
+
state.approvalMode = body.approvalMode || state.approvalMode;
|
|
182
|
+
state.fileAccess = body.fileAccess || state.fileAccess;
|
|
183
|
+
state.allowShellCommands = body.allowShellCommands ?? state.allowShellCommands;
|
|
184
|
+
state.networkAccess = body.networkAccess ?? state.networkAccess;
|
|
185
|
+
state.syncDesktopTranscript = body.syncDesktopTranscript ?? state.syncDesktopTranscript;
|
|
186
|
+
state.lastUpdatedAt = new Date().toISOString();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function applyAction(body) {
|
|
190
|
+
const action = body.action || "refresh";
|
|
191
|
+
const approvalId = body.approvalId || null;
|
|
192
|
+
|
|
193
|
+
switch (action) {
|
|
194
|
+
case "interrupt":
|
|
195
|
+
state.status = "interrupted";
|
|
196
|
+
state.activeTask = "Execution interrupted from iPhone.";
|
|
197
|
+
state.lastAssistantReply = "Desktop task interrupted.";
|
|
198
|
+
appendTerminal("warning", "Interrupt received from iPhone.");
|
|
199
|
+
break;
|
|
200
|
+
case "resume":
|
|
201
|
+
state.status = "working";
|
|
202
|
+
state.activeTask = "Execution resumed from iPhone.";
|
|
203
|
+
state.lastAssistantReply = "Desktop task resumed.";
|
|
204
|
+
appendTerminal("success", "Resume received from iPhone.");
|
|
205
|
+
break;
|
|
206
|
+
case "approve":
|
|
207
|
+
removeApproval(approvalId);
|
|
208
|
+
state.status = "working";
|
|
209
|
+
state.activeTask = "Sensitive action approved from iPhone.";
|
|
210
|
+
state.lastAssistantReply = "Approval granted.";
|
|
211
|
+
appendTerminal("success", `Approved request ${approvalId || "unknown"}.`);
|
|
212
|
+
break;
|
|
213
|
+
case "reject":
|
|
214
|
+
removeApproval(approvalId);
|
|
215
|
+
state.status = "ready";
|
|
216
|
+
state.activeTask = "Sensitive action rejected from iPhone.";
|
|
217
|
+
state.lastAssistantReply = "Approval rejected.";
|
|
218
|
+
appendTerminal("warning", `Rejected request ${approvalId || "unknown"}.`);
|
|
219
|
+
break;
|
|
220
|
+
default:
|
|
221
|
+
state.lastAssistantReply = "Desktop state refreshed.";
|
|
222
|
+
appendTerminal("info", "State refresh requested from iPhone.");
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
state.lastUpdatedAt = new Date().toISOString();
|
|
227
|
+
return `desktop companion applied action: ${action}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function maybeQueueApproval(message) {
|
|
231
|
+
if (!message) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const lowered = String(message).toLowerCase();
|
|
236
|
+
if (!/(delete|remove|install|registry|system32|powershell)/.test(lowered)) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const id = `approval-${Date.now()}`;
|
|
241
|
+
state.pendingApprovals.unshift({
|
|
242
|
+
id,
|
|
243
|
+
title: "Approve Sensitive Command",
|
|
244
|
+
summary: "The desktop session wants to run a potentially sensitive command and is waiting for phone approval.",
|
|
245
|
+
command: message,
|
|
246
|
+
risk: "high"
|
|
247
|
+
});
|
|
248
|
+
state.pendingApprovals = state.pendingApprovals.slice(0, 4);
|
|
249
|
+
appendTerminal("warning", `Queued approval ${id} for sensitive instruction.`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function removeApproval(approvalId) {
|
|
253
|
+
if (!approvalId) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
state.pendingApprovals = state.pendingApprovals.filter((approval) => approval.id !== approvalId);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function appendTerminal(level, text) {
|
|
260
|
+
state.terminalLines.unshift(line(level, text));
|
|
261
|
+
state.terminalLines = state.terminalLines.slice(0, 8);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function line(level, text) {
|
|
265
|
+
return {
|
|
266
|
+
id: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
|
267
|
+
level,
|
|
268
|
+
text
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function sendJson(res, statusCode, body) {
|
|
273
|
+
res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
|
|
274
|
+
res.end(JSON.stringify(body, null, 2));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function readJson(req) {
|
|
278
|
+
const chunks = [];
|
|
279
|
+
|
|
280
|
+
for await (const chunk of req) {
|
|
281
|
+
chunks.push(chunk);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (chunks.length === 0) {
|
|
285
|
+
return {};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
289
|
+
return JSON.parse(raw);
|
|
290
|
+
}
|