@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
package/extension/background.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
|
+
import { createCarrierAttentionRegistry } from "../src/carrier-attention.js";
|
|
2
|
+
import {
|
|
3
|
+
type CarrierContinuationDenial,
|
|
4
|
+
createCarrierContinuationControl,
|
|
5
|
+
} from "../src/carrier-continuation-control.js";
|
|
6
|
+
import type {
|
|
7
|
+
ActionPermissionFacts,
|
|
8
|
+
PermissionSemanticAction,
|
|
9
|
+
} from "../src/carrier-permission.js";
|
|
10
|
+
import { createCarrierPermissionAttemptRegistry } from "../src/carrier-permission-attempt.js";
|
|
11
|
+
import {
|
|
12
|
+
type CarrierPermissionDecision,
|
|
13
|
+
resolveHumanCarrierPermission,
|
|
14
|
+
resolveRoutineCarrierPermission,
|
|
15
|
+
} from "../src/carrier-permission-lifecycle.js";
|
|
1
16
|
import { createCollaborationCarrierApplication } from "../src/collaboration-carrier.js";
|
|
17
|
+
import { shouldTriggerObserverRecovery } from "../src/recovery-trigger.js";
|
|
2
18
|
import {
|
|
3
19
|
createSystemObserver,
|
|
4
20
|
type SystemObserverReasonFailure,
|
|
@@ -12,7 +28,6 @@ import {
|
|
|
12
28
|
type TaskObserverDiagnosticAssessment,
|
|
13
29
|
type TaskObserverDiagnosticFailure,
|
|
14
30
|
} from "../src/task-observer.js";
|
|
15
|
-
import { shouldTriggerObserverRecovery } from "../src/recovery-trigger.js";
|
|
16
31
|
|
|
17
32
|
type PageState = "IDLE" | "BUSY" | "BLOCKED" | "UNKNOWN";
|
|
18
33
|
type ActivityKind =
|
|
@@ -30,6 +45,7 @@ type ContentObservation = {
|
|
|
30
45
|
contentInstanceId: string;
|
|
31
46
|
pageState: PageState;
|
|
32
47
|
activityKind: ActivityKind;
|
|
48
|
+
blockerFacts?: ActionPermissionFacts;
|
|
33
49
|
observedAt: string;
|
|
34
50
|
};
|
|
35
51
|
type RuntimeMessage = {
|
|
@@ -38,6 +54,7 @@ type RuntimeMessage = {
|
|
|
38
54
|
| "PROFLOW_SIDE_PANEL_SNAPSHOT"
|
|
39
55
|
| "PROFLOW_TASK_APPLICATION"
|
|
40
56
|
| "PROFLOW_APPROVAL_APPLICATION"
|
|
57
|
+
| "PROFLOW_CARRIER_ATTENTION_ACTION"
|
|
41
58
|
| "PROFLOW_PROVISIONING_RELAY_FETCH";
|
|
42
59
|
observation?: Omit<ContentObservation, "tabId" | "windowId">;
|
|
43
60
|
operation?: string;
|
|
@@ -54,21 +71,33 @@ type BridgeCommand = {
|
|
|
54
71
|
| "SUBMIT"
|
|
55
72
|
| "VERIFY"
|
|
56
73
|
| "SCREENSHOT"
|
|
57
|
-
| "PERFORM"
|
|
74
|
+
| "PERFORM"
|
|
75
|
+
| "CARRIER_ATTENTION_ACTION";
|
|
58
76
|
tabId?: number;
|
|
59
77
|
url?: string;
|
|
60
78
|
text?: string;
|
|
61
79
|
fingerprint?: string;
|
|
62
80
|
request?: Record<string, unknown>;
|
|
81
|
+
attentionRef?: string;
|
|
82
|
+
action?: "allowOnce" | "deny";
|
|
63
83
|
};
|
|
84
|
+
type ContentSnapshotRequest = { type: "PROFLOW_PAGE_SNAPSHOT_REQUEST" };
|
|
64
85
|
type ContentCommand = {
|
|
65
86
|
type: "PROFLOW_PAGE_COMMAND";
|
|
66
87
|
contentInstanceId: string;
|
|
67
88
|
expectedUrl: string;
|
|
68
|
-
operation:
|
|
89
|
+
operation:
|
|
90
|
+
| "observe"
|
|
91
|
+
| "input"
|
|
92
|
+
| "click"
|
|
93
|
+
| "submit"
|
|
94
|
+
| "verify"
|
|
95
|
+
| "permissionAction";
|
|
69
96
|
selector?: string;
|
|
70
97
|
value?: string;
|
|
71
98
|
fingerprint?: string;
|
|
99
|
+
permissionFingerprint?: string;
|
|
100
|
+
permissionAction?: PermissionSemanticAction;
|
|
72
101
|
};
|
|
73
102
|
type ProvisioningOperation =
|
|
74
103
|
| "PROVISION_CUSTOM_GPT"
|
|
@@ -119,7 +148,10 @@ type ChromeRuntime = {
|
|
|
119
148
|
onClicked: { addListener(listener: () => void): void };
|
|
120
149
|
};
|
|
121
150
|
tabs: {
|
|
122
|
-
query(query: {
|
|
151
|
+
query(query: {
|
|
152
|
+
url?: string;
|
|
153
|
+
currentWindow?: boolean;
|
|
154
|
+
}): Promise<ChromeTab[]>;
|
|
123
155
|
get(tabId: number): Promise<ChromeTab>;
|
|
124
156
|
create(create: { url: string; active: boolean }): Promise<ChromeTab>;
|
|
125
157
|
reload(tabId: number): Promise<void>;
|
|
@@ -129,7 +161,10 @@ type ChromeRuntime = {
|
|
|
129
161
|
): Promise<ChromeTab>;
|
|
130
162
|
sendMessage(
|
|
131
163
|
tabId: number,
|
|
132
|
-
message:
|
|
164
|
+
message:
|
|
165
|
+
| ContentCommand
|
|
166
|
+
| ContentSnapshotRequest
|
|
167
|
+
| ProvisioningContentCommand,
|
|
133
168
|
): Promise<unknown>;
|
|
134
169
|
captureVisibleTab(
|
|
135
170
|
windowId: number,
|
|
@@ -141,28 +176,70 @@ declare const chrome: ChromeRuntime;
|
|
|
141
176
|
|
|
142
177
|
const extensionInstanceId = `extension:${crypto.randomUUID()}`;
|
|
143
178
|
const sessions = new Map<number, ContentObservation>();
|
|
179
|
+
const permissionHandling = new Map<number, string>();
|
|
180
|
+
const permissionAutoAttempts = createCarrierPermissionAttemptRegistry();
|
|
181
|
+
const carrierAttentions = createCarrierAttentionRegistry();
|
|
182
|
+
const carrierContinuationControl = createCarrierContinuationControl();
|
|
144
183
|
const BROWSER_CARRIER_KEEPALIVE_KEY = "proflowBrowserSnapshot";
|
|
145
184
|
const BROWSER_CARRIER_KEEPALIVE_MS = 20_000;
|
|
146
185
|
const BROWSER_BRIDGE_FETCH_TIMEOUT_MS = 5_000;
|
|
186
|
+
let snapshotPersistence = Promise.resolve();
|
|
187
|
+
let transientPermissionAttemptsRestore: Promise<boolean> | null = null;
|
|
147
188
|
|
|
148
189
|
const sleep = (milliseconds: number) =>
|
|
149
190
|
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
|
|
150
191
|
|
|
151
|
-
|
|
152
|
-
|
|
192
|
+
function persistSnapshot(): Promise<void> {
|
|
193
|
+
const value = {
|
|
153
194
|
proflowBrowserSnapshot: {
|
|
154
195
|
extensionInstanceId,
|
|
155
196
|
observedAt: new Date().toISOString(),
|
|
156
197
|
sessions: [...sessions.values()],
|
|
198
|
+
permissionAutoAttempts: permissionAutoAttempts.snapshot(),
|
|
199
|
+
carrierContinuationDenials: carrierContinuationControl.snapshot(),
|
|
157
200
|
recoveryScan: "BOUNDED_ON_START",
|
|
158
201
|
},
|
|
159
|
-
}
|
|
202
|
+
};
|
|
203
|
+
// Preserve invocation order so an older empty snapshot cannot overwrite a
|
|
204
|
+
// later pre-click uncertainty record.
|
|
205
|
+
snapshotPersistence = snapshotPersistence
|
|
206
|
+
.catch(() => undefined)
|
|
207
|
+
.then(() => chrome.storage.session.set(value));
|
|
208
|
+
return snapshotPersistence;
|
|
160
209
|
}
|
|
161
210
|
|
|
162
211
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
163
212
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
164
213
|
}
|
|
165
214
|
|
|
215
|
+
function restoreTransientPermissionAttempts(): Promise<boolean> {
|
|
216
|
+
if (transientPermissionAttemptsRestore)
|
|
217
|
+
return transientPermissionAttemptsRestore;
|
|
218
|
+
transientPermissionAttemptsRestore = (async () => {
|
|
219
|
+
try {
|
|
220
|
+
const stored = await chrome.storage.session.get(
|
|
221
|
+
BROWSER_CARRIER_KEEPALIVE_KEY,
|
|
222
|
+
);
|
|
223
|
+
const snapshot = stored[BROWSER_CARRIER_KEEPALIVE_KEY];
|
|
224
|
+
const validSnapshot = isRecord(snapshot);
|
|
225
|
+
const attemptsLoaded = permissionAutoAttempts.load(
|
|
226
|
+
validSnapshot ? snapshot.permissionAutoAttempts : undefined,
|
|
227
|
+
);
|
|
228
|
+
const denialsLoaded = carrierContinuationControl.load(
|
|
229
|
+
validSnapshot ? snapshot.carrierContinuationDenials : undefined,
|
|
230
|
+
);
|
|
231
|
+
return attemptsLoaded && denialsLoaded;
|
|
232
|
+
} catch {
|
|
233
|
+
// A later pre-click persistence failure still prevents the click. No
|
|
234
|
+
// automatic-action fact is manufactured from unreadable session data.
|
|
235
|
+
permissionAutoAttempts.load(undefined);
|
|
236
|
+
carrierContinuationControl.load(undefined);
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
})();
|
|
240
|
+
return transientPermissionAttemptsRestore;
|
|
241
|
+
}
|
|
242
|
+
|
|
166
243
|
function parseConfig(value: unknown): BridgeConfig | null {
|
|
167
244
|
if (!isRecord(value)) return null;
|
|
168
245
|
const endpoint = value.endpoint;
|
|
@@ -450,6 +527,8 @@ const taskObserver = createTaskObserver({
|
|
|
450
527
|
},
|
|
451
528
|
carrier: {
|
|
452
529
|
async requestWake(input) {
|
|
530
|
+
if (carrierContinuationControl.hasMatchingDispatchDenial(input))
|
|
531
|
+
throw new Error("CARRIER_CONTINUATION_HUMAN_DENIED");
|
|
453
532
|
return invokeObserverApplication("task.wake", input);
|
|
454
533
|
},
|
|
455
534
|
},
|
|
@@ -589,8 +668,16 @@ async function persistSystemObserverState(
|
|
|
589
668
|
|
|
590
669
|
let observerRecoveryInFlight: Promise<void> | null = null;
|
|
591
670
|
let observerRecoveryRetryCount = 0;
|
|
671
|
+
let nextRecoverySuppressions: readonly CarrierContinuationDenial[] = [];
|
|
672
|
+
function suppressNextObserverRecovery(
|
|
673
|
+
denials: readonly CarrierContinuationDenial[],
|
|
674
|
+
): void {
|
|
675
|
+
nextRecoverySuppressions = denials;
|
|
676
|
+
}
|
|
592
677
|
function runObserverRecovery() {
|
|
593
678
|
if (observerRecoveryInFlight) return observerRecoveryInFlight;
|
|
679
|
+
const suppressedContinuations = nextRecoverySuppressions;
|
|
680
|
+
nextRecoverySuppressions = [];
|
|
594
681
|
observerRecoveryInFlight = (async () => {
|
|
595
682
|
let recoveryNeedsRetry = false;
|
|
596
683
|
await collaborationCarrier.recoverPending(50).catch(() => undefined);
|
|
@@ -609,6 +696,14 @@ function runObserverRecovery() {
|
|
|
609
696
|
)
|
|
610
697
|
continue;
|
|
611
698
|
try {
|
|
699
|
+
if (
|
|
700
|
+
suppressedContinuations.some(
|
|
701
|
+
(denial) =>
|
|
702
|
+
denial.taskId === candidate.taskId &&
|
|
703
|
+
denial.workerRef === candidate.workerRef,
|
|
704
|
+
)
|
|
705
|
+
)
|
|
706
|
+
continue;
|
|
612
707
|
const decision =
|
|
613
708
|
candidate.kind === "RECOVERY_RESUME"
|
|
614
709
|
? await taskObserver.drive(candidate.taskId, {
|
|
@@ -668,6 +763,12 @@ function runObserverRecovery() {
|
|
|
668
763
|
}).catch(() => {
|
|
669
764
|
recoveryNeedsRetry = true;
|
|
670
765
|
});
|
|
766
|
+
if (
|
|
767
|
+
suppressedContinuations.some(
|
|
768
|
+
(denial) => denial.taskId === candidate.taskId,
|
|
769
|
+
)
|
|
770
|
+
)
|
|
771
|
+
continue;
|
|
671
772
|
await taskObserver.drive(candidate.taskId).catch(() => {
|
|
672
773
|
recoveryNeedsRetry = true;
|
|
673
774
|
});
|
|
@@ -738,6 +839,462 @@ async function contentCommand(
|
|
|
738
839
|
return response.value;
|
|
739
840
|
}
|
|
740
841
|
|
|
842
|
+
function carrierIdentity(
|
|
843
|
+
url: string,
|
|
844
|
+
): { roleRef: string; workerRef: string | null } | null {
|
|
845
|
+
try {
|
|
846
|
+
const parsed = new URL(url);
|
|
847
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
848
|
+
if (
|
|
849
|
+
parsed.protocol !== "https:" ||
|
|
850
|
+
parsed.hostname !== "chatgpt.com" ||
|
|
851
|
+
segments[0] !== "g" ||
|
|
852
|
+
!segments[1]?.startsWith("g-")
|
|
853
|
+
)
|
|
854
|
+
return null;
|
|
855
|
+
return {
|
|
856
|
+
roleRef: segments[1],
|
|
857
|
+
workerRef: segments[2] === "c" && segments[3] ? segments[3] : null,
|
|
858
|
+
};
|
|
859
|
+
} catch {
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function permissionAttemptKey(observed: ContentObservation): string | null {
|
|
865
|
+
return observed.blockerFacts
|
|
866
|
+
? `${observed.url}:${observed.blockerFacts.fingerprint}`
|
|
867
|
+
: null;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function setCarrierAttention(
|
|
871
|
+
observed: ContentObservation,
|
|
872
|
+
reason: string,
|
|
873
|
+
): void {
|
|
874
|
+
const facts = observed.blockerFacts;
|
|
875
|
+
if (!facts) return;
|
|
876
|
+
const identity = carrierIdentity(observed.url);
|
|
877
|
+
carrierAttentions.derive({
|
|
878
|
+
tabId: observed.tabId,
|
|
879
|
+
contentInstanceId: observed.contentInstanceId,
|
|
880
|
+
url: observed.url,
|
|
881
|
+
permissionFingerprint: facts.fingerprint,
|
|
882
|
+
taskId: facts.taskId,
|
|
883
|
+
roleRef: identity?.roleRef ?? null,
|
|
884
|
+
workerRef: identity?.workerRef ?? null,
|
|
885
|
+
targetHost: facts.targetHost,
|
|
886
|
+
operationId: facts.operationId,
|
|
887
|
+
reason,
|
|
888
|
+
actions: facts.actions.filter(
|
|
889
|
+
(action): action is "allowOnce" | "deny" =>
|
|
890
|
+
action === "allowOnce" || action === "deny",
|
|
891
|
+
),
|
|
892
|
+
observedAt: new Date().toISOString(),
|
|
893
|
+
});
|
|
894
|
+
void publishCarrierAttentions();
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function carrierAttentionViews() {
|
|
898
|
+
return carrierAttentions
|
|
899
|
+
.values()
|
|
900
|
+
.map(
|
|
901
|
+
({
|
|
902
|
+
attentionRef,
|
|
903
|
+
occurrenceRef,
|
|
904
|
+
taskId,
|
|
905
|
+
roleRef,
|
|
906
|
+
workerRef,
|
|
907
|
+
targetHost,
|
|
908
|
+
operationId,
|
|
909
|
+
reason,
|
|
910
|
+
actions,
|
|
911
|
+
observedAt,
|
|
912
|
+
}) => ({
|
|
913
|
+
attentionRef,
|
|
914
|
+
occurrenceRef,
|
|
915
|
+
taskId,
|
|
916
|
+
roleRef,
|
|
917
|
+
workerRef,
|
|
918
|
+
targetHost,
|
|
919
|
+
operationId,
|
|
920
|
+
reason,
|
|
921
|
+
actions,
|
|
922
|
+
observedAt,
|
|
923
|
+
}),
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async function publishCarrierAttentions(): Promise<void> {
|
|
928
|
+
const config = await bridgeConfig().catch(() => null);
|
|
929
|
+
if (!config) return;
|
|
930
|
+
const query = `?extensionInstanceId=${encodeURIComponent(extensionInstanceId)}`;
|
|
931
|
+
const response = await bridgeFetch(config, `/v1/carrier/attentions${query}`, {
|
|
932
|
+
method: "POST",
|
|
933
|
+
body: JSON.stringify({ carrierAttentions: carrierAttentionViews() }),
|
|
934
|
+
});
|
|
935
|
+
if (!response.ok) throw new Error("CARRIER_ATTENTION_PUBLISH_REJECTED");
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function rawPermissionFingerprint(value: unknown): string | null {
|
|
939
|
+
if (!isRecord(value) || !isRecord(value.blockerFacts)) return null;
|
|
940
|
+
return typeof value.blockerFacts.fingerprint === "string"
|
|
941
|
+
? value.blockerFacts.fingerprint
|
|
942
|
+
: null;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function parsePermissionFacts(
|
|
946
|
+
value: unknown,
|
|
947
|
+
): ActionPermissionFacts | undefined {
|
|
948
|
+
if (!isRecord(value) || value.kind !== "ACTION_PERMISSION") return undefined;
|
|
949
|
+
const actions = value.actions;
|
|
950
|
+
if (
|
|
951
|
+
(value.targetHost !== null && typeof value.targetHost !== "string") ||
|
|
952
|
+
typeof value.operationId !== "string" ||
|
|
953
|
+
(value.taskId !== null && typeof value.taskId !== "string") ||
|
|
954
|
+
!Array.isArray(actions) ||
|
|
955
|
+
actions.some(
|
|
956
|
+
(action) =>
|
|
957
|
+
action !== "allowAlways" && action !== "allowOnce" && action !== "deny",
|
|
958
|
+
) ||
|
|
959
|
+
typeof value.fingerprint !== "string"
|
|
960
|
+
)
|
|
961
|
+
return undefined;
|
|
962
|
+
return {
|
|
963
|
+
kind: "ACTION_PERMISSION",
|
|
964
|
+
targetHost: value.targetHost,
|
|
965
|
+
operationId: value.operationId,
|
|
966
|
+
taskId: value.taskId,
|
|
967
|
+
actions: [...actions],
|
|
968
|
+
fingerprint: value.fingerprint,
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function parseSnapshotObservation(
|
|
973
|
+
value: unknown,
|
|
974
|
+
tab: ChromeTab,
|
|
975
|
+
): ContentObservation | null {
|
|
976
|
+
if (!isRecord(value) || tab.id === undefined || tab.windowId === undefined)
|
|
977
|
+
return null;
|
|
978
|
+
const pageState = value.pageState;
|
|
979
|
+
const activityKind = value.activityKind;
|
|
980
|
+
if (
|
|
981
|
+
typeof value.url !== "string" ||
|
|
982
|
+
typeof value.contentInstanceId !== "string" ||
|
|
983
|
+
(pageState !== "IDLE" &&
|
|
984
|
+
pageState !== "BUSY" &&
|
|
985
|
+
pageState !== "BLOCKED" &&
|
|
986
|
+
pageState !== "UNKNOWN") ||
|
|
987
|
+
(activityKind !== null &&
|
|
988
|
+
activityKind !== "GENERATING" &&
|
|
989
|
+
activityKind !== "ACTION_PERMISSION" &&
|
|
990
|
+
activityKind !== "ACTION_RUNNING" &&
|
|
991
|
+
activityKind !== "WAITING_HUMAN" &&
|
|
992
|
+
activityKind !== "WAITING_PEER" &&
|
|
993
|
+
activityKind !== "RECOVERING") ||
|
|
994
|
+
typeof value.observedAt !== "string"
|
|
995
|
+
)
|
|
996
|
+
return null;
|
|
997
|
+
const blockerFacts = parsePermissionFacts(value.blockerFacts);
|
|
998
|
+
if (activityKind === "ACTION_PERMISSION" && blockerFacts === undefined)
|
|
999
|
+
return null;
|
|
1000
|
+
return {
|
|
1001
|
+
tabId: tab.id,
|
|
1002
|
+
windowId: tab.windowId,
|
|
1003
|
+
url: value.url,
|
|
1004
|
+
contentInstanceId: value.contentInstanceId,
|
|
1005
|
+
pageState,
|
|
1006
|
+
activityKind,
|
|
1007
|
+
...(blockerFacts ? { blockerFacts } : {}),
|
|
1008
|
+
observedAt: value.observedAt,
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
async function waitForPermissionReleased(
|
|
1013
|
+
tabId: number,
|
|
1014
|
+
fingerprint: string,
|
|
1015
|
+
): Promise<boolean> {
|
|
1016
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
1017
|
+
const current = sessions.get(tabId);
|
|
1018
|
+
if (current && current.blockerFacts?.fingerprint !== fingerprint)
|
|
1019
|
+
return true;
|
|
1020
|
+
try {
|
|
1021
|
+
const value = await contentCommand(tabId, { operation: "observe" });
|
|
1022
|
+
if (rawPermissionFingerprint(value) !== fingerprint) return true;
|
|
1023
|
+
} catch {
|
|
1024
|
+
// Navigation/content replacement is re-observed on the next bounded pass.
|
|
1025
|
+
}
|
|
1026
|
+
await sleep(250);
|
|
1027
|
+
}
|
|
1028
|
+
return false;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
async function handleActionPermission(
|
|
1032
|
+
observed: ContentObservation,
|
|
1033
|
+
): Promise<void> {
|
|
1034
|
+
const facts = observed.blockerFacts;
|
|
1035
|
+
const key = permissionAttemptKey(observed);
|
|
1036
|
+
if (
|
|
1037
|
+
observed.pageState !== "BLOCKED" ||
|
|
1038
|
+
observed.activityKind !== "ACTION_PERMISSION" ||
|
|
1039
|
+
!facts ||
|
|
1040
|
+
!key
|
|
1041
|
+
)
|
|
1042
|
+
return;
|
|
1043
|
+
if (permissionHandling.get(observed.tabId) === key) return;
|
|
1044
|
+
const existing = carrierAttentions.current(observed.tabId);
|
|
1045
|
+
if (
|
|
1046
|
+
existing?.contentInstanceId === observed.contentInstanceId &&
|
|
1047
|
+
existing.permissionFingerprint === facts.fingerprint
|
|
1048
|
+
)
|
|
1049
|
+
return;
|
|
1050
|
+
permissionHandling.set(observed.tabId, key);
|
|
1051
|
+
try {
|
|
1052
|
+
const identity = carrierIdentity(observed.url);
|
|
1053
|
+
const humanDenied = () =>
|
|
1054
|
+
carrierContinuationControl.hasMatchingPermissionDenial({
|
|
1055
|
+
tabId: observed.tabId,
|
|
1056
|
+
contentInstanceId: observed.contentInstanceId,
|
|
1057
|
+
url: observed.url,
|
|
1058
|
+
permissionFingerprint: facts.fingerprint,
|
|
1059
|
+
taskId: facts.taskId,
|
|
1060
|
+
roleRef: identity?.roleRef ?? null,
|
|
1061
|
+
workerRef: identity?.workerRef ?? null,
|
|
1062
|
+
});
|
|
1063
|
+
if (humanDenied()) {
|
|
1064
|
+
setCarrierAttention(observed, "HUMAN_DENIED");
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if (!(await restoreTransientPermissionAttempts())) {
|
|
1068
|
+
setCarrierAttention(observed, "PERMISSION_ATTEMPT_STATE_UNAVAILABLE");
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
if (!identity || !facts.taskId) {
|
|
1072
|
+
setCarrierAttention(observed, "PERMISSION_CONTEXT_INCOMPLETE");
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
const result = await resolveRoutineCarrierPermission({
|
|
1076
|
+
facts,
|
|
1077
|
+
autoAlreadyAttempted: permissionAutoAttempts.has(observed.tabId, key),
|
|
1078
|
+
humanDenied,
|
|
1079
|
+
port: {
|
|
1080
|
+
async classify(): Promise<CarrierPermissionDecision> {
|
|
1081
|
+
const value = await invokeObserverApplication(
|
|
1082
|
+
"browser.permission.classify",
|
|
1083
|
+
{
|
|
1084
|
+
taskId: facts.taskId as string,
|
|
1085
|
+
roleRef: identity.roleRef,
|
|
1086
|
+
...(identity.workerRef ? { workerRef: identity.workerRef } : {}),
|
|
1087
|
+
conversationLocator: observed.url,
|
|
1088
|
+
targetHost: facts.targetHost,
|
|
1089
|
+
operationId: facts.operationId,
|
|
1090
|
+
},
|
|
1091
|
+
);
|
|
1092
|
+
if (
|
|
1093
|
+
!isRecord(value) ||
|
|
1094
|
+
(value.decision !== "AUTO_ALLOW" &&
|
|
1095
|
+
value.decision !== "DEFER" &&
|
|
1096
|
+
value.decision !== "HUMAN_REQUIRED") ||
|
|
1097
|
+
typeof value.reason !== "string"
|
|
1098
|
+
)
|
|
1099
|
+
return {
|
|
1100
|
+
decision: "HUMAN_REQUIRED",
|
|
1101
|
+
reason: "PERMISSION_CLASSIFICATION_INVALID",
|
|
1102
|
+
};
|
|
1103
|
+
return {
|
|
1104
|
+
decision: value.decision,
|
|
1105
|
+
reason: value.reason,
|
|
1106
|
+
};
|
|
1107
|
+
},
|
|
1108
|
+
revalidate() {
|
|
1109
|
+
const current = observationFor(observed.tabId);
|
|
1110
|
+
return (
|
|
1111
|
+
current.contentInstanceId === observed.contentInstanceId &&
|
|
1112
|
+
current.url === observed.url &&
|
|
1113
|
+
current.blockerFacts?.fingerprint === facts.fingerprint
|
|
1114
|
+
);
|
|
1115
|
+
},
|
|
1116
|
+
waitBeforeReclassify: () => sleep(250),
|
|
1117
|
+
async act(action) {
|
|
1118
|
+
permissionAutoAttempts.begin(observed.tabId, key);
|
|
1119
|
+
// Persist uncertainty before the click. If the MV3 worker restarts
|
|
1120
|
+
// before readback, the same effect is never replayed blindly.
|
|
1121
|
+
await persistSnapshot();
|
|
1122
|
+
await contentCommand(observed.tabId, {
|
|
1123
|
+
operation: "permissionAction",
|
|
1124
|
+
permissionFingerprint: facts.fingerprint,
|
|
1125
|
+
permissionAction: action,
|
|
1126
|
+
});
|
|
1127
|
+
},
|
|
1128
|
+
released: () =>
|
|
1129
|
+
waitForPermissionReleased(observed.tabId, facts.fingerprint),
|
|
1130
|
+
},
|
|
1131
|
+
});
|
|
1132
|
+
if (result.status === "RELEASED") {
|
|
1133
|
+
permissionAutoAttempts.release(observed.tabId, key);
|
|
1134
|
+
carrierAttentions.removeTab(observed.tabId);
|
|
1135
|
+
void publishCarrierAttentions();
|
|
1136
|
+
await persistSnapshot();
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
if (result.status === "HUMAN_REQUIRED")
|
|
1140
|
+
setCarrierAttention(observed, result.reason);
|
|
1141
|
+
} catch {
|
|
1142
|
+
setCarrierAttention(observed, "AUTO_ALLOW_FAILED");
|
|
1143
|
+
} finally {
|
|
1144
|
+
if (permissionHandling.get(observed.tabId) === key)
|
|
1145
|
+
permissionHandling.delete(observed.tabId);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
type CarrierBlockerHandler = (observed: ContentObservation) => Promise<void>;
|
|
1150
|
+
|
|
1151
|
+
// The application lines consume normalized reality only. New blocker cases
|
|
1152
|
+
// register a strategy here instead of branching Workflow/Collaboration/Observer.
|
|
1153
|
+
const carrierBlockerStrategies: ReadonlyMap<string, CarrierBlockerHandler> =
|
|
1154
|
+
new Map([["ACTION_PERMISSION", handleActionPermission]]);
|
|
1155
|
+
|
|
1156
|
+
function handleCarrierBlocker(observed: ContentObservation): void {
|
|
1157
|
+
if (observed.pageState !== "BLOCKED" || observed.activityKind === null)
|
|
1158
|
+
return;
|
|
1159
|
+
const strategy = carrierBlockerStrategies.get(observed.activityKind);
|
|
1160
|
+
if (strategy) void strategy(observed);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function processContentObservation(
|
|
1164
|
+
observed: ContentObservation,
|
|
1165
|
+
triggerRecovery: boolean,
|
|
1166
|
+
): void {
|
|
1167
|
+
const previous = sessions.get(observed.tabId);
|
|
1168
|
+
sessions.set(observed.tabId, observed);
|
|
1169
|
+
permissionAutoAttempts.observe(
|
|
1170
|
+
observed.tabId,
|
|
1171
|
+
permissionAttemptKey(observed),
|
|
1172
|
+
);
|
|
1173
|
+
const attention = carrierAttentions.current(observed.tabId);
|
|
1174
|
+
if (
|
|
1175
|
+
attention &&
|
|
1176
|
+
(observed.contentInstanceId !== attention.contentInstanceId ||
|
|
1177
|
+
observed.url !== attention.url ||
|
|
1178
|
+
observed.blockerFacts?.fingerprint !== attention.permissionFingerprint)
|
|
1179
|
+
) {
|
|
1180
|
+
carrierAttentions.removeTab(observed.tabId);
|
|
1181
|
+
void publishCarrierAttentions();
|
|
1182
|
+
}
|
|
1183
|
+
handleCarrierBlocker(observed);
|
|
1184
|
+
const shouldRecover =
|
|
1185
|
+
triggerRecovery && shouldTriggerObserverRecovery(previous, observed);
|
|
1186
|
+
const suppressed =
|
|
1187
|
+
shouldRecover &&
|
|
1188
|
+
carrierContinuationControl.suppressRecovery(previous, observed);
|
|
1189
|
+
void persistSnapshot();
|
|
1190
|
+
if (shouldRecover && !suppressed) void runObserverRecovery();
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
async function rebuildCarrierAttentionsFromTabs(): Promise<
|
|
1194
|
+
CarrierContinuationDenial[]
|
|
1195
|
+
> {
|
|
1196
|
+
const consumedDenials: CarrierContinuationDenial[] = [];
|
|
1197
|
+
const tabs = await chrome.tabs.query({ url: "https://chatgpt.com/g/*" });
|
|
1198
|
+
for (const tab of tabs) {
|
|
1199
|
+
if (tab.id === undefined || tab.windowId === undefined) continue;
|
|
1200
|
+
try {
|
|
1201
|
+
const response = await chrome.tabs.sendMessage(tab.id, {
|
|
1202
|
+
type: "PROFLOW_PAGE_SNAPSHOT_REQUEST",
|
|
1203
|
+
});
|
|
1204
|
+
if (!isRecord(response) || response.ok !== true) continue;
|
|
1205
|
+
const observed = parseSnapshotObservation(response.value, tab);
|
|
1206
|
+
if (observed) {
|
|
1207
|
+
processContentObservation(observed, false);
|
|
1208
|
+
const consumed = carrierContinuationControl.consumeRecovery(
|
|
1209
|
+
undefined,
|
|
1210
|
+
observed,
|
|
1211
|
+
);
|
|
1212
|
+
if (consumed) consumedDenials.push(consumed);
|
|
1213
|
+
}
|
|
1214
|
+
} catch {
|
|
1215
|
+
// A tab without a live content receiver is not reconstructed from guesses.
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
await persistSnapshot();
|
|
1219
|
+
await publishCarrierAttentions().catch(() => undefined);
|
|
1220
|
+
return [
|
|
1221
|
+
...consumedDenials,
|
|
1222
|
+
...carrierContinuationControl
|
|
1223
|
+
.snapshot()
|
|
1224
|
+
.filter(
|
|
1225
|
+
(denial) =>
|
|
1226
|
+
!consumedDenials.some(
|
|
1227
|
+
(consumed) => consumed.attentionRef === denial.attentionRef,
|
|
1228
|
+
),
|
|
1229
|
+
),
|
|
1230
|
+
];
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
async function decideCarrierAttention(
|
|
1234
|
+
attentionRef: string,
|
|
1235
|
+
action: "allowOnce" | "deny",
|
|
1236
|
+
): Promise<{
|
|
1237
|
+
attentionRef: string;
|
|
1238
|
+
action: "allowOnce" | "deny";
|
|
1239
|
+
status: "APPLIED";
|
|
1240
|
+
}> {
|
|
1241
|
+
const attention = carrierAttentions.find(attentionRef);
|
|
1242
|
+
if (!attention?.actions.includes(action))
|
|
1243
|
+
throw new Error("CARRIER_ATTENTION_ACTION_DENIED");
|
|
1244
|
+
await resolveHumanCarrierPermission({
|
|
1245
|
+
action,
|
|
1246
|
+
revalidate() {
|
|
1247
|
+
const current = observationFor(attention.tabId);
|
|
1248
|
+
return (
|
|
1249
|
+
current.contentInstanceId === attention.contentInstanceId &&
|
|
1250
|
+
current.url === attention.url &&
|
|
1251
|
+
current.blockerFacts?.fingerprint === attention.permissionFingerprint
|
|
1252
|
+
);
|
|
1253
|
+
},
|
|
1254
|
+
async act(semanticAction) {
|
|
1255
|
+
if (semanticAction === "deny") {
|
|
1256
|
+
carrierContinuationControl.beginDenied({
|
|
1257
|
+
attentionRef: attention.attentionRef,
|
|
1258
|
+
tabId: attention.tabId,
|
|
1259
|
+
taskId: attention.taskId,
|
|
1260
|
+
roleRef: attention.roleRef,
|
|
1261
|
+
workerRef: attention.workerRef,
|
|
1262
|
+
url: attention.url,
|
|
1263
|
+
contentInstanceId: attention.contentInstanceId,
|
|
1264
|
+
permissionFingerprint: attention.permissionFingerprint,
|
|
1265
|
+
});
|
|
1266
|
+
await persistSnapshot();
|
|
1267
|
+
}
|
|
1268
|
+
try {
|
|
1269
|
+
await contentCommand(attention.tabId, {
|
|
1270
|
+
operation: "permissionAction",
|
|
1271
|
+
permissionFingerprint: attention.permissionFingerprint,
|
|
1272
|
+
permissionAction: semanticAction,
|
|
1273
|
+
});
|
|
1274
|
+
} catch (error) {
|
|
1275
|
+
if (semanticAction === "deny") {
|
|
1276
|
+
carrierContinuationControl.cancelDenied(attention.attentionRef);
|
|
1277
|
+
await persistSnapshot();
|
|
1278
|
+
}
|
|
1279
|
+
throw error;
|
|
1280
|
+
}
|
|
1281
|
+
},
|
|
1282
|
+
released: () =>
|
|
1283
|
+
waitForPermissionReleased(
|
|
1284
|
+
attention.tabId,
|
|
1285
|
+
attention.permissionFingerprint,
|
|
1286
|
+
),
|
|
1287
|
+
});
|
|
1288
|
+
permissionAutoAttempts.release(
|
|
1289
|
+
attention.tabId,
|
|
1290
|
+
`${attention.url}:${attention.permissionFingerprint}`,
|
|
1291
|
+
);
|
|
1292
|
+
carrierAttentions.delete(attention.attentionRef);
|
|
1293
|
+
void publishCarrierAttentions();
|
|
1294
|
+
await persistSnapshot();
|
|
1295
|
+
return { attentionRef, action, status: "APPLIED" };
|
|
1296
|
+
}
|
|
1297
|
+
|
|
741
1298
|
async function waitForSubmittedMessage(
|
|
742
1299
|
tabId: number,
|
|
743
1300
|
fingerprint: string,
|
|
@@ -771,6 +1328,14 @@ function text(value: unknown, name: string): string {
|
|
|
771
1328
|
}
|
|
772
1329
|
|
|
773
1330
|
async function executeCommand(command: BridgeCommand): Promise<unknown> {
|
|
1331
|
+
if (command.type === "CARRIER_ATTENTION_ACTION") {
|
|
1332
|
+
if (command.action !== "allowOnce" && command.action !== "deny")
|
|
1333
|
+
throw new Error("ATTENTION_ACTION_INVALID");
|
|
1334
|
+
return decideCarrierAttention(
|
|
1335
|
+
text(command.attentionRef, "ATTENTION_REF"),
|
|
1336
|
+
command.action,
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
774
1339
|
if (command.type === "LIST_TABS") {
|
|
775
1340
|
const tabs = await chrome.tabs.query({ url: "https://chatgpt.com/g/*" });
|
|
776
1341
|
return tabs
|
|
@@ -1008,6 +1573,7 @@ async function runBridgeLoop() {
|
|
|
1008
1573
|
}),
|
|
1009
1574
|
});
|
|
1010
1575
|
if (!hello.ok) throw new Error("BRIDGE_HELLO_REJECTED");
|
|
1576
|
+
await publishCarrierAttentions();
|
|
1011
1577
|
// Hello only establishes a session. The first inner-loop action must be
|
|
1012
1578
|
// a real command poll so Runtime readiness cannot be granted by hello alone.
|
|
1013
1579
|
let lastHeartbeatAt = Date.now();
|
|
@@ -1236,16 +1802,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
|
1236
1802
|
sender.tab?.id !== undefined &&
|
|
1237
1803
|
sender.tab.windowId !== undefined
|
|
1238
1804
|
) {
|
|
1239
|
-
const previous = sessions.get(sender.tab.id);
|
|
1240
1805
|
const observed: ContentObservation = {
|
|
1241
1806
|
...message.observation,
|
|
1242
1807
|
tabId: sender.tab.id,
|
|
1243
1808
|
windowId: sender.tab.windowId,
|
|
1244
1809
|
};
|
|
1245
|
-
|
|
1246
|
-
void persistSnapshot();
|
|
1247
|
-
if (shouldTriggerObserverRecovery(previous, observed))
|
|
1248
|
-
void runObserverRecovery();
|
|
1810
|
+
processContentObservation(observed, true);
|
|
1249
1811
|
sendResponse({ accepted: true, extensionInstanceId });
|
|
1250
1812
|
return;
|
|
1251
1813
|
}
|
|
@@ -1259,6 +1821,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
|
1259
1821
|
extensionInstanceId,
|
|
1260
1822
|
observedAt: new Date().toISOString(),
|
|
1261
1823
|
sessions: [...sessions.values()],
|
|
1824
|
+
carrierAttentions: carrierAttentionViews(),
|
|
1262
1825
|
taskApplicationConfigured: application !== null,
|
|
1263
1826
|
approvalApplicationConfigured: approval !== null,
|
|
1264
1827
|
systemObserver: observerState
|
|
@@ -1276,6 +1839,33 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
|
1276
1839
|
);
|
|
1277
1840
|
return true;
|
|
1278
1841
|
}
|
|
1842
|
+
if (message.type === "PROFLOW_CARRIER_ATTENTION_ACTION") {
|
|
1843
|
+
if (!message.input) {
|
|
1844
|
+
sendResponse({ ok: false, error: "CARRIER_ATTENTION_MESSAGE_INVALID" });
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
const attentionRef = message.input.attentionRef;
|
|
1848
|
+
const action = message.input.action;
|
|
1849
|
+
if (
|
|
1850
|
+
typeof attentionRef !== "string" ||
|
|
1851
|
+
(action !== "allowOnce" && action !== "deny")
|
|
1852
|
+
) {
|
|
1853
|
+
sendResponse({ ok: false, error: "CARRIER_ATTENTION_MESSAGE_INVALID" });
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
void decideCarrierAttention(attentionRef, action).then(
|
|
1857
|
+
(value) => sendResponse({ ok: true, value }),
|
|
1858
|
+
(error: unknown) =>
|
|
1859
|
+
sendResponse({
|
|
1860
|
+
ok: false,
|
|
1861
|
+
error:
|
|
1862
|
+
error instanceof Error
|
|
1863
|
+
? error.message
|
|
1864
|
+
: "CARRIER_ATTENTION_ACTION_FAILED",
|
|
1865
|
+
}),
|
|
1866
|
+
);
|
|
1867
|
+
return true;
|
|
1868
|
+
}
|
|
1279
1869
|
if (message.type === "PROFLOW_APPROVAL_APPLICATION") {
|
|
1280
1870
|
if (typeof message.operation !== "string" || !message.input) {
|
|
1281
1871
|
sendResponse({
|
|
@@ -1352,9 +1942,15 @@ async function openTaskPage(): Promise<void> {
|
|
|
1352
1942
|
body.url.startsWith(`${bridge.endpoint}/tasks/bootstrap/`)
|
|
1353
1943
|
) {
|
|
1354
1944
|
const webUrl = `${bridge.endpoint}/tasks`;
|
|
1355
|
-
const [existing] = await chrome.tabs.query({
|
|
1945
|
+
const [existing] = await chrome.tabs.query({
|
|
1946
|
+
url: webUrl,
|
|
1947
|
+
currentWindow: true,
|
|
1948
|
+
});
|
|
1356
1949
|
if (existing?.id !== undefined) {
|
|
1357
|
-
await chrome.tabs.update(existing.id, {
|
|
1950
|
+
await chrome.tabs.update(existing.id, {
|
|
1951
|
+
url: body.url,
|
|
1952
|
+
active: true,
|
|
1953
|
+
});
|
|
1358
1954
|
} else {
|
|
1359
1955
|
await chrome.tabs.create({ url: body.url, active: true });
|
|
1360
1956
|
}
|
|
@@ -1377,10 +1973,13 @@ async function openTaskPage(): Promise<void> {
|
|
|
1377
1973
|
}
|
|
1378
1974
|
|
|
1379
1975
|
async function startBackgroundRuntime(): Promise<void> {
|
|
1976
|
+
await restoreTransientPermissionAttempts();
|
|
1380
1977
|
await bootstrapManagedRuntimeConfig();
|
|
1381
1978
|
await persistSnapshot();
|
|
1382
1979
|
void runBridgeLoop();
|
|
1383
1980
|
void runProvisioningBridgeLoop();
|
|
1981
|
+
const suppressedContinuations = await rebuildCarrierAttentionsFromTabs();
|
|
1982
|
+
suppressNextObserverRecovery(suppressedContinuations);
|
|
1384
1983
|
void runObserverRecovery();
|
|
1385
1984
|
}
|
|
1386
1985
|
|
|
@@ -1388,17 +1987,23 @@ chrome.action.onClicked.addListener(() => {
|
|
|
1388
1987
|
void openTaskPage();
|
|
1389
1988
|
});
|
|
1390
1989
|
chrome.runtime.onInstalled.addListener(() => {
|
|
1391
|
-
void
|
|
1990
|
+
void restoreTransientPermissionAttempts().then(async () => {
|
|
1991
|
+
await bootstrapManagedRuntimeConfig();
|
|
1392
1992
|
await persistSnapshot();
|
|
1393
1993
|
void runBridgeLoop();
|
|
1994
|
+
const suppressedContinuations = await rebuildCarrierAttentionsFromTabs();
|
|
1995
|
+
suppressNextObserverRecovery(suppressedContinuations);
|
|
1394
1996
|
void runObserverRecovery();
|
|
1395
1997
|
});
|
|
1396
1998
|
});
|
|
1397
1999
|
chrome.runtime.onStartup.addListener(() => {
|
|
1398
2000
|
sessions.clear();
|
|
1399
|
-
void
|
|
2001
|
+
void restoreTransientPermissionAttempts().then(async () => {
|
|
2002
|
+
await bootstrapManagedRuntimeConfig();
|
|
1400
2003
|
await persistSnapshot();
|
|
1401
2004
|
void runBridgeLoop();
|
|
2005
|
+
const suppressedContinuations = await rebuildCarrierAttentionsFromTabs();
|
|
2006
|
+
suppressNextObserverRecovery(suppressedContinuations);
|
|
1402
2007
|
void runObserverRecovery();
|
|
1403
2008
|
});
|
|
1404
2009
|
});
|