replica-bridge 0.2.0 → 0.3.2
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/package.json +1 -1
- package/src/bridge/enroll.js +1 -1
- package/src/bridge/service.js +27 -5
- package/src/bridge/writer.js +67 -0
- package/src/relay/config.js +27 -0
package/package.json
CHANGED
package/src/bridge/enroll.js
CHANGED
|
@@ -8,7 +8,7 @@ import { loadState, saveState } from "./state.js";
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
/** The project's public relay, which the app also signs in to by default (Sources/Core/RelayPairing.swift). */
|
|
11
|
-
export const PUBLIC_RELAY = "https://
|
|
11
|
+
export const PUBLIC_RELAY = "https://agensrelay.office-cli.onl";
|
|
12
12
|
|
|
13
13
|
/** With no --relay: REPLICA_RELAY, else the relay on this machine over loopback, else the public relay. */
|
|
14
14
|
export function defaultRelayURL() {
|
package/src/bridge/service.js
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
import { relaySocketURL } from "../http.js";
|
|
17
17
|
import { killCommand, spawnCommand } from "./command.js";
|
|
18
18
|
import { hostKeyPair, loadState } from "./state.js";
|
|
19
|
+
import { annotateWriterError } from "./writer.js";
|
|
20
|
+
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
|
|
@@ -33,6 +35,8 @@ import { hostKeyPair, loadState } from "./state.js";
|
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
|
|
38
|
+
|
|
39
|
+
|
|
36
40
|
|
|
37
41
|
|
|
38
42
|
|
|
@@ -44,6 +48,8 @@ import { hostKeyPair, loadState } from "./state.js";
|
|
|
44
48
|
|
|
45
49
|
|
|
46
50
|
|
|
51
|
+
|
|
52
|
+
|
|
47
53
|
|
|
48
54
|
|
|
49
55
|
|
|
@@ -94,7 +100,14 @@ export class BridgeService {
|
|
|
94
100
|
try { envelope = JSON.parse(data.toString()); } catch { return; }
|
|
95
101
|
this.onEnvelope(envelope);
|
|
96
102
|
});
|
|
97
|
-
|
|
103
|
+
// Something other than the relay answered (a web page where the relay used to be, a proxy error, a revoked
|
|
104
|
+
// token). With this listener ws leaves the socket half-open and emits no close, so nothing retried and the
|
|
105
|
+
// process ran out of work and exited 0, which the background services read as "replaced" and left stopped.
|
|
106
|
+
// Ending the socket goes through close and the usual backoff instead.
|
|
107
|
+
ws.on("unexpected-response", (_req, res) => {
|
|
108
|
+
this.log(`relay refused the host connection: HTTP ${res.statusCode}; retrying`);
|
|
109
|
+
ws.terminate();
|
|
110
|
+
});
|
|
98
111
|
ws.on("error", (error) => this.log(`relay connection error: ${error.message}`));
|
|
99
112
|
ws.on("close", (code) => {
|
|
100
113
|
if (ws !== this.relay) return; // a socket this bridge already replaced itself
|
|
@@ -187,6 +200,10 @@ export class BridgeService {
|
|
|
187
200
|
this.scheduleAck(stream);
|
|
188
201
|
} else if (message.t === "ack") {
|
|
189
202
|
conn.stream?.out.ack(message.seq);
|
|
203
|
+
} else if (message.t === "closed") {
|
|
204
|
+
// The phone is done with this stream: stop its proxy now, so the app-server unloads the threads it followed and
|
|
205
|
+
// the computer's other Codex apps can open them, rather than holding them for streamTTL.
|
|
206
|
+
if (conn.stream) this.endStream(conn.stream, "the phone closed the stream");
|
|
190
207
|
}
|
|
191
208
|
}
|
|
192
209
|
|
|
@@ -213,12 +230,17 @@ export class BridgeService {
|
|
|
213
230
|
|
|
214
231
|
async openStream(deviceId ) {
|
|
215
232
|
const appServer = await this.connectAppServer();
|
|
216
|
-
const stream = { id: crypto.randomBytes(12).toString("base64url"), deviceId, appServer, out: new StreamBuffer(), in: new StreamBuffer(), conn: null, expiry: null, ackTimer: null };
|
|
233
|
+
const stream = { id: crypto.randomBytes(12).toString("base64url"), deviceId, appServer, out: new StreamBuffer(), in: new StreamBuffer(), conn: null, expiry: null, ackTimer: null, relayed: Promise.resolve() };
|
|
217
234
|
this.streams.set(stream.id, stream);
|
|
218
235
|
appServer.on("message", (data ) => {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
236
|
+
const annotated = annotateWriterError(data.toString(), this.options.findWriter);
|
|
237
|
+
stream.relayed = stream.relayed.then(async () => {
|
|
238
|
+
const line = await annotated;
|
|
239
|
+
if (!this.streams.has(stream.id)) return;
|
|
240
|
+
let item;
|
|
241
|
+
try { item = stream.out.push(line); } catch { return this.endStream(stream, "the phone stayed away too long and the buffer filled up"); }
|
|
242
|
+
if (stream.conn) this.sendInner(stream.conn, { t: "msg", seq: item.seq, line: item.line });
|
|
243
|
+
});
|
|
222
244
|
});
|
|
223
245
|
appServer.on("close", () => this.endStream(stream, "codex app-server closed the connection"));
|
|
224
246
|
appServer.on("error", (error) => this.log(`app-server stream ${stream.id}: ${error.message}`));
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Which app holds a thread when the app-server answers "thread <id> already has an active writer". Codex lets one
|
|
2
|
+
// process write a thread at a time through a lock file, `$CODEX_HOME/thread-writer-locks/<id>.lock`, and the error
|
|
3
|
+
// does not say who has it. On the computer the bridge can: whoever has the lock file open. The answer goes back to
|
|
4
|
+
// the phone in the error's `data.replicaWriter`, so the app can say "the ChatGPT desktop app has this task" rather
|
|
5
|
+
// than "another Codex session". Only macOS and Linux (lsof); elsewhere, or when lsof finds nothing, the error passes
|
|
6
|
+
// through unchanged.
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
/** The app holding a thread's lock, as the phone reads it. */
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
const ACTIVE_WRITER = /^thread ([0-9A-Za-z-]+) already has an active writer/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The app behind a process's command line. The ChatGPT desktop app runs its own app-server from inside its bundle
|
|
23
|
+
* (`/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/…/codex app-server`, observed with
|
|
24
|
+
* ChatGPT 155.0.8059.80); `codex exec` runs one for the length of the command.
|
|
25
|
+
*/
|
|
26
|
+
export function classifyWriter(command ) {
|
|
27
|
+
if (/\/(ChatGPT|Codex)\.app\//.test(command)) return "chatgpt-desktop";
|
|
28
|
+
if (/(^|[\s/])codex(\.exe)?\s+(.*\s)?exec(\s|$)/.test(command)) return "codex-exec";
|
|
29
|
+
if (/(^|[\s/])codex(\.exe)?(\s|$)/.test(command)) return "codex-cli";
|
|
30
|
+
return "other";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function run(command , args ) {
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
execFile(command, args, { timeout: 3000 }, (_error, stdout) => resolve(String(stdout ?? "")));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Who has the thread's lock file open; null when nobody does or it cannot be told on this platform. */
|
|
40
|
+
export async function findWriter(threadId , codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex")) {
|
|
41
|
+
if (process.platform === "win32") return null;
|
|
42
|
+
const lock = path.join(codexHome, "thread-writer-locks", `${threadId}.lock`);
|
|
43
|
+
const pids = (await run("lsof", ["-t", "--", lock])).split(/\s+/).map(Number).filter((pid) => pid > 0 && pid !== process.pid);
|
|
44
|
+
for (const pid of pids) {
|
|
45
|
+
const command = (await run("ps", ["-o", "command=", "-p", String(pid)])).trim();
|
|
46
|
+
if (command) return { app: classifyWriter(command), pid };
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* An app-server line with the writer added to an "already has an active writer" error, or the line itself. Lines
|
|
53
|
+
* that cannot be such an error are not parsed.
|
|
54
|
+
*/
|
|
55
|
+
export async function annotateWriterError(line , find = findWriter) {
|
|
56
|
+
if (!line.includes("already has an active writer")) return line;
|
|
57
|
+
let message;
|
|
58
|
+
try { message = JSON.parse(line); } catch { return line; }
|
|
59
|
+
const error = message?.error;
|
|
60
|
+
const threadId = typeof error?.message === "string" ? ACTIVE_WRITER.exec(error.message)?.[1] : undefined;
|
|
61
|
+
if (!threadId) return line;
|
|
62
|
+
const writer = await find(threadId).catch(() => null);
|
|
63
|
+
if (!writer) return line;
|
|
64
|
+
const data = error.data && typeof error.data === "object" && !Array.isArray(error.data) ? error.data : {};
|
|
65
|
+
message.error = { ...error, data: { ...data, replicaWriter: writer } };
|
|
66
|
+
return JSON.stringify(message);
|
|
67
|
+
}
|
package/src/relay/config.js
CHANGED
|
@@ -40,6 +40,8 @@ export function readEnvFile(file ) {
|
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
|
|
44
|
+
|
|
43
45
|
|
|
44
46
|
|
|
45
47
|
/** "github:583231, 12345, *" → ["583231", "12345", "*"]; anything that is not a numeric id or * is refused. */
|
|
@@ -51,6 +53,30 @@ export function parseAllowedIds(text ) {
|
|
|
51
53
|
});
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
/** "~/repos/minim/deploy/minim.env:MINIM_ALLOW" → { file, name }. */
|
|
57
|
+
export function parseAllowedFrom(text ) {
|
|
58
|
+
const colon = text.lastIndexOf(":");
|
|
59
|
+
const file = text.slice(0, colon).trim();
|
|
60
|
+
const name = text.slice(colon + 1).trim();
|
|
61
|
+
if (colon < 1 || !file || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`GITHUB_ALLOWED_USERS_FROM takes <env file>:<VARIABLE>, not "${text}"`);
|
|
62
|
+
return { file: expandHome(file), name };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The allowed GitHub ids: GITHUB_ALLOWED_USERS plus, when set, the list in GITHUB_ALLOWED_USERS_FROM, read again on
|
|
67
|
+
* every call so editing that file takes effect without a restart. A missing file or variable throws (nobody gets in).
|
|
68
|
+
*/
|
|
69
|
+
export function allowedIdsReader(config ) {
|
|
70
|
+
const from = config.githubAllowedFrom;
|
|
71
|
+
if (!from) return () => config.githubAllowedIds;
|
|
72
|
+
return () => {
|
|
73
|
+
if (!fs.existsSync(from.file)) throw new Error(`GITHUB_ALLOWED_USERS_FROM: ${from.file} does not exist`);
|
|
74
|
+
const list = readEnvFile(from.file)[from.name];
|
|
75
|
+
if (list === undefined) throw new Error(`GITHUB_ALLOWED_USERS_FROM: ${from.file} has no ${from.name}`);
|
|
76
|
+
return [...new Set([...config.githubAllowedIds, ...parseAllowedIds(list)])];
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
54
80
|
const expandHome = (value ) => (value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value);
|
|
55
81
|
|
|
56
82
|
export function loadRelayConfig(envPath = defaultEnvPath()) {
|
|
@@ -72,5 +98,6 @@ export function loadRelayConfig(envPath = defaultEnvPath()) {
|
|
|
72
98
|
githubClientSecret: get("GITHUB_CLIENT_SECRET"),
|
|
73
99
|
githubCallbackUrl: get("GITHUB_CALLBACK_URL"),
|
|
74
100
|
githubAllowedIds: parseAllowedIds(get("GITHUB_ALLOWED_USERS") ?? ""),
|
|
101
|
+
githubAllowedFrom: get("GITHUB_ALLOWED_USERS_FROM") ? parseAllowedFrom(get("GITHUB_ALLOWED_USERS_FROM") ) : null,
|
|
75
102
|
};
|
|
76
103
|
}
|