@tomflow/proflow-execution-browser-extension 0.1.11 → 0.1.13
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/CHANGELOG.md +12 -0
- package/DOCS.md +12 -4
- package/SETUP.md +10 -14
- package/dist/deployment/adapter.d.ts +49 -50
- package/dist/deployment/adapter.js +139 -103
- package/dist/deployment/descriptor.d.ts +4 -1
- package/dist/deployment/descriptor.js +5 -1
- package/dist/extension/background.js +189 -0
- package/dist/extension/provisioning-content.d.ts +1 -0
- package/dist/extension/provisioning-content.js +936 -0
- package/dist/src/configure-args.d.ts +3 -0
- package/dist/src/configure-args.js +23 -0
- package/dist/src/configure.js +8 -71
- package/dist/src/custom-gpt-editor-driver.d.ts +51 -0
- package/dist/src/custom-gpt-editor-driver.js +116 -0
- package/dist/src/custom-gpt-knowledge.d.ts +21 -0
- package/dist/src/custom-gpt-knowledge.js +148 -0
- package/dist/src/custom-gpt-provisioner.d.ts +78 -0
- package/dist/src/custom-gpt-provisioner.js +225 -0
- package/dist/src/custom-gpt-role.d.ts +66 -0
- package/dist/src/custom-gpt-role.js +98 -0
- package/dist/src/install-workflow.d.ts +29 -0
- package/dist/src/install-workflow.js +64 -0
- package/dist/src/pairing.d.ts +20 -0
- package/dist/src/pairing.js +176 -0
- package/dist/src/provisioning-bridge.d.ts +46 -0
- package/dist/src/provisioning-bridge.js +314 -0
- package/extension/background.ts +264 -3
- package/extension/provisioning-content.ts +1033 -0
- package/manifest.json +9 -1
- package/package.json +7 -5
- package/proflow.module.json +5 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type CustomGptProvisioningCommandInput = {
|
|
2
|
+
type: "PROVISION_CUSTOM_GPT" | "FINALIZE_CUSTOM_GPT_AUTH";
|
|
3
|
+
request: Record<string, unknown>;
|
|
4
|
+
};
|
|
5
|
+
export type ProvisioningRelayFileInput = {
|
|
6
|
+
name: string;
|
|
7
|
+
path: string;
|
|
8
|
+
mime: string;
|
|
9
|
+
};
|
|
10
|
+
export type ProvisioningRelayFileDescriptor = {
|
|
11
|
+
fileId: string;
|
|
12
|
+
name: string;
|
|
13
|
+
mime: string;
|
|
14
|
+
sizeBytes: number;
|
|
15
|
+
sha256: string;
|
|
16
|
+
url: string;
|
|
17
|
+
};
|
|
18
|
+
export interface CustomGptProvisioningBridgeOptions {
|
|
19
|
+
token: string;
|
|
20
|
+
extensionId: string;
|
|
21
|
+
host?: "127.0.0.1";
|
|
22
|
+
port?: number;
|
|
23
|
+
heartbeatFreshnessMs?: number;
|
|
24
|
+
commandTimeoutMs?: number;
|
|
25
|
+
now?: () => Date;
|
|
26
|
+
idFactory?: () => string;
|
|
27
|
+
}
|
|
28
|
+
export declare class CustomGptProvisioningBridgeError extends Error {
|
|
29
|
+
readonly code: "PROVISIONING_AUTH_INVALID" | "PROVISIONING_INPUT_INVALID" | "PROVISIONING_OFFLINE" | "PROVISIONING_COMMAND_TIMEOUT" | "PROVISIONING_COMMAND_FAILED";
|
|
30
|
+
constructor(code: CustomGptProvisioningBridgeError["code"], message: string);
|
|
31
|
+
}
|
|
32
|
+
export declare function createCustomGptProvisioningBridgeServer(options: CustomGptProvisioningBridgeOptions): Promise<Readonly<{
|
|
33
|
+
endpoint: string;
|
|
34
|
+
provisioning: Readonly<{
|
|
35
|
+
request: (input: CustomGptProvisioningCommandInput) => Promise<unknown>;
|
|
36
|
+
registerFiles: (files: readonly ProvisioningRelayFileInput[]) => Promise<ProvisioningRelayFileDescriptor[]>;
|
|
37
|
+
}>;
|
|
38
|
+
status(): {
|
|
39
|
+
online: boolean;
|
|
40
|
+
extensionInstanceId: string | null;
|
|
41
|
+
queuedCommands: number;
|
|
42
|
+
pendingCommands: number;
|
|
43
|
+
relayFiles: number;
|
|
44
|
+
};
|
|
45
|
+
close(): Promise<void>;
|
|
46
|
+
}>>;
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createServer, } from "node:http";
|
|
4
|
+
export class CustomGptProvisioningBridgeError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "CustomGptProvisioningBridgeError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const jsonHeaders = {
|
|
13
|
+
"content-type": "application/json; charset=utf-8",
|
|
14
|
+
"cache-control": "no-store",
|
|
15
|
+
};
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
function stringField(value, key) {
|
|
20
|
+
const item = value[key];
|
|
21
|
+
if (typeof item !== "string" || item.length === 0) {
|
|
22
|
+
throw new CustomGptProvisioningBridgeError("PROVISIONING_INPUT_INVALID", `${key} must be a non-empty string`);
|
|
23
|
+
}
|
|
24
|
+
return item;
|
|
25
|
+
}
|
|
26
|
+
function sha256(bytes) {
|
|
27
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
28
|
+
}
|
|
29
|
+
function safeRelayName(value) {
|
|
30
|
+
return (value.length > 0 &&
|
|
31
|
+
value.length <= 255 &&
|
|
32
|
+
!value.includes("/") &&
|
|
33
|
+
!value.includes("\\") &&
|
|
34
|
+
!value.includes("\0"));
|
|
35
|
+
}
|
|
36
|
+
function safeMime(value) {
|
|
37
|
+
return /^[a-z0-9.+-]+\/[a-z0-9.+-]+(?:;[ a-z0-9=._+-]+)?$/i.test(value);
|
|
38
|
+
}
|
|
39
|
+
function safeEqual(left, right) {
|
|
40
|
+
const leftBytes = Buffer.from(left);
|
|
41
|
+
const rightBytes = Buffer.from(right);
|
|
42
|
+
return (leftBytes.length === rightBytes.length &&
|
|
43
|
+
timingSafeEqual(leftBytes, rightBytes));
|
|
44
|
+
}
|
|
45
|
+
async function readJson(request) {
|
|
46
|
+
let body = "";
|
|
47
|
+
for await (const chunk of request) {
|
|
48
|
+
body += String(chunk);
|
|
49
|
+
if (body.length > 100_000) {
|
|
50
|
+
throw new CustomGptProvisioningBridgeError("PROVISIONING_INPUT_INVALID", "provisioning bridge body exceeds 100000 characters");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return body.length === 0 ? {} : JSON.parse(body);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new CustomGptProvisioningBridgeError("PROVISIONING_INPUT_INVALID", "provisioning bridge body is not valid JSON");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function send(response, status, value) {
|
|
61
|
+
response.writeHead(status, jsonHeaders);
|
|
62
|
+
response.end(value === undefined ? "" : JSON.stringify(value));
|
|
63
|
+
}
|
|
64
|
+
export async function createCustomGptProvisioningBridgeServer(options) {
|
|
65
|
+
if (options.token.length < 32) {
|
|
66
|
+
throw new TypeError("provisioning bridge token must contain at least 32 characters");
|
|
67
|
+
}
|
|
68
|
+
if (!/^[a-z]{32}$/.test(options.extensionId)) {
|
|
69
|
+
throw new TypeError("extensionId must be a canonical Chromium extension id");
|
|
70
|
+
}
|
|
71
|
+
const now = options.now ?? (() => new Date());
|
|
72
|
+
const idFactory = options.idFactory ?? randomUUID;
|
|
73
|
+
const freshnessMs = options.heartbeatFreshnessMs ?? 10_000;
|
|
74
|
+
const commandTimeoutMs = options.commandTimeoutMs ?? 180_000;
|
|
75
|
+
const expectedOrigin = `chrome-extension://${options.extensionId}`;
|
|
76
|
+
const queue = [];
|
|
77
|
+
const pending = new Map();
|
|
78
|
+
const relayFiles = new Map();
|
|
79
|
+
let session;
|
|
80
|
+
let closed = false;
|
|
81
|
+
const authenticate = (request, url) => {
|
|
82
|
+
const authorization = request.headers.authorization;
|
|
83
|
+
const origin = request.headers.origin;
|
|
84
|
+
const originlessCommandPoll = request.method === "GET" &&
|
|
85
|
+
url.pathname === "/v1/provisioning/commands/next" &&
|
|
86
|
+
(origin === undefined || origin === "null");
|
|
87
|
+
if (!authorization?.startsWith("Bearer ") ||
|
|
88
|
+
!safeEqual(authorization.slice(7), options.token) ||
|
|
89
|
+
(!originlessCommandPoll && origin !== expectedOrigin)) {
|
|
90
|
+
throw new CustomGptProvisioningBridgeError("PROVISIONING_AUTH_INVALID", "provisioning bridge authentication failed");
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const assertSession = (url) => {
|
|
94
|
+
if (!session) {
|
|
95
|
+
throw new CustomGptProvisioningBridgeError("PROVISIONING_OFFLINE", "provisioning session has not completed hello");
|
|
96
|
+
}
|
|
97
|
+
if (url.searchParams.get("extensionInstanceId") !==
|
|
98
|
+
session.extensionInstanceId) {
|
|
99
|
+
throw new CustomGptProvisioningBridgeError("PROVISIONING_AUTH_INVALID", "stale provisioning extension session");
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const server = createServer(async (request, response) => {
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
105
|
+
if (request.method === "GET" &&
|
|
106
|
+
url.pathname.startsWith("/v1/provisioning/files/")) {
|
|
107
|
+
response.setHeader("access-control-allow-origin", "https://chatgpt.com");
|
|
108
|
+
response.setHeader("vary", "origin");
|
|
109
|
+
if (request.headers.origin !== undefined &&
|
|
110
|
+
request.headers.origin !== "https://chatgpt.com" &&
|
|
111
|
+
request.headers.origin !== expectedOrigin)
|
|
112
|
+
throw new CustomGptProvisioningBridgeError("PROVISIONING_AUTH_INVALID", "provisioning file relay origin is invalid");
|
|
113
|
+
if (!session || now().getTime() - session.lastHeartbeatAt > freshnessMs)
|
|
114
|
+
throw new CustomGptProvisioningBridgeError("PROVISIONING_OFFLINE", "provisioning extension heartbeat is not fresh");
|
|
115
|
+
const fileId = decodeURIComponent(url.pathname.slice("/v1/provisioning/files/".length));
|
|
116
|
+
const relay = relayFiles.get(fileId);
|
|
117
|
+
if (!relay) {
|
|
118
|
+
send(response, 404, { error: "NOT_FOUND" });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
relayFiles.delete(fileId);
|
|
122
|
+
const bytes = await readFile(relay.path);
|
|
123
|
+
if (bytes.length !== relay.sizeBytes || sha256(bytes) !== relay.sha256)
|
|
124
|
+
throw new CustomGptProvisioningBridgeError("PROVISIONING_INPUT_INVALID", "registered provisioning file changed before relay");
|
|
125
|
+
response.writeHead(200, {
|
|
126
|
+
"access-control-allow-origin": "https://chatgpt.com",
|
|
127
|
+
vary: "origin",
|
|
128
|
+
"content-type": relay.mime,
|
|
129
|
+
"content-length": String(bytes.length),
|
|
130
|
+
"cache-control": "no-store",
|
|
131
|
+
"content-disposition": `attachment; filename="${relay.name.replace(/["\r\n]/g, "_")}"`,
|
|
132
|
+
});
|
|
133
|
+
response.end(bytes);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
response.setHeader("access-control-allow-origin", expectedOrigin);
|
|
137
|
+
response.setHeader("vary", "origin");
|
|
138
|
+
if (request.method === "OPTIONS") {
|
|
139
|
+
response.setHeader("access-control-allow-headers", "authorization, content-type");
|
|
140
|
+
response.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
|
|
141
|
+
response.writeHead(204);
|
|
142
|
+
response.end();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
authenticate(request, url);
|
|
146
|
+
if (request.method === "POST" &&
|
|
147
|
+
url.pathname === "/v1/provisioning/session/hello") {
|
|
148
|
+
const body = await readJson(request);
|
|
149
|
+
if (!isRecord(body) ||
|
|
150
|
+
stringField(body, "extensionId") !== options.extensionId) {
|
|
151
|
+
throw new CustomGptProvisioningBridgeError("PROVISIONING_AUTH_INVALID", "provisioning extension identity mismatch");
|
|
152
|
+
}
|
|
153
|
+
session = {
|
|
154
|
+
extensionInstanceId: stringField(body, "extensionInstanceId"),
|
|
155
|
+
lastHeartbeatAt: now().getTime(),
|
|
156
|
+
};
|
|
157
|
+
send(response, 200, { accepted: true });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
assertSession(url);
|
|
161
|
+
if (request.method === "POST" &&
|
|
162
|
+
url.pathname === "/v1/provisioning/session/heartbeat") {
|
|
163
|
+
if (session)
|
|
164
|
+
session.lastHeartbeatAt = now().getTime();
|
|
165
|
+
send(response, 200, { accepted: true });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (request.method === "GET" &&
|
|
169
|
+
url.pathname === "/v1/provisioning/commands/next") {
|
|
170
|
+
if (session)
|
|
171
|
+
session.lastHeartbeatAt = now().getTime();
|
|
172
|
+
const command = queue.shift();
|
|
173
|
+
if (command) {
|
|
174
|
+
const tracked = pending.get(command.commandId);
|
|
175
|
+
if (tracked)
|
|
176
|
+
tracked.stage = "DELIVERED";
|
|
177
|
+
}
|
|
178
|
+
send(response, command ? 200 : 204, command);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (request.method === "POST" &&
|
|
182
|
+
url.pathname === "/v1/provisioning/commands/result") {
|
|
183
|
+
const body = await readJson(request);
|
|
184
|
+
if (!isRecord(body)) {
|
|
185
|
+
throw new CustomGptProvisioningBridgeError("PROVISIONING_INPUT_INVALID", "provisioning command result must be an object");
|
|
186
|
+
}
|
|
187
|
+
const commandId = stringField(body, "commandId");
|
|
188
|
+
const tracked = pending.get(commandId);
|
|
189
|
+
if (!tracked) {
|
|
190
|
+
throw new CustomGptProvisioningBridgeError("PROVISIONING_INPUT_INVALID", "provisioning command result is stale or unknown");
|
|
191
|
+
}
|
|
192
|
+
pending.delete(commandId);
|
|
193
|
+
clearTimeout(tracked.timer);
|
|
194
|
+
if (body.ok === true)
|
|
195
|
+
tracked.resolve(body.value);
|
|
196
|
+
else {
|
|
197
|
+
tracked.reject(new CustomGptProvisioningBridgeError("PROVISIONING_COMMAND_FAILED", typeof body.error === "string"
|
|
198
|
+
? body.error
|
|
199
|
+
: "provisioning extension command failed"));
|
|
200
|
+
}
|
|
201
|
+
send(response, 200, { accepted: true });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
send(response, 404, { error: "NOT_FOUND" });
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
const bridgeError = error instanceof CustomGptProvisioningBridgeError
|
|
208
|
+
? error
|
|
209
|
+
: new CustomGptProvisioningBridgeError("PROVISIONING_INPUT_INVALID", error instanceof Error
|
|
210
|
+
? error.message
|
|
211
|
+
: "provisioning request failed");
|
|
212
|
+
send(response, bridgeError.code === "PROVISIONING_AUTH_INVALID" ? 401 : 400, { error: bridgeError.code });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
await new Promise((resolve, reject) => {
|
|
216
|
+
server.once("error", reject);
|
|
217
|
+
server.listen(options.port ?? 0, options.host ?? "127.0.0.1", () => {
|
|
218
|
+
server.off("error", reject);
|
|
219
|
+
resolve();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
const address = server.address();
|
|
223
|
+
if (!address || typeof address === "string")
|
|
224
|
+
throw new Error("provisioning bridge address missing");
|
|
225
|
+
const endpoint = `http://127.0.0.1:${address.port}`;
|
|
226
|
+
const online = () => !closed &&
|
|
227
|
+
session !== undefined &&
|
|
228
|
+
now().getTime() - session.lastHeartbeatAt <= freshnessMs;
|
|
229
|
+
const requestProvisioning = (input) => {
|
|
230
|
+
if ((input.type !== "PROVISION_CUSTOM_GPT" &&
|
|
231
|
+
input.type !== "FINALIZE_CUSTOM_GPT_AUTH") ||
|
|
232
|
+
!isRecord(input.request)) {
|
|
233
|
+
return Promise.reject(new CustomGptProvisioningBridgeError("PROVISIONING_INPUT_INVALID", "unsupported provisioning command"));
|
|
234
|
+
}
|
|
235
|
+
if (!online()) {
|
|
236
|
+
return Promise.reject(new CustomGptProvisioningBridgeError("PROVISIONING_OFFLINE", "provisioning extension heartbeat is not fresh"));
|
|
237
|
+
}
|
|
238
|
+
const commandId = `provisioning-command:${idFactory()}`;
|
|
239
|
+
const command = { ...input, commandId };
|
|
240
|
+
return new Promise((resolve, reject) => {
|
|
241
|
+
const timer = setTimeout(() => {
|
|
242
|
+
const tracked = pending.get(commandId);
|
|
243
|
+
if (tracked?.stage === "QUEUED") {
|
|
244
|
+
const index = queue.findIndex((item) => item.commandId === commandId);
|
|
245
|
+
if (index >= 0)
|
|
246
|
+
queue.splice(index, 1);
|
|
247
|
+
}
|
|
248
|
+
pending.delete(commandId);
|
|
249
|
+
reject(new CustomGptProvisioningBridgeError("PROVISIONING_COMMAND_TIMEOUT", "provisioning extension command result timed out"));
|
|
250
|
+
}, commandTimeoutMs);
|
|
251
|
+
pending.set(commandId, {
|
|
252
|
+
command,
|
|
253
|
+
stage: "QUEUED",
|
|
254
|
+
resolve,
|
|
255
|
+
reject,
|
|
256
|
+
timer,
|
|
257
|
+
});
|
|
258
|
+
queue.push(command);
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
const registerFiles = async (files) => {
|
|
262
|
+
if (!online())
|
|
263
|
+
throw new CustomGptProvisioningBridgeError("PROVISIONING_OFFLINE", "provisioning extension heartbeat is not fresh");
|
|
264
|
+
if (!Array.isArray(files) || files.length === 0 || files.length > 64)
|
|
265
|
+
throw new CustomGptProvisioningBridgeError("PROVISIONING_INPUT_INVALID", "provisioning relay file list is invalid");
|
|
266
|
+
const descriptors = [];
|
|
267
|
+
for (const file of files) {
|
|
268
|
+
if (!safeRelayName(file.name) ||
|
|
269
|
+
!safeMime(file.mime) ||
|
|
270
|
+
file.path.length === 0)
|
|
271
|
+
throw new CustomGptProvisioningBridgeError("PROVISIONING_INPUT_INVALID", "provisioning relay file metadata is invalid");
|
|
272
|
+
const bytes = await readFile(file.path);
|
|
273
|
+
const fileId = `file:${idFactory()}`;
|
|
274
|
+
const descriptor = {
|
|
275
|
+
fileId,
|
|
276
|
+
name: file.name,
|
|
277
|
+
mime: file.mime,
|
|
278
|
+
sizeBytes: bytes.length,
|
|
279
|
+
sha256: sha256(bytes),
|
|
280
|
+
url: `${endpoint}/v1/provisioning/files/${encodeURIComponent(fileId)}`,
|
|
281
|
+
};
|
|
282
|
+
relayFiles.set(fileId, { ...descriptor, path: file.path });
|
|
283
|
+
descriptors.push(descriptor);
|
|
284
|
+
}
|
|
285
|
+
return descriptors;
|
|
286
|
+
};
|
|
287
|
+
return Object.freeze({
|
|
288
|
+
endpoint,
|
|
289
|
+
provisioning: Object.freeze({
|
|
290
|
+
request: requestProvisioning,
|
|
291
|
+
registerFiles,
|
|
292
|
+
}),
|
|
293
|
+
status() {
|
|
294
|
+
return {
|
|
295
|
+
online: online(),
|
|
296
|
+
extensionInstanceId: session?.extensionInstanceId ?? null,
|
|
297
|
+
queuedCommands: queue.length,
|
|
298
|
+
pendingCommands: pending.size,
|
|
299
|
+
relayFiles: relayFiles.size,
|
|
300
|
+
};
|
|
301
|
+
},
|
|
302
|
+
async close() {
|
|
303
|
+
closed = true;
|
|
304
|
+
for (const item of pending.values()) {
|
|
305
|
+
clearTimeout(item.timer);
|
|
306
|
+
item.reject(new CustomGptProvisioningBridgeError("PROVISIONING_OFFLINE", "provisioning bridge server closed"));
|
|
307
|
+
}
|
|
308
|
+
pending.clear();
|
|
309
|
+
queue.length = 0;
|
|
310
|
+
relayFiles.clear();
|
|
311
|
+
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
}
|