@tomflow/proflow-execution-browser-extension 0.1.37 → 0.1.39
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/dist/deployment/adapter.d.ts +10 -10
- package/dist/deployment/descriptor.d.ts +1 -1
- package/dist/deployment/descriptor.js +1 -1
- package/dist/extension/background.js +687 -19
- package/dist/extension/content.js +267 -28
- package/dist/extension/tasks.js +74 -3
- package/dist/src/bridge.js +59 -6
- package/dist/src/carrier-attention-view.d.ts +13 -0
- package/dist/src/carrier-attention-view.js +54 -0
- package/dist/src/carrier-attention.d.ts +26 -0
- package/dist/src/carrier-attention.js +44 -0
- package/dist/src/carrier-continuation-control.d.ts +44 -0
- package/dist/src/carrier-continuation-control.js +114 -0
- package/dist/src/carrier-permission-attempt.d.ts +18 -0
- package/dist/src/carrier-permission-attempt.js +68 -0
- package/dist/src/carrier-permission-lifecycle.d.ts +34 -0
- package/dist/src/carrier-permission-lifecycle.js +73 -0
- package/dist/src/carrier-permission.d.ts +16 -0
- package/dist/src/carrier-permission.js +65 -0
- package/dist/src/chatgpt-runtime-adapter.d.ts +16 -0
- package/dist/src/chatgpt-runtime-adapter.js +173 -0
- package/dist/src/composer-submit.d.ts +12 -0
- package/dist/src/composer-submit.js +22 -0
- package/dist/src/pairing.js +13 -0
- package/extension/background.ts +622 -17
- package/extension/content.ts +40 -39
- package/extension/tasks.html +6 -0
- package/extension/tasks.ts +93 -5
- package/manifest.json +1 -1
- package/package.json +3 -3
- package/proflow.module.json +1 -1
|
@@ -1,5 +1,251 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
(() => {
|
|
3
|
+
// packages/execution-browser-extension/src/carrier-permission.ts
|
|
4
|
+
var actionLabels = [
|
|
5
|
+
[/^(始终允许|always allow)$/i, "allowAlways"],
|
|
6
|
+
[/^(允许一次|allow once)$/i, "allowOnce"],
|
|
7
|
+
[/^(拒绝|deny)$/i, "deny"]
|
|
8
|
+
];
|
|
9
|
+
function permissionSemanticAction(label) {
|
|
10
|
+
const normalized = label.trim();
|
|
11
|
+
for (const [pattern, action] of actionLabels)
|
|
12
|
+
if (pattern.test(normalized)) return action;
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
function normalizedText(value) {
|
|
16
|
+
return value.replace(/\s+/g, " ").trim().slice(0, 4096);
|
|
17
|
+
}
|
|
18
|
+
function hashFingerprint(value) {
|
|
19
|
+
let hash = 2166136261;
|
|
20
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
21
|
+
hash ^= value.charCodeAt(index);
|
|
22
|
+
hash = Math.imul(hash, 16777619);
|
|
23
|
+
}
|
|
24
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
25
|
+
}
|
|
26
|
+
function targetHost(text) {
|
|
27
|
+
const url = text.match(/https?:\/\/([a-z0-9.-]+)(?=[/:\s"'”]|$)/i)?.[1];
|
|
28
|
+
if (url) return url.toLowerCase();
|
|
29
|
+
const hosts = text.match(/[a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+){2,}/gi) ?? [];
|
|
30
|
+
return hosts.find((value) => value.includes("devtunnels.ms"))?.toLowerCase() ?? hosts[0]?.toLowerCase() ?? null;
|
|
31
|
+
}
|
|
32
|
+
function operationId(text) {
|
|
33
|
+
return text.match(
|
|
34
|
+
/(?:工具调用|tool call)\s*[::]\s*[^\s.]+(?:\.[^\s.]+)*\.([A-Za-z][A-Za-z0-9_]*)/i
|
|
35
|
+
)?.[1] ?? null;
|
|
36
|
+
}
|
|
37
|
+
function taskId(text) {
|
|
38
|
+
return text.match(/\btask-[A-Za-z0-9-]+\b/)?.[0] ?? null;
|
|
39
|
+
}
|
|
40
|
+
function detectActionPermission(candidates) {
|
|
41
|
+
for (const candidate of candidates) {
|
|
42
|
+
const actions = candidate.buttonLabels.map(permissionSemanticAction).filter((value) => value !== null);
|
|
43
|
+
if (!actions.includes("deny") || !actions.includes("allowAlways") && !actions.includes("allowOnce"))
|
|
44
|
+
continue;
|
|
45
|
+
const text = normalizedText(candidate.text);
|
|
46
|
+
const operation = operationId(text);
|
|
47
|
+
if (!operation) continue;
|
|
48
|
+
return {
|
|
49
|
+
kind: "ACTION_PERMISSION",
|
|
50
|
+
targetHost: targetHost(text),
|
|
51
|
+
operationId: operation,
|
|
52
|
+
taskId: taskId(text),
|
|
53
|
+
actions,
|
|
54
|
+
fingerprint: `permission:v1:${hashFingerprint(`${text}|${actions.join(",")}`)}`
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function permissionActionAllowed(facts, expectedFingerprint, action) {
|
|
60
|
+
return facts.fingerprint === expectedFingerprint && facts.actions.includes(action);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// packages/execution-browser-extension/src/composer-submit.ts
|
|
64
|
+
async function submitAfterComposerCommit(port, expectedValue, maxFrames = 180) {
|
|
65
|
+
let stableFrames = 0;
|
|
66
|
+
for (let frame = 0; frame < maxFrames; frame += 1) {
|
|
67
|
+
await port.nextFrame();
|
|
68
|
+
if (port.readValue() === expectedValue && port.submitReady()) {
|
|
69
|
+
stableFrames += 1;
|
|
70
|
+
if (stableFrames >= 2) {
|
|
71
|
+
port.clickSubmit();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
stableFrames = 0;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
throw new Error("COMPOSER_SUBMIT_NOT_READY");
|
|
79
|
+
}
|
|
80
|
+
async function submitControlledComposer(port, expectedValue, maxFrames = 180) {
|
|
81
|
+
port.write(expectedValue);
|
|
82
|
+
port.dispatchInput(expectedValue);
|
|
83
|
+
await submitAfterComposerCommit(port, expectedValue, maxFrames);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// packages/execution-browser-extension/src/chatgpt-runtime-adapter.ts
|
|
87
|
+
function classifyChatGptPageSignals(input) {
|
|
88
|
+
if (input.permission)
|
|
89
|
+
return {
|
|
90
|
+
pageState: "BLOCKED",
|
|
91
|
+
activityKind: "ACTION_PERMISSION",
|
|
92
|
+
blockerFacts: input.permission
|
|
93
|
+
};
|
|
94
|
+
if (input.hasDialog)
|
|
95
|
+
return { pageState: "BLOCKED", activityKind: "WAITING_HUMAN" };
|
|
96
|
+
if (input.isGenerating)
|
|
97
|
+
return { pageState: "BUSY", activityKind: "GENERATING" };
|
|
98
|
+
if (input.hasComposer) return { pageState: "IDLE", activityKind: null };
|
|
99
|
+
return { pageState: "UNKNOWN", activityKind: null };
|
|
100
|
+
}
|
|
101
|
+
var composerSelector = '#prompt-textarea, textarea, [contenteditable="true"]';
|
|
102
|
+
var sendSelector = 'button[data-testid="send-button"], button[aria-label*="Send"], button[aria-label*="\u53D1\u9001"]';
|
|
103
|
+
function buttonLabel(button) {
|
|
104
|
+
return (button.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
105
|
+
}
|
|
106
|
+
function actionPermissionDom(document2) {
|
|
107
|
+
const view = document2.defaultView;
|
|
108
|
+
if (!view) return null;
|
|
109
|
+
const semanticButtons = [...document2.querySelectorAll("button")].filter(
|
|
110
|
+
(button) => button instanceof view.HTMLButtonElement && permissionSemanticAction(buttonLabel(button)) !== null
|
|
111
|
+
);
|
|
112
|
+
for (const seed of semanticButtons) {
|
|
113
|
+
let root = seed.parentElement;
|
|
114
|
+
for (let depth = 0; root && depth < 8; depth += 1, root = root.parentElement) {
|
|
115
|
+
const buttons = [...root.querySelectorAll("button")].filter(
|
|
116
|
+
(button) => button instanceof view.HTMLButtonElement
|
|
117
|
+
);
|
|
118
|
+
const facts = detectActionPermission([
|
|
119
|
+
{
|
|
120
|
+
text: root.textContent ?? "",
|
|
121
|
+
buttonLabels: buttons.map(buttonLabel)
|
|
122
|
+
}
|
|
123
|
+
]);
|
|
124
|
+
if (!facts) continue;
|
|
125
|
+
const mapped = /* @__PURE__ */ new Map();
|
|
126
|
+
for (const button of buttons) {
|
|
127
|
+
const action = permissionSemanticAction(buttonLabel(button));
|
|
128
|
+
if (action && !mapped.has(action)) mapped.set(action, button);
|
|
129
|
+
}
|
|
130
|
+
return { facts, buttons: mapped };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
function observeChatGptPage(document2) {
|
|
136
|
+
const permission = actionPermissionDom(document2);
|
|
137
|
+
return classifyChatGptPageSignals({
|
|
138
|
+
permission: permission?.facts ?? null,
|
|
139
|
+
hasDialog: document2.querySelector('[role="dialog"]') !== null,
|
|
140
|
+
isGenerating: document2.querySelector(
|
|
141
|
+
'[data-testid="stop-button"], button[aria-label*="Stop"], button[aria-label*="\u505C\u6B62"]'
|
|
142
|
+
) !== null,
|
|
143
|
+
hasComposer: document2.querySelector(composerSelector) !== null
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function performChatGptPermissionAction(document2, expectedFingerprint, action) {
|
|
147
|
+
const permission = actionPermissionDom(document2);
|
|
148
|
+
if (!permission) throw new Error("ACTION_PERMISSION_NOT_FOUND");
|
|
149
|
+
if (!permissionActionAllowed(permission.facts, expectedFingerprint, action))
|
|
150
|
+
throw new Error("STALE_PERMISSION");
|
|
151
|
+
const button = permission.buttons.get(action);
|
|
152
|
+
if (!button || button.disabled || button.getAttribute("aria-disabled") === "true")
|
|
153
|
+
throw new Error("PERMISSION_ACTION_NOT_READY");
|
|
154
|
+
button.click();
|
|
155
|
+
return permission.facts;
|
|
156
|
+
}
|
|
157
|
+
function composerElement(document2) {
|
|
158
|
+
const view = document2.defaultView;
|
|
159
|
+
const element = document2.querySelector(composerSelector);
|
|
160
|
+
if (!view || !(element instanceof view.HTMLElement))
|
|
161
|
+
throw new Error("COMPOSER_NOT_FOUND");
|
|
162
|
+
return element;
|
|
163
|
+
}
|
|
164
|
+
function readElementValue(element) {
|
|
165
|
+
const view = element.ownerDocument.defaultView;
|
|
166
|
+
if (!view) return "";
|
|
167
|
+
if (element instanceof view.HTMLTextAreaElement || element instanceof view.HTMLInputElement)
|
|
168
|
+
return element.value;
|
|
169
|
+
return element.textContent ?? "";
|
|
170
|
+
}
|
|
171
|
+
function nativeWrite(element, value) {
|
|
172
|
+
const view = element.ownerDocument.defaultView;
|
|
173
|
+
if (!view) throw new Error("DOM_WINDOW_NOT_READY");
|
|
174
|
+
if (element instanceof view.HTMLTextAreaElement || element instanceof view.HTMLInputElement) {
|
|
175
|
+
const prototype = element instanceof view.HTMLTextAreaElement ? view.HTMLTextAreaElement.prototype : view.HTMLInputElement.prototype;
|
|
176
|
+
const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
|
|
177
|
+
if (!setter) throw new Error("COMPOSER_NATIVE_SETTER_MISSING");
|
|
178
|
+
setter.call(element, value);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
element.textContent = value;
|
|
182
|
+
}
|
|
183
|
+
function dispatchComposerInput(element, value) {
|
|
184
|
+
const view = element.ownerDocument.defaultView;
|
|
185
|
+
if (!view) throw new Error("DOM_WINDOW_NOT_READY");
|
|
186
|
+
element.dispatchEvent(
|
|
187
|
+
new view.InputEvent("input", {
|
|
188
|
+
bubbles: true,
|
|
189
|
+
inputType: "insertText",
|
|
190
|
+
data: value
|
|
191
|
+
})
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
function nextComposerFrame(document2) {
|
|
195
|
+
const view = document2.defaultView;
|
|
196
|
+
if (!view) return Promise.reject(new Error("DOM_WINDOW_NOT_READY"));
|
|
197
|
+
return new Promise((resolve) => {
|
|
198
|
+
let settled = false;
|
|
199
|
+
const finish = () => {
|
|
200
|
+
if (settled) return;
|
|
201
|
+
settled = true;
|
|
202
|
+
resolve();
|
|
203
|
+
};
|
|
204
|
+
view.requestAnimationFrame(finish);
|
|
205
|
+
view.setTimeout(finish, 50);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
function sendButton(document2) {
|
|
209
|
+
const view = document2.defaultView;
|
|
210
|
+
const element = document2.querySelector(sendSelector);
|
|
211
|
+
return view && element instanceof view.HTMLButtonElement ? element : null;
|
|
212
|
+
}
|
|
213
|
+
async function submitChatGptComposer(document2, value) {
|
|
214
|
+
const composer = composerElement(document2);
|
|
215
|
+
composer.focus();
|
|
216
|
+
await submitControlledComposer(
|
|
217
|
+
{
|
|
218
|
+
write: (next) => nativeWrite(composer, next),
|
|
219
|
+
dispatchInput: (next) => dispatchComposerInput(composer, next),
|
|
220
|
+
readValue: () => readElementValue(composer),
|
|
221
|
+
submitReady: () => {
|
|
222
|
+
const button = sendButton(document2);
|
|
223
|
+
return Boolean(
|
|
224
|
+
button && !button.disabled && button.getAttribute("aria-disabled") !== "true"
|
|
225
|
+
);
|
|
226
|
+
},
|
|
227
|
+
nextFrame: () => nextComposerFrame(document2),
|
|
228
|
+
clickSubmit: () => {
|
|
229
|
+
const button = sendButton(document2);
|
|
230
|
+
if (!button || button.disabled || button.getAttribute("aria-disabled") === "true")
|
|
231
|
+
throw new Error("COMPOSER_SUBMIT_NOT_READY");
|
|
232
|
+
button.click();
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
value
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
function writeChatGptInput(document2, selector, value) {
|
|
239
|
+
if (!selector || selector.length > 512) throw new Error("SELECTOR_INVALID");
|
|
240
|
+
const view = document2.defaultView;
|
|
241
|
+
const element = document2.querySelector(selector);
|
|
242
|
+
if (!view || !(element instanceof view.HTMLElement))
|
|
243
|
+
throw new Error("ELEMENT_NOT_FOUND");
|
|
244
|
+
element.focus();
|
|
245
|
+
nativeWrite(element, value);
|
|
246
|
+
dispatchComposerInput(element, value);
|
|
247
|
+
}
|
|
248
|
+
|
|
3
249
|
// packages/execution-browser-extension/src/submitted-message.ts
|
|
4
250
|
function containsSubmittedFingerprint(candidates, fingerprint) {
|
|
5
251
|
if (!fingerprint) return false;
|
|
@@ -13,17 +259,7 @@
|
|
|
13
259
|
// packages/execution-browser-extension/extension/content.ts
|
|
14
260
|
var contentInstanceId = `content:${crypto.randomUUID()}`;
|
|
15
261
|
function pageState() {
|
|
16
|
-
|
|
17
|
-
return { pageState: "BLOCKED", activityKind: "ACTION_PERMISSION" };
|
|
18
|
-
if (document.querySelector(
|
|
19
|
-
'[data-testid="stop-button"], button[aria-label*="Stop"]'
|
|
20
|
-
))
|
|
21
|
-
return { pageState: "BUSY", activityKind: "GENERATING" };
|
|
22
|
-
if (document.querySelector(
|
|
23
|
-
'#prompt-textarea, textarea, [contenteditable="true"]'
|
|
24
|
-
))
|
|
25
|
-
return { pageState: "IDLE", activityKind: null };
|
|
26
|
-
return { pageState: "UNKNOWN", activityKind: null };
|
|
262
|
+
return observeChatGptPage(document);
|
|
27
263
|
}
|
|
28
264
|
function observation() {
|
|
29
265
|
return {
|
|
@@ -52,6 +288,7 @@
|
|
|
52
288
|
}
|
|
53
289
|
chrome.runtime.onMessage.addListener((command, _sender, sendResponse) => {
|
|
54
290
|
void (async () => {
|
|
291
|
+
if (command.type === "PROFLOW_PAGE_SNAPSHOT_REQUEST") return observation();
|
|
55
292
|
if (command.type !== "PROFLOW_PAGE_COMMAND" || command.contentInstanceId !== contentInstanceId || command.expectedUrl !== location.href)
|
|
56
293
|
throw new Error("STALE_CONTENT_SESSION");
|
|
57
294
|
if (command.operation === "observe") return observation();
|
|
@@ -60,30 +297,32 @@
|
|
|
60
297
|
...observation(),
|
|
61
298
|
verified: hasFingerprint(command.fingerprint)
|
|
62
299
|
};
|
|
63
|
-
if (
|
|
64
|
-
|
|
300
|
+
if (command.operation === "permissionAction") {
|
|
301
|
+
if (!command.permissionFingerprint || !command.permissionAction)
|
|
302
|
+
throw new Error("PERMISSION_ACTION_INVALID");
|
|
303
|
+
performChatGptPermissionAction(
|
|
304
|
+
document,
|
|
305
|
+
command.permissionFingerprint,
|
|
306
|
+
command.permissionAction
|
|
307
|
+
);
|
|
308
|
+
return observation();
|
|
309
|
+
}
|
|
310
|
+
if (pageState().pageState === "BLOCKED") throw new Error("PAGE_BLOCKED");
|
|
65
311
|
if (command.operation === "click") {
|
|
66
312
|
safeElement(command.selector).click();
|
|
67
313
|
return observation();
|
|
68
314
|
}
|
|
69
|
-
const input = safeElement(command.selector ?? "#prompt-textarea");
|
|
70
315
|
if (command.value === void 0 || command.value.length > 4096)
|
|
71
316
|
throw new Error("INPUT_BUDGET_EXCEEDED");
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
data: command.value
|
|
81
|
-
})
|
|
317
|
+
if (command.operation === "submit") {
|
|
318
|
+
await submitChatGptComposer(document, command.value);
|
|
319
|
+
return observation();
|
|
320
|
+
}
|
|
321
|
+
writeChatGptInput(
|
|
322
|
+
document,
|
|
323
|
+
command.selector ?? "#prompt-textarea",
|
|
324
|
+
command.value
|
|
82
325
|
);
|
|
83
|
-
if (command.operation === "submit")
|
|
84
|
-
safeElement(
|
|
85
|
-
'button[data-testid="send-button"], button[aria-label*="Send"]'
|
|
86
|
-
).click();
|
|
87
326
|
return observation();
|
|
88
327
|
})().then(
|
|
89
328
|
(value) => sendResponse({ ok: true, value }),
|
package/dist/extension/tasks.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
const extensionRuntime = typeof chrome
|
|
1
|
+
import { parseCarrierAttentionViews } from "../src/carrier-attention-view.js";
|
|
2
|
+
const extensionRuntime = typeof chrome === "undefined" ? null : chrome.runtime;
|
|
3
3
|
function element(selector) {
|
|
4
4
|
const value = document.querySelector(selector);
|
|
5
5
|
if (!value)
|
|
@@ -17,6 +17,7 @@ const startButton = element("#start-task");
|
|
|
17
17
|
const ensureWorkersButton = element("#ensure-workers");
|
|
18
18
|
const newTaskForm = element("#new-task-form");
|
|
19
19
|
const approvalsTarget = element("#approvals");
|
|
20
|
+
const carrierAttentionsTarget = element("#carrier-attentions");
|
|
20
21
|
const systemAssessmentTarget = element("#system-assessment");
|
|
21
22
|
let selected = null;
|
|
22
23
|
function requestId(prefix) {
|
|
@@ -68,6 +69,73 @@ async function approvalApplication(operation, input) {
|
|
|
68
69
|
: "APPROVAL_APPLICATION_FAILED");
|
|
69
70
|
return response.value;
|
|
70
71
|
}
|
|
72
|
+
async function carrierAttentionAction(attentionRef, action) {
|
|
73
|
+
if (!extensionRuntime) {
|
|
74
|
+
const response = await fetch("/tasks/api/carrier-attention", {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "content-type": "application/json" },
|
|
77
|
+
body: JSON.stringify({ attentionRef, action }),
|
|
78
|
+
});
|
|
79
|
+
const body = record(await response.json());
|
|
80
|
+
if (!response.ok || body.ok !== true)
|
|
81
|
+
throw new Error(typeof body.error === "string"
|
|
82
|
+
? body.error
|
|
83
|
+
: "CARRIER_ATTENTION_ACTION_FAILED");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const raw = await extensionRuntime.sendMessage({
|
|
87
|
+
type: "PROFLOW_CARRIER_ATTENTION_ACTION",
|
|
88
|
+
input: { attentionRef, action },
|
|
89
|
+
});
|
|
90
|
+
const response = record(raw);
|
|
91
|
+
if (response.ok !== true)
|
|
92
|
+
throw new Error(typeof response.error === "string"
|
|
93
|
+
? response.error
|
|
94
|
+
: "CARRIER_ATTENTION_ACTION_FAILED");
|
|
95
|
+
}
|
|
96
|
+
function renderCarrierAttentions(snapshot) {
|
|
97
|
+
const attentions = parseCarrierAttentionViews(snapshot.carrierAttentions);
|
|
98
|
+
carrierAttentionsTarget.replaceChildren();
|
|
99
|
+
if (attentions.length === 0) {
|
|
100
|
+
carrierAttentionsTarget.textContent = "No carrier attention.";
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
for (const attention of attentions) {
|
|
104
|
+
const row = document.createElement("div");
|
|
105
|
+
row.className = "task";
|
|
106
|
+
const label = document.createElement("div");
|
|
107
|
+
label.textContent = `${attention.operationId} · ${attention.targetHost ?? "unknown target"}`;
|
|
108
|
+
const detail = document.createElement("div");
|
|
109
|
+
detail.className = "meta";
|
|
110
|
+
detail.textContent = [
|
|
111
|
+
attention.reason,
|
|
112
|
+
attention.taskId ? `task ${attention.taskId}` : "task unknown",
|
|
113
|
+
attention.roleRef ? `role ${attention.roleRef}` : "role unknown",
|
|
114
|
+
].join(" · ");
|
|
115
|
+
row.append(label, detail);
|
|
116
|
+
if (attention.actions.includes("allowOnce")) {
|
|
117
|
+
const allow = document.createElement("button");
|
|
118
|
+
allow.type = "button";
|
|
119
|
+
allow.textContent = "Allow once";
|
|
120
|
+
allow.addEventListener("click", () => void run(async () => {
|
|
121
|
+
await carrierAttentionAction(attention.attentionRef, "allowOnce");
|
|
122
|
+
await refreshBrowserStatus();
|
|
123
|
+
}));
|
|
124
|
+
row.append(allow);
|
|
125
|
+
}
|
|
126
|
+
if (attention.actions.includes("deny")) {
|
|
127
|
+
const deny = document.createElement("button");
|
|
128
|
+
deny.type = "button";
|
|
129
|
+
deny.textContent = "Deny";
|
|
130
|
+
deny.addEventListener("click", () => void run(async () => {
|
|
131
|
+
await carrierAttentionAction(attention.attentionRef, "deny");
|
|
132
|
+
await refreshBrowserStatus();
|
|
133
|
+
}));
|
|
134
|
+
row.append(deny);
|
|
135
|
+
}
|
|
136
|
+
carrierAttentionsTarget.append(row);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
71
139
|
async function refreshApprovals() {
|
|
72
140
|
const value = record(await approvalApplication("approval.list", { status: "PENDING" }));
|
|
73
141
|
const approvals = Array.isArray(value.approvals)
|
|
@@ -170,7 +238,9 @@ async function refreshTasks() {
|
|
|
170
238
|
}
|
|
171
239
|
async function pageStatus() {
|
|
172
240
|
if (extensionRuntime)
|
|
173
|
-
return record(await extensionRuntime.sendMessage({
|
|
241
|
+
return record(await extensionRuntime.sendMessage({
|
|
242
|
+
type: "PROFLOW_SIDE_PANEL_SNAPSHOT",
|
|
243
|
+
}));
|
|
174
244
|
const response = await fetch("/tasks/api/status", { cache: "no-store" });
|
|
175
245
|
const body = record(await response.json());
|
|
176
246
|
if (!response.ok || body.ok !== true)
|
|
@@ -179,6 +249,7 @@ async function pageStatus() {
|
|
|
179
249
|
}
|
|
180
250
|
async function refreshBrowserStatus() {
|
|
181
251
|
const snapshot = await pageStatus();
|
|
252
|
+
renderCarrierAttentions(snapshot);
|
|
182
253
|
connection.textContent =
|
|
183
254
|
snapshot.taskApplicationConfigured === true &&
|
|
184
255
|
snapshot.approvalApplicationConfigured === true
|
package/dist/src/bridge.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { createServer, } from "node:http";
|
|
3
|
+
import { parseCarrierAttentionViews, } from "./carrier-attention-view.js";
|
|
3
4
|
export class BrowserRealityBridgeError extends Error {
|
|
4
5
|
code;
|
|
5
6
|
constructor(code, message) {
|
|
@@ -121,6 +122,8 @@ export async function createBrowserRealityBridgeServer(options) {
|
|
|
121
122
|
let lastCommandConsumerAt = null;
|
|
122
123
|
let closed = false;
|
|
123
124
|
let endpoint = "";
|
|
125
|
+
let carrierAttentions = [];
|
|
126
|
+
let requestCommand = () => Promise.reject(new BrowserRealityBridgeError("BRIDGE_OFFLINE", "extension command consumer is not ready"));
|
|
124
127
|
const taskBootstrap = new Map();
|
|
125
128
|
const taskSessions = new Map();
|
|
126
129
|
const taskCookie = "proflow_tasks_session";
|
|
@@ -162,7 +165,9 @@ export async function createBrowserRealityBridgeServer(options) {
|
|
|
162
165
|
const server = createServer(async (request, response) => {
|
|
163
166
|
try {
|
|
164
167
|
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
165
|
-
if (options.taskWeb &&
|
|
168
|
+
if (options.taskWeb &&
|
|
169
|
+
request.method === "GET" &&
|
|
170
|
+
url.pathname.startsWith("/tasks/bootstrap/")) {
|
|
166
171
|
pruneTaskWebState();
|
|
167
172
|
const bootstrap = decodeURIComponent(url.pathname.slice("/tasks/bootstrap/".length));
|
|
168
173
|
if (!taskBootstrap.has(bootstrap)) {
|
|
@@ -200,6 +205,7 @@ export async function createBrowserRealityBridgeServer(options) {
|
|
|
200
205
|
taskApplicationConfigured: true,
|
|
201
206
|
approvalApplicationConfigured: true,
|
|
202
207
|
systemObserver: null,
|
|
208
|
+
carrierAttentions,
|
|
203
209
|
browserCarrier: {
|
|
204
210
|
online: commandConsumerReady(),
|
|
205
211
|
sessionOnline: sessionOnline(),
|
|
@@ -215,13 +221,41 @@ export async function createBrowserRealityBridgeServer(options) {
|
|
|
215
221
|
});
|
|
216
222
|
return;
|
|
217
223
|
}
|
|
218
|
-
if (request.method === "POST" &&
|
|
224
|
+
if (request.method === "POST" &&
|
|
225
|
+
url.pathname === "/tasks/api/carrier-attention") {
|
|
219
226
|
if (request.headers.origin !== endpoint) {
|
|
220
227
|
send(response, 403, { error: "TASK_WEB_ORIGIN_INVALID" });
|
|
221
228
|
return;
|
|
222
229
|
}
|
|
223
230
|
const body = await readJson(request);
|
|
224
|
-
if (!isRecord(body)
|
|
231
|
+
if (!isRecord(body))
|
|
232
|
+
throw new BrowserRealityBridgeError("BRIDGE_INPUT_INVALID", "carrier attention action must be an object");
|
|
233
|
+
const attentionRef = stringField(body, "attentionRef");
|
|
234
|
+
const action = body.action;
|
|
235
|
+
if (action !== "allowOnce" && action !== "deny")
|
|
236
|
+
throw new BrowserRealityBridgeError("BRIDGE_INPUT_INVALID", "carrier attention action is invalid");
|
|
237
|
+
const attention = carrierAttentions.find((candidate) => candidate.attentionRef === attentionRef);
|
|
238
|
+
if (!attention?.actions.includes(action))
|
|
239
|
+
throw new BrowserRealityBridgeError("BRIDGE_INPUT_INVALID", "carrier attention reference is stale or denied");
|
|
240
|
+
const value = await requestCommand({
|
|
241
|
+
type: "CARRIER_ATTENTION_ACTION",
|
|
242
|
+
attentionRef,
|
|
243
|
+
action,
|
|
244
|
+
});
|
|
245
|
+
send(response, 200, { ok: true, value });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (request.method === "POST" &&
|
|
249
|
+
(url.pathname === "/tasks/api/task" ||
|
|
250
|
+
url.pathname === "/tasks/api/approval")) {
|
|
251
|
+
if (request.headers.origin !== endpoint) {
|
|
252
|
+
send(response, 403, { error: "TASK_WEB_ORIGIN_INVALID" });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const body = await readJson(request);
|
|
256
|
+
if (!isRecord(body) ||
|
|
257
|
+
typeof body.operation !== "string" ||
|
|
258
|
+
!isRecord(body.input))
|
|
225
259
|
throw new BrowserRealityBridgeError("BRIDGE_INPUT_INVALID", "task web request is invalid");
|
|
226
260
|
const value = url.pathname.endsWith("/task")
|
|
227
261
|
? await options.taskWeb.invokeTask(body.operation, body.input)
|
|
@@ -242,11 +276,15 @@ export async function createBrowserRealityBridgeServer(options) {
|
|
|
242
276
|
return;
|
|
243
277
|
}
|
|
244
278
|
authenticate(request);
|
|
245
|
-
if (options.taskWeb &&
|
|
279
|
+
if (options.taskWeb &&
|
|
280
|
+
request.method === "POST" &&
|
|
281
|
+
url.pathname === "/v1/tasks/session") {
|
|
246
282
|
pruneTaskWebState();
|
|
247
283
|
const bootstrap = idFactory();
|
|
248
284
|
taskBootstrap.set(bootstrap, now().getTime() + taskBootstrapTtlMs);
|
|
249
|
-
send(response, 200, {
|
|
285
|
+
send(response, 200, {
|
|
286
|
+
url: `${endpoint}/tasks/bootstrap/${encodeURIComponent(bootstrap)}`,
|
|
287
|
+
});
|
|
250
288
|
return;
|
|
251
289
|
}
|
|
252
290
|
if (request.method === "POST" && url.pathname === "/v1/session/hello") {
|
|
@@ -289,6 +327,20 @@ export async function createBrowserRealityBridgeServer(options) {
|
|
|
289
327
|
send(response, 200, { accepted: true });
|
|
290
328
|
return;
|
|
291
329
|
}
|
|
330
|
+
if (request.method === "POST" &&
|
|
331
|
+
url.pathname === "/v1/carrier/attentions") {
|
|
332
|
+
requireExtensionOrigin(request);
|
|
333
|
+
const body = await readJson(request);
|
|
334
|
+
if (!isRecord(body) || !Array.isArray(body.carrierAttentions))
|
|
335
|
+
throw new BrowserRealityBridgeError("BRIDGE_INPUT_INVALID", "carrier attentions must be an array");
|
|
336
|
+
const parsed = parseCarrierAttentionViews(body.carrierAttentions);
|
|
337
|
+
if (body.carrierAttentions.length > 128 ||
|
|
338
|
+
parsed.length !== body.carrierAttentions.length)
|
|
339
|
+
throw new BrowserRealityBridgeError("BRIDGE_INPUT_INVALID", "carrier attentions contain invalid entries");
|
|
340
|
+
carrierAttentions = parsed;
|
|
341
|
+
send(response, 200, { accepted: true });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
292
344
|
if (request.method === "GET" && url.pathname === "/v1/commands/next") {
|
|
293
345
|
const stamp = now();
|
|
294
346
|
session.lastHeartbeatAt = stamp.getTime();
|
|
@@ -349,7 +401,7 @@ export async function createBrowserRealityBridgeServer(options) {
|
|
|
349
401
|
if (!address || typeof address === "string")
|
|
350
402
|
throw new Error("bridge address missing");
|
|
351
403
|
endpoint = `http://127.0.0.1:${address.port}`;
|
|
352
|
-
|
|
404
|
+
requestCommand = (command) => {
|
|
353
405
|
if (!online())
|
|
354
406
|
return Promise.reject(new BrowserRealityBridgeError("BRIDGE_OFFLINE", "extension command consumer is not ready"));
|
|
355
407
|
const commandId = `browser-command:${idFactory()}`;
|
|
@@ -435,6 +487,7 @@ export async function createBrowserRealityBridgeServer(options) {
|
|
|
435
487
|
},
|
|
436
488
|
async close() {
|
|
437
489
|
closed = true;
|
|
490
|
+
carrierAttentions = [];
|
|
438
491
|
for (const item of pending.values()) {
|
|
439
492
|
clearTimeout(item.timer);
|
|
440
493
|
item.reject(new BrowserRealityBridgeError("BRIDGE_OFFLINE", "bridge server closed"));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type CarrierAttentionView = {
|
|
2
|
+
attentionRef: string;
|
|
3
|
+
occurrenceRef: string;
|
|
4
|
+
taskId: string | null;
|
|
5
|
+
roleRef: string | null;
|
|
6
|
+
workerRef: string | null;
|
|
7
|
+
targetHost: string | null;
|
|
8
|
+
operationId: string;
|
|
9
|
+
reason: string;
|
|
10
|
+
actions: Array<"allowOnce" | "deny">;
|
|
11
|
+
observedAt: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function parseCarrierAttentionViews(value: unknown): CarrierAttentionView[];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
function nullableString(value) {
|
|
2
|
+
return value === null || typeof value === "string";
|
|
3
|
+
}
|
|
4
|
+
function parseCarrierAttentionView(value) {
|
|
5
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
6
|
+
return null;
|
|
7
|
+
const attentionRef = Reflect.get(value, "attentionRef");
|
|
8
|
+
const occurrenceRef = Reflect.get(value, "occurrenceRef");
|
|
9
|
+
const taskId = Reflect.get(value, "taskId");
|
|
10
|
+
const roleRef = Reflect.get(value, "roleRef");
|
|
11
|
+
const workerRef = Reflect.get(value, "workerRef");
|
|
12
|
+
const targetHost = Reflect.get(value, "targetHost");
|
|
13
|
+
const operationId = Reflect.get(value, "operationId");
|
|
14
|
+
const reason = Reflect.get(value, "reason");
|
|
15
|
+
const actions = Reflect.get(value, "actions");
|
|
16
|
+
const observedAt = Reflect.get(value, "observedAt");
|
|
17
|
+
if (typeof attentionRef !== "string" ||
|
|
18
|
+
attentionRef.length === 0 ||
|
|
19
|
+
typeof occurrenceRef !== "string" ||
|
|
20
|
+
occurrenceRef.length === 0 ||
|
|
21
|
+
!nullableString(taskId) ||
|
|
22
|
+
!nullableString(roleRef) ||
|
|
23
|
+
!nullableString(workerRef) ||
|
|
24
|
+
!nullableString(targetHost) ||
|
|
25
|
+
typeof operationId !== "string" ||
|
|
26
|
+
operationId.length === 0 ||
|
|
27
|
+
typeof reason !== "string" ||
|
|
28
|
+
reason.length === 0 ||
|
|
29
|
+
!Array.isArray(actions) ||
|
|
30
|
+
actions.some((action) => action !== "allowOnce" && action !== "deny") ||
|
|
31
|
+
typeof observedAt !== "string" ||
|
|
32
|
+
observedAt.length === 0)
|
|
33
|
+
return null;
|
|
34
|
+
return {
|
|
35
|
+
attentionRef,
|
|
36
|
+
occurrenceRef,
|
|
37
|
+
taskId,
|
|
38
|
+
roleRef,
|
|
39
|
+
workerRef,
|
|
40
|
+
targetHost,
|
|
41
|
+
operationId,
|
|
42
|
+
reason,
|
|
43
|
+
actions: [...actions],
|
|
44
|
+
observedAt,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function parseCarrierAttentionViews(value) {
|
|
48
|
+
if (!Array.isArray(value))
|
|
49
|
+
return [];
|
|
50
|
+
return value
|
|
51
|
+
.slice(0, 128)
|
|
52
|
+
.map(parseCarrierAttentionView)
|
|
53
|
+
.filter((item) => item !== null);
|
|
54
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type CarrierAttentionAction = "allowOnce" | "deny";
|
|
2
|
+
export type CarrierAttention = {
|
|
3
|
+
attentionRef: string;
|
|
4
|
+
occurrenceRef: string;
|
|
5
|
+
tabId: number;
|
|
6
|
+
contentInstanceId: string;
|
|
7
|
+
url: string;
|
|
8
|
+
permissionFingerprint: string;
|
|
9
|
+
taskId: string | null;
|
|
10
|
+
roleRef: string | null;
|
|
11
|
+
workerRef: string | null;
|
|
12
|
+
targetHost: string | null;
|
|
13
|
+
operationId: string;
|
|
14
|
+
reason: string;
|
|
15
|
+
actions: CarrierAttentionAction[];
|
|
16
|
+
observedAt: string;
|
|
17
|
+
};
|
|
18
|
+
export type CarrierAttentionInput = Omit<CarrierAttention, "attentionRef" | "occurrenceRef">;
|
|
19
|
+
export declare function createCarrierAttentionRegistry(idFactory?: () => string): Readonly<{
|
|
20
|
+
derive(input: CarrierAttentionInput): CarrierAttention;
|
|
21
|
+
find(attentionRef: string): CarrierAttention | null;
|
|
22
|
+
current(tabId: number): CarrierAttention | null;
|
|
23
|
+
values(): CarrierAttention[];
|
|
24
|
+
delete(attentionRef: string): boolean;
|
|
25
|
+
removeTab(tabId: number): boolean;
|
|
26
|
+
}>;
|