@minhspark/codex-mcp-bridge 1.19.6 → 1.20.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/.codex-plugin/plugin.json +26 -0
- package/.mcp.json +7 -0
- package/CHANGELOG.md +16 -0
- package/README.md +43 -1
- package/assets/plugin-icon.svg +5 -0
- package/mcp.json +9 -0
- package/package.json +10 -3
- package/plugin.json +33 -0
- package/scripts/install-native-relay.mjs +1 -1
- package/scripts/publish-repo-analytics.mjs +74 -26
- package/scripts/sync-version.mjs +7 -0
- package/skills/codex-bridge/SKILL.md +19 -0
- package/src/claude-bridge.mjs +54 -17
- package/src/claude-composer-submit.mjs +102 -0
- package/src/claude-composer-submit.ps1 +211 -0
- package/src/claude-session-creation.mjs +53 -7
- package/src/claude-session-router.mjs +31 -0
- package/src/index.mjs +1 -1
- package/src/native-relay-companion.mjs +1 -1
- package/src/platform.mjs +5 -3
- package/src/runtime-state.mjs +1 -1
- package/src/sites-connector.mjs +410 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createHash, randomUUID, randomBytes } from "node:crypto";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { BridgeSecurityPolicy } from "./security-policy.mjs";
|
|
12
|
+
import { DesktopTaskDelivery } from "./thread-delivery.mjs";
|
|
13
|
+
import { DesktopTaskReceipts } from "./desktop-task-receipts.mjs";
|
|
14
|
+
import { NativeDesktopRelay, resolveRelayThreadId } from "./native-relay.mjs";
|
|
15
|
+
import { createNativeScopeAuthorizer } from "./native-relay-companion.mjs";
|
|
16
|
+
import { assertAccountIdentity, readBridgeAccounts, requireBridgeAccounts } from "./bridge-account-context.mjs";
|
|
17
|
+
import { exitForVersionRequest } from "./cli-version.mjs";
|
|
18
|
+
|
|
19
|
+
exitForVersionRequest(import.meta.url);
|
|
20
|
+
|
|
21
|
+
const text = z.string().min(1).max(8192);
|
|
22
|
+
const uuid = z.string().uuid();
|
|
23
|
+
const accountSchema = z.object({ claude: z.string().regex(/^[a-f0-9]{64}$/), codex: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
|
|
24
|
+
const configSchema = z.object({
|
|
25
|
+
siteUrl: z.string().url(), serviceToken: text.optional(), connectorId: uuid, connectorToken: z.string().regex(/^[A-Za-z0-9_-]{43,128}$/),
|
|
26
|
+
ownerEmail: z.string().email().optional(), allowedRoots: z.array(text).min(1).max(64), accountContext: accountSchema,
|
|
27
|
+
}).strict();
|
|
28
|
+
const toolSchemas = {
|
|
29
|
+
list_codex_projects: z.object({}).strict(),
|
|
30
|
+
list_codex_threads: z.object({ cwd: text.optional(), limit: z.number().int().min(1).max(50).optional() }).strict(),
|
|
31
|
+
read_codex_thread: z.object({ threadId: text, turnLimit: z.number().int().min(1).max(10).optional() }).strict(),
|
|
32
|
+
start_codex_thread: z.object({ cwd: text, prompt: z.string().min(1).max(60000), title: z.string().min(1).max(200), requestId: uuid }).strict(),
|
|
33
|
+
send_to_codex_thread: z.object({ threadId: text, prompt: z.string().min(1).max(60000), requestId: uuid }).strict(),
|
|
34
|
+
};
|
|
35
|
+
const mutations = new Set(["start_codex_thread", "send_to_codex_thread"]);
|
|
36
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
37
|
+
const result = (value, isError = false) => ({ content: [{ type: "text", text: JSON.stringify(value) }], structuredContent: value, ...(isError ? { isError: true } : {}) });
|
|
38
|
+
const failed = (state, message) => result({ state, message }, true);
|
|
39
|
+
|
|
40
|
+
export function validateSitesConfig(value, { allowLoopback = false } = {}) {
|
|
41
|
+
const config = configSchema.parse(value);
|
|
42
|
+
const url = new URL(config.siteUrl);
|
|
43
|
+
const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
|
|
44
|
+
if (url.protocol !== "https:" && !(allowLoopback && loopback && url.protocol === "http:")) throw new Error("Sites connector requires an HTTPS site URL");
|
|
45
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== "/") throw new Error("Sites URL must be an origin without credentials, path, query or fragment");
|
|
46
|
+
if ([config.serviceToken, config.connectorToken].some((token) => token && /[\r\n]/.test(token))) throw new Error("Connector credentials contain invalid characters");
|
|
47
|
+
if (config.allowedRoots.some((root) => !path.isAbsolute(root) || root.includes(path.delimiter))) throw new Error("Allowed roots must be absolute directories without a path-list separator");
|
|
48
|
+
return Object.freeze({ ...config, siteUrl: url.origin, allowedRoots: Object.freeze([...config.allowedRoots]), accountContext: Object.freeze({ ...config.accountContext }) });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function readPrivateJson(file, maxBytes = 2 * 1024 * 1024) {
|
|
52
|
+
const info = await fs.lstat(file);
|
|
53
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > maxBytes) throw new Error("Private connector file is unsafe or oversized");
|
|
54
|
+
const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
55
|
+
try {
|
|
56
|
+
const current = await handle.stat();
|
|
57
|
+
if (!current.isFile() || current.nlink !== 1 || current.ino !== info.ino || current.dev !== info.dev || current.size > maxBytes) throw new Error("Private connector file changed during access");
|
|
58
|
+
return JSON.parse(await handle.readFile("utf8"));
|
|
59
|
+
} finally { await handle.close(); }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function loadSitesConfig(file, options) {
|
|
63
|
+
if (!path.isAbsolute(file)) throw new Error("--config requires an absolute filename");
|
|
64
|
+
return validateSitesConfig(await readPrivateJson(file, 65536), options);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function defaultSitesConfigPath(env = process.env) {
|
|
68
|
+
return path.join(env.CODEX_HOME ?? path.join(os.homedir(), ".codex"), "sites-bridge", "connector.json");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function protectPrivatePath(target, directory = false) {
|
|
72
|
+
if (process.platform !== "win32") { await fs.chmod(target, directory ? 0o700 : 0o600); return; }
|
|
73
|
+
const run = promisify(execFile);
|
|
74
|
+
const { stdout } = await run("whoami.exe", ["/user", "/fo", "csv", "/nh"], { windowsHide: true });
|
|
75
|
+
const sid = stdout.match(/S-1-\d+(?:-\d+)+/)?.[0];
|
|
76
|
+
if (!sid) throw new Error("Current Windows user SID could not be verified");
|
|
77
|
+
await run("icacls.exe", [target, "/inheritance:r", "/grant:r", `*${sid}:${directory ? "(OI)(CI)" : ""}(F)`], { windowsHide: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function pairSitesConnector({ siteUrl, allowedRoots, configFile = defaultSitesConfigPath(), readAccounts = readBridgeAccounts, fetchImpl = fetch, allowLoopback = false }) {
|
|
81
|
+
if (!path.isAbsolute(configFile)) throw new Error("Pairing configuration path must be absolute");
|
|
82
|
+
const accounts = requireBridgeAccounts(readAccounts());
|
|
83
|
+
let config;
|
|
84
|
+
try {
|
|
85
|
+
config = await loadSitesConfig(configFile, { allowLoopback });
|
|
86
|
+
if (config.siteUrl !== new URL(siteUrl).origin || JSON.stringify(config.allowedRoots) !== JSON.stringify(allowedRoots)) throw new Error("Existing pairing belongs to a different Site or allowed roots; use a separate private config file");
|
|
87
|
+
assertAccountIdentity(config.accountContext, readAccounts());
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error.code !== "ENOENT") throw error;
|
|
90
|
+
config = validateSitesConfig({ siteUrl, connectorId: randomUUID(), connectorToken: randomBytes(32).toString("base64url"), allowedRoots, accountContext: accounts }, { allowLoopback });
|
|
91
|
+
new BridgeSecurityPolicy(sitesHardenedEnv(config));
|
|
92
|
+
const directory = path.dirname(configFile);
|
|
93
|
+
const created = await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
94
|
+
if ((await fs.lstat(directory)).isSymbolicLink()) throw new Error("Pairing configuration directory is unsafe");
|
|
95
|
+
if (created !== undefined) await protectPrivatePath(directory, true);
|
|
96
|
+
const handle = await fs.open(configFile, "wx", 0o600);
|
|
97
|
+
try { await handle.writeFile(JSON.stringify(config, null, 2)); await handle.sync(); } finally { await handle.close(); }
|
|
98
|
+
await protectPrivatePath(configFile);
|
|
99
|
+
}
|
|
100
|
+
const client = new SitesConnector({ config, fetchImpl });
|
|
101
|
+
const paired = await client.post("/agent/pair/start", { connectorId: config.connectorId, connectorToken: config.connectorToken });
|
|
102
|
+
if (typeof paired.pairingUrl !== "string") throw new Error("Sites did not return a pairing URL");
|
|
103
|
+
const url = new URL(paired.pairingUrl);
|
|
104
|
+
if (url.origin !== config.siteUrl || url.pathname !== "/connect" || url.searchParams.get("code") !== config.connectorId) throw new Error("Sites returned an unexpected pairing destination");
|
|
105
|
+
assertAccountIdentity(config.accountContext, readAccounts());
|
|
106
|
+
return { config, configFile, pairingUrl: url.href };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function validateSitesJob(value, { requireExpiry = false } = {}) {
|
|
110
|
+
const envelope = z.object({ id: z.string().min(1).max(128), tool: z.string(), arguments: z.record(z.unknown()), requestId: uuid.nullish(), expiresAt: z.number().int().positive().optional() }).strict().parse(value);
|
|
111
|
+
if (requireExpiry && envelope.expiresAt === undefined) throw new Error("Job has no dispatch deadline");
|
|
112
|
+
const schema = toolSchemas[envelope.tool];
|
|
113
|
+
if (!schema) throw new Error("Unsupported Sites connector tool");
|
|
114
|
+
const arguments_ = schema.parse(envelope.arguments);
|
|
115
|
+
if (mutations.has(envelope.tool) && envelope.requestId && envelope.requestId.toLowerCase() !== arguments_.requestId.toLowerCase()) throw new Error("Job requestId does not match its mutation arguments");
|
|
116
|
+
return { ...envelope, arguments: arguments_ };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function sitesHardenedEnv(config, env = process.env) {
|
|
120
|
+
return { ...env, CODEX_BRIDGE_HARDENED: "1", CODEX_BRIDGE_DESKTOP_TASKS: "1", CODEX_BRIDGE_THREAD_POLICY: "roots", CODEX_BRIDGE_REMAP: "0", CODEX_BRIDGE_AUTOSTART: "0", CODEX_BRIDGE_PATH_MAP: "", CODEX_BRIDGE_ALLOWED_THREADS: "", CODEX_BRIDGE_ALLOWED_ROOTS: config.allowedRoots.join(path.delimiter) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function createSitesDesktopBackend(config, {
|
|
124
|
+
configDirectory, env = process.env, readAccounts = readBridgeAccounts,
|
|
125
|
+
relay, authorizeFactory = createNativeScopeAuthorizer, Delivery = DesktopTaskDelivery,
|
|
126
|
+
resolveExecutor = resolveRelayThreadId, now = Date.now,
|
|
127
|
+
} = {}) {
|
|
128
|
+
const strictEnv = sitesHardenedEnv(config, env);
|
|
129
|
+
const security = new BridgeSecurityPolicy(strictEnv);
|
|
130
|
+
const native = relay ?? new NativeDesktopRelay({ env: strictEnv });
|
|
131
|
+
let operationDeadline = Infinity;
|
|
132
|
+
let remoteBeforeDispatch;
|
|
133
|
+
const checkAccount = () => { assertAccountIdentity(config.accountContext, readAccounts()); };
|
|
134
|
+
const checkDispatch = () => { checkAccount(); if (now() >= operationDeadline) throw new Error("Sites request expired; no further dispatch is allowed"); };
|
|
135
|
+
requireBridgeAccounts(readAccounts());
|
|
136
|
+
checkAccount();
|
|
137
|
+
const metadata = async ({ operation, arguments: args }, options) => {
|
|
138
|
+
checkDispatch();
|
|
139
|
+
const reply = await native.requestDesktop(operation, args, { ...options, timeoutMs: Math.min(30000, operationDeadline - now()), accountContext: config.accountContext, beforeSend: checkDispatch });
|
|
140
|
+
checkAccount();
|
|
141
|
+
return { success: true, structuredContent: reply.result };
|
|
142
|
+
};
|
|
143
|
+
const authorize = authorizeFactory({ env: strictEnv, dispatchDesktop: metadata });
|
|
144
|
+
const guardedRelay = {
|
|
145
|
+
status: (options) => native.status(options),
|
|
146
|
+
requestDesktop: async (operation, args, options = {}) => {
|
|
147
|
+
checkDispatch();
|
|
148
|
+
const executorThreadId = resolveExecutor(strictEnv).threadId;
|
|
149
|
+
const request = { executorThreadId, targetThreadId: args.threadId, operation, arguments: args, accountContext: config.accountContext };
|
|
150
|
+
const expected = await authorize(request);
|
|
151
|
+
const reply = await native.requestDesktop(operation, args, {
|
|
152
|
+
...options, timeoutMs: Math.min(options.timeoutMs ?? 30000, operationDeadline - now()), accountContext: config.accountContext,
|
|
153
|
+
beforeSend: async () => {
|
|
154
|
+
checkDispatch();
|
|
155
|
+
await authorize({ ...request, phase: "write", expected });
|
|
156
|
+
if (["create_thread", "send_message_to_thread", "set_thread_title", "navigate_to_codex_page"].includes(operation)) await remoteBeforeDispatch?.();
|
|
157
|
+
await options.beforeSend?.();
|
|
158
|
+
checkDispatch();
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
checkAccount();
|
|
162
|
+
if (reply.executorThreadId !== executorThreadId) throw new Error("Native relay executor changed during dispatch; outcome is unverified");
|
|
163
|
+
const checked = await authorize({ ...request, phase: "return", expected, result: reply.result });
|
|
164
|
+
checkAccount();
|
|
165
|
+
return { ...reply, result: checked?.result ?? reply.result };
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
const delivery = new Delivery({
|
|
169
|
+
relay: guardedRelay, security, accountContext: () => config.accountContext,
|
|
170
|
+
beforeRequest: checkAccount, now,
|
|
171
|
+
...(configDirectory ? { receipts: new DesktopTaskReceipts({ directory: path.join(configDirectory, "sites-task-receipts") }) } : {}),
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
checkAccount,
|
|
175
|
+
async execute(job, { beforeDispatch } = {}) {
|
|
176
|
+
checkAccount();
|
|
177
|
+
remoteBeforeDispatch = beforeDispatch;
|
|
178
|
+
const args = job.arguments;
|
|
179
|
+
const deadline = Math.min(now() + 40000, job.expiresAt ?? Infinity);
|
|
180
|
+
operationDeadline = deadline;
|
|
181
|
+
checkDispatch();
|
|
182
|
+
let value;
|
|
183
|
+
switch (job.tool) {
|
|
184
|
+
case "list_codex_projects": value = await delivery.request("list_projects", {}, { deadline }); break;
|
|
185
|
+
case "list_codex_threads": value = await delivery.list(args); break;
|
|
186
|
+
case "read_codex_thread":
|
|
187
|
+
await delivery.inspect(args.threadId, undefined, { deadline });
|
|
188
|
+
value = await delivery.request("read_thread", { threadId: args.threadId, hostId: "local", turnLimit: args.turnLimit ?? 3 }, { deadline });
|
|
189
|
+
break;
|
|
190
|
+
case "start_codex_thread": value = await delivery.create({ ...args, name: args.title, deadline }); break;
|
|
191
|
+
case "send_to_codex_thread": {
|
|
192
|
+
const sent = await delivery.withThread(args.threadId, () => delivery.send({ ...args, deadline }), { deadline });
|
|
193
|
+
value = { threadId: sent.threadId, cwd: sent.cwd, name: sent.name, previousTurnId: sent.previousTurnId, backend: sent.backend, state: "accepted" };
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
default: throw new Error("Unsupported Sites connector tool");
|
|
197
|
+
}
|
|
198
|
+
checkAccount();
|
|
199
|
+
return result(value);
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export class SitesOperationJournal {
|
|
205
|
+
constructor({ directory, config }) {
|
|
206
|
+
this.directory = directory;
|
|
207
|
+
this.binding = hash(JSON.stringify([config.siteUrl, config.connectorId, config.accountContext, config.allowedRoots]));
|
|
208
|
+
}
|
|
209
|
+
key(job) { return hash(JSON.stringify([this.binding, mutations.has(job.tool) ? job.arguments.requestId.toLowerCase() : job.id])); }
|
|
210
|
+
fingerprint(job) { return hash(JSON.stringify([job.tool, job.arguments])); }
|
|
211
|
+
async prepare() {
|
|
212
|
+
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
213
|
+
const info = await fs.lstat(this.directory);
|
|
214
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Connector journal directory is unsafe");
|
|
215
|
+
this.protection ??= protectPrivatePath(this.directory, true);
|
|
216
|
+
await this.protection;
|
|
217
|
+
}
|
|
218
|
+
async read(key) {
|
|
219
|
+
if (!/^[a-f0-9]{64}$/.test(key)) throw new Error("Invalid journal key");
|
|
220
|
+
try {
|
|
221
|
+
const entry = await readPrivateJson(path.join(this.directory, `${key}.json`));
|
|
222
|
+
if (entry.binding !== this.binding || entry.key !== key || !["started", "finished"].includes(entry.state) || typeof entry.fingerprint !== "string" || typeof entry.id !== "string") throw new Error("Connector journal identity is invalid");
|
|
223
|
+
return entry;
|
|
224
|
+
} catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
225
|
+
}
|
|
226
|
+
async begin(job) {
|
|
227
|
+
await this.prepare();
|
|
228
|
+
const key = this.key(job);
|
|
229
|
+
const entry = { version: 1, key, binding: this.binding, fingerprint: this.fingerprint(job), id: job.id, state: "started", uploaded: false };
|
|
230
|
+
let handle;
|
|
231
|
+
try {
|
|
232
|
+
handle = await fs.open(path.join(this.directory, `${key}.json`), "wx", 0o600);
|
|
233
|
+
await handle.writeFile(JSON.stringify(entry));
|
|
234
|
+
await handle.sync();
|
|
235
|
+
return { entry, fresh: true };
|
|
236
|
+
} catch (error) { if (error.code === "EEXIST") return { entry: await this.read(key), fresh: false }; throw error; }
|
|
237
|
+
finally { await handle?.close(); }
|
|
238
|
+
}
|
|
239
|
+
async save(entry) {
|
|
240
|
+
await this.prepare();
|
|
241
|
+
const temporary = path.join(this.directory, `${entry.key}.${randomUUID()}.tmp`);
|
|
242
|
+
const handle = await fs.open(temporary, "wx", 0o600);
|
|
243
|
+
try { await handle.writeFile(JSON.stringify(entry)); await handle.sync(); } finally { await handle.close(); }
|
|
244
|
+
await fs.rename(temporary, path.join(this.directory, `${entry.key}.json`));
|
|
245
|
+
}
|
|
246
|
+
async pending() {
|
|
247
|
+
await this.prepare();
|
|
248
|
+
const entries = [];
|
|
249
|
+
for (const filename of await fs.readdir(this.directory)) {
|
|
250
|
+
if (!/^[a-f0-9]{64}\.json$/.test(filename)) continue;
|
|
251
|
+
const entry = await this.read(filename.slice(0, -5));
|
|
252
|
+
if (!entry.uploaded) entries.push(entry);
|
|
253
|
+
}
|
|
254
|
+
return entries;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export class SitesConnector {
|
|
259
|
+
constructor({ config, backend, journal, fetchImpl = fetch, timeoutMs = 15000, heartbeatMs = 5000, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), log = () => {} }) {
|
|
260
|
+
this.config = config; this.backend = backend; this.journal = journal;
|
|
261
|
+
this.fetch = fetchImpl; this.timeoutMs = timeoutMs; this.heartbeatMs = heartbeatMs; this.sleep = sleep; this.log = log;
|
|
262
|
+
}
|
|
263
|
+
async post(route, body, signal) {
|
|
264
|
+
const encoded = JSON.stringify(body);
|
|
265
|
+
if (Buffer.byteLength(encoded) > 99000) throw new Error("Sites request exceeded the hosted body limit");
|
|
266
|
+
const response = await this.fetch(`${this.config.siteUrl}${route}`, {
|
|
267
|
+
method: "POST", redirect: "error", signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(this.timeoutMs)]) : AbortSignal.timeout(this.timeoutMs),
|
|
268
|
+
headers: { "Content-Type": "application/json", ...(this.config.serviceToken ? { "OAI-Sites-Authorization": `Bearer ${this.config.serviceToken}` } : {}), Authorization: `Bearer ${this.config.connectorToken}`, "X-Codex-Connector-ID": this.config.connectorId },
|
|
269
|
+
body: encoded,
|
|
270
|
+
});
|
|
271
|
+
if (!response.ok) throw new Error(`Sites endpoint returned HTTP ${response.status}`);
|
|
272
|
+
const chunks = [];
|
|
273
|
+
let bytes = 0;
|
|
274
|
+
if (response.body) {
|
|
275
|
+
const reader = response.body.getReader();
|
|
276
|
+
try {
|
|
277
|
+
while (true) {
|
|
278
|
+
const { value, done } = await reader.read();
|
|
279
|
+
if (done) break;
|
|
280
|
+
bytes += value.byteLength;
|
|
281
|
+
if (bytes > 2 * 1024 * 1024) { await reader.cancel(); throw new Error("Sites response exceeded the size limit"); }
|
|
282
|
+
chunks.push(Buffer.from(value));
|
|
283
|
+
}
|
|
284
|
+
} finally { reader.releaseLock(); }
|
|
285
|
+
}
|
|
286
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
287
|
+
}
|
|
288
|
+
async withHeartbeat(operation) {
|
|
289
|
+
const controller = new AbortController();
|
|
290
|
+
let timer;
|
|
291
|
+
let pending;
|
|
292
|
+
let failure;
|
|
293
|
+
const verify = async () => {
|
|
294
|
+
if (failure) throw failure;
|
|
295
|
+
if (!pending) {
|
|
296
|
+
pending = (async () => {
|
|
297
|
+
this.backend.checkAccount();
|
|
298
|
+
const response = await this.post("/agent/heartbeat", {}, controller.signal);
|
|
299
|
+
if (response.paired !== true) throw new Error("The device pairing is no longer active");
|
|
300
|
+
})().catch((error) => { failure = error; clearInterval(timer); throw error; }).finally(() => { pending = null; });
|
|
301
|
+
}
|
|
302
|
+
return pending;
|
|
303
|
+
};
|
|
304
|
+
try {
|
|
305
|
+
await verify();
|
|
306
|
+
timer = setInterval(() => { void verify().catch(() => {}); }, this.heartbeatMs);
|
|
307
|
+
timer.unref?.();
|
|
308
|
+
return await operation(verify);
|
|
309
|
+
} finally {
|
|
310
|
+
clearInterval(timer);
|
|
311
|
+
controller.abort();
|
|
312
|
+
await pending?.catch(() => {});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
async upload(entry) {
|
|
316
|
+
this.backend.checkAccount();
|
|
317
|
+
await this.post("/agent/result", { id: entry.id, result: entry.result });
|
|
318
|
+
await this.journal.save({ ...entry, uploaded: true });
|
|
319
|
+
}
|
|
320
|
+
async recover() {
|
|
321
|
+
for (let entry of await this.journal.pending()) {
|
|
322
|
+
if (entry.state === "started") {
|
|
323
|
+
entry = { ...entry, state: "finished", result: failed("uncertain", "The connector stopped after claiming this request. It may have dispatched. Inspect the original task; this request will not be replayed.") };
|
|
324
|
+
await this.journal.save(entry);
|
|
325
|
+
}
|
|
326
|
+
await this.withHeartbeat(() => this.upload(entry));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async step() {
|
|
330
|
+
this.backend.checkAccount();
|
|
331
|
+
await this.recover();
|
|
332
|
+
const response = await this.post("/agent/poll", {});
|
|
333
|
+
if (response.job == null) return { state: response.paired === false ? "pairing" : "idle" };
|
|
334
|
+
let job;
|
|
335
|
+
try { job = validateSitesJob(response.job, { requireExpiry: true }); }
|
|
336
|
+
catch {
|
|
337
|
+
if (typeof response.job?.id === "string" && response.job.id.length <= 8192) await this.post("/agent/result", { id: response.job.id, result: failed("blocked", "Invalid or unsupported connector job") });
|
|
338
|
+
return { state: "blocked" };
|
|
339
|
+
}
|
|
340
|
+
if (Date.now() >= job.expiresAt) {
|
|
341
|
+
await this.post("/agent/result", { id: job.id, result: failed("blocked", "This request expired before local dispatch. No operation was sent.") });
|
|
342
|
+
return { state: "blocked" };
|
|
343
|
+
}
|
|
344
|
+
const { entry, fresh } = await this.journal.begin(job);
|
|
345
|
+
if (!fresh && entry.fingerprint !== this.journal.fingerprint(job)) {
|
|
346
|
+
await this.post("/agent/result", { id: job.id, result: failed("blocked", "The requestId belongs to a different operation. No prompt was sent.") });
|
|
347
|
+
return { state: "blocked" };
|
|
348
|
+
}
|
|
349
|
+
return this.withHeartbeat(async (verifyPairing) => {
|
|
350
|
+
let outcome;
|
|
351
|
+
if (!fresh) outcome = entry.result ?? failed("uncertain", "An earlier connector claimed this request; it will not be replayed.");
|
|
352
|
+
else {
|
|
353
|
+
try { this.backend.checkAccount(); outcome = await this.backend.execute(job, { beforeDispatch: verifyPairing }); this.backend.checkAccount(); }
|
|
354
|
+
catch { outcome = failed(mutations.has(job.tool) ? "uncertain" : "blocked", mutations.has(job.tool) ? "The Desktop operation could not be fully verified. It may have dispatched. Inspect the original task before any further send." : "The Desktop operation is unavailable or blocked by local account/project policy."); }
|
|
355
|
+
}
|
|
356
|
+
if (Buffer.byteLength(JSON.stringify(outcome)) > 90000) outcome = failed("result_too_large", "The operation produced a result larger than the hosted response limit. Inspect the original Desktop task. The operation will not be replayed.");
|
|
357
|
+
const finished = { ...entry, id: job.id, state: "finished", result: outcome, uploaded: false };
|
|
358
|
+
await this.journal.save(finished);
|
|
359
|
+
await this.upload(finished);
|
|
360
|
+
return { state: outcome.isError ? outcome.structuredContent?.state ?? "blocked" : "completed", id: job.id };
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
async run({ once = false, signal } = {}) {
|
|
364
|
+
do {
|
|
365
|
+
if (signal?.aborted) break;
|
|
366
|
+
try { const state = await this.step(); this.log(state); if (once) return state; }
|
|
367
|
+
catch { this.log({ state: "offline", message: "Connector paused: Sites transport or local account verification is unavailable. Claimed operations are retained." }); if (once) throw new Error("Connector could not complete its polling cycle; retained operations will not be replayed"); }
|
|
368
|
+
await this.sleep(2000);
|
|
369
|
+
} while (!signal?.aborted);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function runSitesConnector({ configFile, once = false, signal, ...dependencies }) {
|
|
374
|
+
const config = await loadSitesConfig(configFile);
|
|
375
|
+
const directory = path.dirname(configFile);
|
|
376
|
+
const receiptDirectory = path.join(directory, "sites-task-receipts");
|
|
377
|
+
await fs.mkdir(receiptDirectory, { recursive: true, mode: 0o700 });
|
|
378
|
+
if ((await fs.lstat(receiptDirectory)).isSymbolicLink()) throw new Error("Sites task receipt directory is unsafe");
|
|
379
|
+
await protectPrivatePath(receiptDirectory, true);
|
|
380
|
+
const backend = createSitesDesktopBackend(config, { configDirectory: directory, ...dependencies });
|
|
381
|
+
const journal = new SitesOperationJournal({ directory: path.join(directory, "sites-operation-journal"), config });
|
|
382
|
+
return new SitesConnector({ config, backend, journal, log: (state) => process.stderr.write(`[sites-connector] ${JSON.stringify(state)}\n`), ...dependencies }).run({ once, signal });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
386
|
+
const args = process.argv.slice(2);
|
|
387
|
+
const flags = new Map();
|
|
388
|
+
let invalid = false;
|
|
389
|
+
for (let index = 0; index < args.length; index++) {
|
|
390
|
+
const name = args[index];
|
|
391
|
+
if (!["--config", "--site", "--roots", "--pair", "--once"].includes(name) || flags.has(name)) { invalid = true; break; }
|
|
392
|
+
if (["--pair", "--once"].includes(name)) flags.set(name, true);
|
|
393
|
+
else if (!args[index + 1] || args[index + 1].startsWith("--")) { invalid = true; break; }
|
|
394
|
+
else flags.set(name, args[++index]);
|
|
395
|
+
}
|
|
396
|
+
if (invalid || flags.has("--pair") && (!flags.has("--site") || !flags.has("--roots"))) {
|
|
397
|
+
process.stderr.write("Usage: node src/sites-connector.mjs [--config <absolute-file>] [--once] [--pair --site <HTTPS-origin> --roots <path-list>]\n"); process.exitCode = 1;
|
|
398
|
+
} else {
|
|
399
|
+
const controller = new AbortController();
|
|
400
|
+
for (const name of ["SIGINT", "SIGTERM"]) process.once(name, () => controller.abort());
|
|
401
|
+
const configFile = flags.get("--config") ?? defaultSitesConfigPath();
|
|
402
|
+
await (async () => {
|
|
403
|
+
if (flags.has("--pair")) {
|
|
404
|
+
const paired = await pairSitesConnector({ configFile, siteUrl: flags.get("--site"), allowedRoots: flags.get("--roots").split(path.delimiter).filter(Boolean) });
|
|
405
|
+
process.stderr.write(`[sites-connector] Authorize this device in ChatGPT: ${paired.pairingUrl}\n`);
|
|
406
|
+
}
|
|
407
|
+
await runSitesConnector({ configFile, once: flags.has("--once"), signal: controller.signal });
|
|
408
|
+
})().catch(() => { process.stderr.write("[sites-connector] Startup or polling failed. Check the private configuration, original accounts and native relay.\n"); process.exitCode = 1; });
|
|
409
|
+
}
|
|
410
|
+
}
|