@tomflow/proflow-execution-browser-extension 0.1.12 → 0.1.14

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.
@@ -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
+ }
@@ -36,10 +36,12 @@ type RuntimeMessage = {
36
36
  | "PROFLOW_CONTENT_OBSERVATION"
37
37
  | "PROFLOW_SIDE_PANEL_SNAPSHOT"
38
38
  | "PROFLOW_TASK_APPLICATION"
39
- | "PROFLOW_APPROVAL_APPLICATION";
39
+ | "PROFLOW_APPROVAL_APPLICATION"
40
+ | "PROFLOW_PROVISIONING_RELAY_FETCH";
40
41
  observation?: Omit<ContentObservation, "tabId" | "windowId">;
41
42
  operation?: string;
42
43
  input?: Record<string, unknown>;
44
+ url?: string;
43
45
  };
44
46
  type BridgeConfig = { endpoint: string; token: string };
45
47
  type BridgeCommand = {
@@ -67,7 +69,25 @@ type ContentCommand = {
67
69
  value?: string;
68
70
  fingerprint?: string;
69
71
  };
70
- type ChromeTab = { id?: number; windowId?: number; url?: string };
72
+ type ProvisioningOperation =
73
+ | "PROVISION_CUSTOM_GPT"
74
+ | "FINALIZE_CUSTOM_GPT_AUTH";
75
+ type ProvisioningBridgeCommand = {
76
+ commandId: string;
77
+ type: ProvisioningOperation;
78
+ request: Record<string, unknown>;
79
+ };
80
+ type ProvisioningContentCommand = {
81
+ type: "PROFLOW_PROVISIONING_COMMAND";
82
+ operation: ProvisioningOperation;
83
+ request: Record<string, unknown>;
84
+ };
85
+ type ChromeTab = {
86
+ id?: number;
87
+ windowId?: number;
88
+ url?: string;
89
+ status?: "loading" | "complete";
90
+ };
71
91
  type ChromeRuntime = {
72
92
  runtime: {
73
93
  id: string;
@@ -98,12 +118,17 @@ type ChromeRuntime = {
98
118
  };
99
119
  tabs: {
100
120
  query(query: { url?: string }): Promise<ChromeTab[]>;
121
+ get(tabId: number): Promise<ChromeTab>;
101
122
  create(create: { url: string; active: boolean }): Promise<ChromeTab>;
123
+ reload(tabId: number): Promise<void>;
102
124
  update(
103
125
  tabId: number,
104
126
  update: { url?: string; active?: boolean },
105
127
  ): Promise<ChromeTab>;
106
- sendMessage(tabId: number, message: ContentCommand): Promise<unknown>;
128
+ sendMessage(
129
+ tabId: number,
130
+ message: ContentCommand | ProvisioningContentCommand,
131
+ ): Promise<unknown>;
107
132
  captureVisibleTab(
108
133
  windowId: number,
109
134
  options: { format: "png" },
@@ -158,6 +183,7 @@ function parseConfig(value: unknown): BridgeConfig | null {
158
183
 
159
184
  type ManagedRuntimeConfig = {
160
185
  proflowRuntimeBridge?: unknown;
186
+ proflowProvisioningBridge?: unknown;
161
187
  proflowTaskApplication?: unknown;
162
188
  proflowApprovalApplication?: unknown;
163
189
  };
@@ -176,6 +202,7 @@ async function bootstrapManagedRuntimeConfig(): Promise<void> {
176
202
  if (!isRecord(raw)) return;
177
203
  const managed = raw as ManagedRuntimeConfig;
178
204
  const bridge = parseConfig(managed.proflowRuntimeBridge);
205
+ const provisioning = parseConfig(managed.proflowProvisioningBridge);
179
206
  const task = parseConfig(managed.proflowTaskApplication);
180
207
  const approval = parseConfig(managed.proflowApprovalApplication);
181
208
  if (!bridge || !task || !approval) {
@@ -183,6 +210,7 @@ async function bootstrapManagedRuntimeConfig(): Promise<void> {
183
210
  }
184
211
  await chrome.storage.local.set({
185
212
  proflowRuntimeBridge: bridge,
213
+ ...(provisioning ? { proflowProvisioningBridge: provisioning } : {}),
186
214
  proflowTaskApplication: task,
187
215
  proflowApprovalApplication: approval,
188
216
  });
@@ -193,6 +221,11 @@ async function bridgeConfig(): Promise<BridgeConfig | null> {
193
221
  return parseConfig(stored.proflowRuntimeBridge);
194
222
  }
195
223
 
224
+ async function provisioningBridgeConfig(): Promise<BridgeConfig | null> {
225
+ const stored = await chrome.storage.local.get("proflowProvisioningBridge");
226
+ return parseConfig(stored.proflowProvisioningBridge);
227
+ }
228
+
196
229
  async function taskApplicationConfig(): Promise<BridgeConfig | null> {
197
230
  const stored = await chrome.storage.local.get("proflowTaskApplication");
198
231
  return parseConfig(stored.proflowTaskApplication);
@@ -804,6 +837,96 @@ async function executeCommand(command: BridgeCommand): Promise<unknown> {
804
837
  throw new Error("BROWSER_PRIMITIVE_UNAVAILABLE");
805
838
  }
806
839
 
840
+ function missingProvisioningReceiver(error: unknown): boolean {
841
+ const message = error instanceof Error ? error.message : String(error);
842
+ return (
843
+ message.includes("Could not establish connection") ||
844
+ message.includes("Receiving end does not exist")
845
+ );
846
+ }
847
+
848
+ function isEditorUrl(url: string | undefined): boolean {
849
+ return (
850
+ url === "https://chatgpt.com/gpts/editor" ||
851
+ Boolean(url?.startsWith("https://chatgpt.com/gpts/editor/"))
852
+ );
853
+ }
854
+
855
+ async function provisioningContentCommand(
856
+ tabId: number,
857
+ operation: ProvisioningOperation,
858
+ request: Record<string, unknown>,
859
+ ): Promise<unknown> {
860
+ let receiverReloaded = false;
861
+ for (let attempt = 0; attempt < 60; attempt += 1) {
862
+ let response: unknown;
863
+ try {
864
+ response = await chrome.tabs.sendMessage(tabId, {
865
+ type: "PROFLOW_PROVISIONING_COMMAND",
866
+ operation,
867
+ request,
868
+ });
869
+ } catch (error) {
870
+ if (!receiverReloaded && missingProvisioningReceiver(error)) {
871
+ try {
872
+ const tab = await chrome.tabs.get(tabId);
873
+ if (tab.status === "complete" && isEditorUrl(tab.url)) {
874
+ await chrome.tabs.reload(tabId);
875
+ receiverReloaded = true;
876
+ }
877
+ } catch {}
878
+ }
879
+ if (attempt === 59) throw error;
880
+ await sleep(250);
881
+ continue;
882
+ }
883
+ if (!isRecord(response) || response.ok !== true) {
884
+ const detail =
885
+ isRecord(response) && typeof response.error === "string"
886
+ ? response.error
887
+ : "PROVISIONING_CONTENT_FAILED";
888
+ if (
889
+ (detail === "PROVISIONING_SURFACE_NOT_READY" ||
890
+ detail === "GPT_EDITOR_CONFIGURE_SURFACE_NOT_READY") &&
891
+ attempt < 59
892
+ ) {
893
+ await sleep(250);
894
+ continue;
895
+ }
896
+ throw new Error(detail);
897
+ }
898
+ return response.value;
899
+ }
900
+ throw new Error("PROVISIONING_CONTENT_TIMEOUT");
901
+ }
902
+
903
+ async function executeProvisioningCommand(
904
+ command: ProvisioningBridgeCommand,
905
+ ): Promise<unknown> {
906
+ if (!isRecord(command.request))
907
+ throw new Error("PROVISIONING_COMMAND_INVALID");
908
+ let editorUrl = "https://chatgpt.com/gpts/editor";
909
+ if (command.type === "FINALIZE_CUSTOM_GPT_AUTH") {
910
+ const carrierUrl = new URL(text(command.request.carrierUrl, "CARRIER_URL"));
911
+ const match = /^\/g\/(g-[A-Za-z0-9_-]+)$/.exec(carrierUrl.pathname);
912
+ if (
913
+ carrierUrl.origin !== "https://chatgpt.com" ||
914
+ carrierUrl.username !== "" ||
915
+ carrierUrl.password !== "" ||
916
+ carrierUrl.search !== "" ||
917
+ carrierUrl.hash !== "" ||
918
+ !match?.[1]
919
+ )
920
+ throw new Error("PROVISIONING_CARRIER_URL_INVALID");
921
+ editorUrl = `https://chatgpt.com/gpts/editor/${match[1]}`;
922
+ }
923
+ const tab = await chrome.tabs.create({ url: editorUrl, active: true });
924
+ return provisioningContentCommand(
925
+ numeric(tab.id, "TAB_ID"),
926
+ command.type,
927
+ command.request,
928
+ );
929
+ }
807
930
  async function bridgeFetch(
808
931
  config: BridgeConfig,
809
932
  path: string,
@@ -911,7 +1034,144 @@ async function runBridgeLoop() {
911
1034
  }
912
1035
  }
913
1036
 
1037
+ let provisioningBridgeLoopStarted = false;
1038
+ async function runProvisioningBridgeLoop() {
1039
+ if (provisioningBridgeLoopStarted) return;
1040
+ provisioningBridgeLoopStarted = true;
1041
+ while (true) {
1042
+ const config = await provisioningBridgeConfig();
1043
+ if (!config) {
1044
+ await sleep(1_000);
1045
+ continue;
1046
+ }
1047
+ const query = `?extensionInstanceId=${encodeURIComponent(extensionInstanceId)}`;
1048
+ try {
1049
+ const hello = await bridgeFetch(
1050
+ config,
1051
+ "/v1/provisioning/session/hello",
1052
+ {
1053
+ method: "POST",
1054
+ body: JSON.stringify({
1055
+ extensionId: chrome.runtime.id,
1056
+ extensionInstanceId,
1057
+ }),
1058
+ },
1059
+ );
1060
+ if (!hello.ok) throw new Error("PROVISIONING_BRIDGE_HELLO_REJECTED");
1061
+ let lastHeartbeatAt = 0;
1062
+ while (true) {
1063
+ if (Date.now() - lastHeartbeatAt >= 5_000) {
1064
+ const heartbeat = await bridgeFetch(
1065
+ config,
1066
+ `/v1/provisioning/session/heartbeat${query}`,
1067
+ { method: "POST", body: "{}" },
1068
+ );
1069
+ if (!heartbeat.ok)
1070
+ throw new Error("PROVISIONING_BRIDGE_HEARTBEAT_REJECTED");
1071
+ lastHeartbeatAt = Date.now();
1072
+ }
1073
+ const response = await bridgeFetch(
1074
+ config,
1075
+ `/v1/provisioning/commands/next${query}`,
1076
+ );
1077
+ if (response.status === 204) {
1078
+ await sleep(250);
1079
+ continue;
1080
+ }
1081
+ if (!response.ok) throw new Error("PROVISIONING_BRIDGE_POLL_REJECTED");
1082
+ const command = (await response.json()) as ProvisioningBridgeCommand;
1083
+ const commandHeartbeat = setInterval(() => {
1084
+ void bridgeFetch(
1085
+ config,
1086
+ `/v1/provisioning/session/heartbeat${query}`,
1087
+ { method: "POST", body: "{}" },
1088
+ ).catch(() => undefined);
1089
+ }, 2_000);
1090
+ let result: Record<string, unknown>;
1091
+ try {
1092
+ result = {
1093
+ commandId: command.commandId,
1094
+ ok: true,
1095
+ value: await executeProvisioningCommand(command),
1096
+ };
1097
+ } catch (error) {
1098
+ result = {
1099
+ commandId: command.commandId,
1100
+ ok: false,
1101
+ error:
1102
+ error instanceof Error
1103
+ ? error.message
1104
+ : "PROVISIONING_EXTENSION_COMMAND_FAILED",
1105
+ };
1106
+ } finally {
1107
+ clearInterval(commandHeartbeat);
1108
+ }
1109
+ const reported = await bridgeFetch(
1110
+ config,
1111
+ `/v1/provisioning/commands/result${query}`,
1112
+ { method: "POST", body: JSON.stringify(result) },
1113
+ );
1114
+ if (!reported.ok)
1115
+ throw new Error("PROVISIONING_BRIDGE_RESULT_REJECTED");
1116
+ }
1117
+ } catch {
1118
+ await sleep(1_000);
1119
+ }
1120
+ }
1121
+ }
1122
+
1123
+ function provisioningRelayBase64(bytes: Uint8Array): string {
1124
+ let binary = "";
1125
+ for (let offset = 0; offset < bytes.length; offset += 0x8000)
1126
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
1127
+ return btoa(binary);
1128
+ }
1129
+
914
1130
  chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
1131
+ if (message.type === "PROFLOW_PROVISIONING_RELAY_FETCH") {
1132
+ const rawUrl = typeof message.url === "string" ? message.url : "";
1133
+ let parsed: URL;
1134
+ try {
1135
+ parsed = new URL(rawUrl);
1136
+ } catch {
1137
+ sendResponse({ ok: false, error: "KNOWLEDGE_RELAY_URL_INVALID" });
1138
+ return;
1139
+ }
1140
+ if (
1141
+ parsed.protocol !== "http:" ||
1142
+ parsed.hostname !== "127.0.0.1" ||
1143
+ !parsed.pathname.startsWith("/v1/provisioning/files/") ||
1144
+ parsed.username !== "" ||
1145
+ parsed.password !== "" ||
1146
+ parsed.search !== "" ||
1147
+ parsed.hash !== ""
1148
+ ) {
1149
+ sendResponse({ ok: false, error: "KNOWLEDGE_RELAY_URL_INVALID" });
1150
+ return;
1151
+ }
1152
+ void fetch(parsed.toString(), { cache: "no-store" }).then(
1153
+ async (response) => {
1154
+ if (!response.ok) {
1155
+ sendResponse({
1156
+ ok: false,
1157
+ error: `KNOWLEDGE_RELAY_FETCH_FAILED:${response.status}`,
1158
+ });
1159
+ return;
1160
+ }
1161
+ const bytes = new Uint8Array(await response.arrayBuffer());
1162
+ sendResponse({ ok: true, base64: provisioningRelayBase64(bytes) });
1163
+ },
1164
+ (error: unknown) =>
1165
+ sendResponse({
1166
+ ok: false,
1167
+ error:
1168
+ error instanceof Error
1169
+ ? `KNOWLEDGE_RELAY_FETCH_FAILED:${error.message}`
1170
+ : "KNOWLEDGE_RELAY_FETCH_FAILED",
1171
+ }),
1172
+ );
1173
+ return true;
1174
+ }
915
1175
  if (
916
1176
  message.type === "PROFLOW_CONTENT_OBSERVATION" &&
917
1177
  message.observation &&
@@ -1020,6 +1280,7 @@ async function startBackgroundRuntime(): Promise<void> {
1020
1280
  await bootstrapManagedRuntimeConfig();
1021
1281
  await persistSnapshot();
1022
1282
  void runBridgeLoop();
1283
+ void runProvisioningBridgeLoop();
1023
1284
  void runObserverRecovery();
1024
1285
  }
1025
1286