@effect-agent/platform-cloudflare 0.1.0-beta.52 → 0.1.0-beta.53

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.
@@ -5,41 +5,66 @@ export const maxAttributeLength = 2048;
5
5
  export const inspectFrame = `(() => {
6
6
  const doc = document;
7
7
  const forms = [];
8
- const elements = [...doc.querySelectorAll('input,select,button,a[href]')].slice(0, 65);
8
+ const isChoice = el => el instanceof HTMLInputElement && ['radio','checkbox'].includes(el.type);
9
+ const presented = el => {
10
+ if (el.closest('[hidden],[aria-hidden="true"],[inert]') ||
11
+ ['hidden','collapse'].includes(getComputedStyle(el).visibility)) return false;
12
+ for (let parent = el; parent; parent = parent.parentElement) {
13
+ if (getComputedStyle(parent).opacity === '0') return false;
14
+ }
15
+ return true;
16
+ };
17
+ const hasLayout = el => presented(el) &&
18
+ [...el.getClientRects()].some(rect => rect.width > 0 && rect.height > 0);
19
+ const available = el => el.type !== 'hidden' && !el.closest('[hidden],[aria-hidden="true"],[inert]') &&
20
+ (hasLayout(el) || (isChoice(el) && [...(el.labels ?? [])].some(hasLayout)));
21
+ const elements = [...doc.querySelectorAll('input,textarea,select,button,a[href]')].filter(available).slice(0, 65);
22
+ const labelText = el => {
23
+ const label = el.labels?.[0]?.cloneNode(true);
24
+ label?.querySelectorAll('input,textarea,select,script,style,noscript').forEach(child => child.remove());
25
+ return label?.textContent ?? el.getAttribute('aria-label');
26
+ };
9
27
  const describe = (el) => {
10
- if (doc !== document || !el.isConnected || el.ownerDocument !== doc) return null;
28
+ if (doc !== document || !el.isConnected || el.ownerDocument !== doc || !available(el)) return null;
11
29
  const form = el.form ?? null;
12
30
  let formIndex = forms.indexOf(form);
13
31
  if (formIndex < 0) { formIndex = forms.length; forms.push(form); }
14
- const action = form ? (el.hasAttribute('formaction') ? el.formAction : form.action || doc.URL) : doc.URL;
32
+ // Resolved href (including path/query/fragment and base-URL changes) is part of the fingerprint.
33
+ const action = el instanceof HTMLAnchorElement ? el.href : form ? (el.hasAttribute('formaction') ? el.formAction : form.action || doc.URL) : doc.URL;
15
34
  const method = form ? (el.hasAttribute('formmethod') ? el.formMethod : form.method) : '';
16
35
  const enctype = form?.enctype ?? '';
17
36
  const name = el.name ?? '';
18
37
  const completion = el.getAttribute('autocomplete') ?? '';
19
38
  const inputType = el.type ?? '';
39
+ const choiceValue = isChoice(el) ? el.value : '';
20
40
  // Reject before parsing, fingerprinting or CDP transfer. Truncation could hide a target change.
21
- if ([action, method, enctype, name, completion, inputType].some(value => value.length > ${maxAttributeLength})) return null;
41
+ if ([action, method, enctype, name, completion, inputType, choiceValue].some(value => value.length > ${maxAttributeLength})) return null;
22
42
  const type = inputType.toLowerCase();
23
43
  const autocomplete = completion.trim().toLowerCase().split(/\\s+/).at(-1);
24
44
  const cardRoles = { 'cc-name':'card-name', 'cc-number':'card-number', 'cc-exp':'card-expiry',
25
45
  'cc-exp-month':'card-expiry-month', 'cc-exp-year':'card-expiry-year', 'cc-csc':'card-security-code' };
26
46
  let role = 'unsupported';
27
- const nativeField = el instanceof HTMLInputElement || el instanceof HTMLSelectElement;
28
- if (nativeField && form && !['submit','button'].includes(type) && !el.disabled && !el.readOnly && el.getClientRects().length > 0) {
29
- if (el instanceof HTMLInputElement && type === 'password') role = 'password';
30
- else if (['text','email','tel','number','month',''].includes(type) || el instanceof HTMLSelectElement) {
31
- if (cardRoles[autocomplete]) role = cardRoles[autocomplete];
32
- else if (autocomplete === 'username' || autocomplete === 'email') role = 'username';
47
+ const nativeField = el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement;
48
+ if (nativeField && !['submit','button'].includes(type) && !el.matches(':disabled') && !el.readOnly && !(el instanceof HTMLSelectElement && el.multiple)) {
49
+ if (isChoice(el)) role = type;
50
+ else if (type === 'password' || ['current-password','new-password','one-time-code'].includes(autocomplete)) {
51
+ if (type === 'password' && form) role = 'password';
52
+ } else if (['text','email','tel','number','month','search','url','date','time','week','datetime-local','textarea',''].includes(type) || el instanceof HTMLSelectElement) {
53
+ if (cardRoles[autocomplete]) { if (form) role = cardRoles[autocomplete]; }
54
+ else if (autocomplete === 'username' || autocomplete === 'email') { if (form) role = 'username'; }
33
55
  else if (['text','email'].includes(type) && form && form.querySelector('input[type="password"]')) role = 'username';
56
+ else if (el instanceof HTMLSelectElement) { if (!el.multiple) role = 'select'; }
57
+ else role = 'text';
34
58
  }
35
59
  } else if (el instanceof HTMLButtonElement || (el instanceof HTMLInputElement && ['submit','button'].includes(type))) {
36
- if (!el.disabled) role = type === 'submit' && form ? 'submit' : type === 'button' ? 'button' : 'unsupported';
60
+ if (!el.matches(':disabled')) role = type === 'submit' && form ? 'submit' : type === 'button' ? 'button' : 'unsupported';
37
61
  } else if (el instanceof HTMLAnchorElement) role = 'link';
38
- const fingerprint = JSON.stringify([role, action, method, enctype, name, completion, type]);
39
- return { role, formIndex, action, fingerprint,
40
- label: (el.labels?.[0]?.textContent ?? el.getAttribute('aria-label') ?? el.textContent ?? '').slice(0,200) };
62
+ const fingerprint = JSON.stringify([role, action, method, enctype, name, completion, type, choiceValue]);
63
+ return { role, formIndex, action, fingerprint, ...(isChoice(el) ? {checked: el.checked} : {}),
64
+ label: (labelText(el) ?? (nativeField ? '' : el.textContent) ?? '').slice(0,200) };
41
65
  };
42
- const expose = ({role, formIndex, action, label}) => ({role, formIndex, action, label});
66
+ const expose = ({role, formIndex, action, label, checked}) =>
67
+ ({role, formIndex, action, label, ...(checked === undefined ? {} : {checked})});
43
68
  const original = elements.map(describe);
44
69
  const validate = (index) => {
45
70
  const current = describe(elements[index]);
@@ -51,14 +76,32 @@ export const inspectFrame = `(() => {
51
76
  doc, elements, original: original.map(current => current && expose(current)), validate,
52
77
  text: () => {
53
78
  if (doc !== document) return null;
54
- const clone = doc.body?.cloneNode(true);
55
- clone?.querySelectorAll('input,textarea,select,script,style,noscript,iframe,object,embed').forEach(el => el.remove());
56
- return (clone?.textContent ?? '').slice(0,65536);
79
+ if (!doc.body) return '';
80
+ const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
81
+ const range = doc.createRange();
82
+ let text = '';
83
+ for (let node = walker.nextNode(); node && text.length < 65536; node = walker.nextNode()) {
84
+ const parent = node.parentElement;
85
+ if (!parent || parent.closest('input,textarea,select,script,style,noscript,iframe,object,embed')) continue;
86
+ if (!presented(parent)) continue;
87
+ range.selectNodeContents(node);
88
+ if (![...range.getClientRects()].some(rect => rect.width > 0 && rect.height > 0)) continue;
89
+ text += (node.textContent ?? '').replace(/\\s+/g, ' ') + ' ';
90
+ }
91
+ return text.slice(0,65536);
57
92
  },
58
93
  fill: (index, role, value) => {
59
94
  const current = validate(index);
60
95
  if (!current || current.role !== role) return false;
61
96
  const el = elements[index];
97
+ if (role === 'select') {
98
+ const options = [...el.options].filter(option => !option.matches(':disabled'));
99
+ const values = options.filter(option => option.value === value);
100
+ const matches = values.length > 0 ? values : options.filter(option => option.textContent?.trim() === value);
101
+ if (matches.length !== 1) return 'unsupported';
102
+ value = matches[0].value;
103
+ if ([...el.options].filter(option => option.value === value).length !== 1) return 'unsupported';
104
+ }
62
105
  let prototype = Object.getPrototypeOf(el);
63
106
  let setter;
64
107
  while (prototype && !setter) { setter = Object.getOwnPropertyDescriptor(prototype,'value')?.set; prototype = Object.getPrototypeOf(prototype); }
@@ -73,7 +116,7 @@ export const inspectFrame = `(() => {
73
116
  },
74
117
  click: (index) => {
75
118
  const current = validate(index);
76
- if (!current || !['button','submit','link'].includes(current.role)) return false;
119
+ if (!current || !['button','submit','link','radio','checkbox'].includes(current.role)) return false;
77
120
  if (current.role === 'submit' && elements[index].form && !elements[index].form.matches(':valid')) return 'needs-attention';
78
121
  elements[index].click();
79
122
  return true;
@@ -30,6 +30,7 @@ const Description = Schema.Struct({
30
30
  formIndex: Schema.Natural,
31
31
  action: Schema.String.check(Schema.isMaxLength(maxAttributeLength)),
32
32
  label: Schema.String.check(Schema.isMaxLength(200)),
33
+ checked: Schema.optionalKey(Schema.Boolean),
33
34
  });
34
35
 
35
36
  const Descriptions = Schema.Array(Schema.NullOr(Description)).check(Schema.isMaxLength(65));
@@ -97,6 +98,7 @@ export const makeProtectedNativeTransport = Effect.fn("ProtectedNativeTransport.
97
98
  let closed = false;
98
99
  let violation = false;
99
100
  let documentRef: string | undefined;
101
+ let observationOrigins: ReadonlySet<string> | undefined;
100
102
  const frames = new Map<Frame, FrameState>();
101
103
  const controls = new Map<string, ControlState>();
102
104
 
@@ -144,13 +146,30 @@ export const makeProtectedNativeTransport = Effect.fn("ProtectedNativeTransport.
144
146
  clear();
145
147
  };
146
148
 
147
- const context = Effect.gen(function* () {
148
- yield* check;
149
+ const observationFrames = Effect.gen(function* () {
149
150
  const list = page.frames();
150
151
 
151
- if (list.length > 16) return yield* transportError("unsupported");
152
+ if (list.length > 32) return yield* transportError("unsupported");
153
+
154
+ // Opaque/blank child documents have no observation channel or credential targets.
155
+ // The main document and every retained HTTPS child still require strict origin validation.
156
+ return yield* Effect.filter(list, (frame) =>
157
+ frame === page.mainFrame()
158
+ ? Effect.succeed(true)
159
+ : Effect.try({
160
+ try: () => frame.url() !== "" && new URL(frame.url()).protocol === "https:",
161
+ catch: () => transportError("provider"),
162
+ }),
163
+ );
164
+ });
165
+
166
+ const context = Effect.gen(function* () {
167
+ yield* check;
168
+ const list = yield* observationFrames;
152
169
  const topOrigin = yield* origin(page.url());
153
- const frameOrigins = yield* Effect.forEach(list, (frame) => origin(frame.url()));
170
+ const frameOrigins = [...new Set(yield* Effect.forEach(list, (frame) => origin(frame.url())))];
171
+
172
+ if (frameOrigins.length > 16) return yield* transportError("unsupported");
154
173
 
155
174
  if (documentRef === undefined) documentRef = yield* uuid;
156
175
 
@@ -175,6 +194,8 @@ export const makeProtectedNativeTransport = Effect.fn("ProtectedNativeTransport.
175
194
 
176
195
  if (
177
196
  !state ||
197
+ (observationOrigins !== undefined &&
198
+ !observationOrigins.has(state.control.target.frameOrigin)) ||
178
199
  state.expires <= (yield* clock.currentTimeMillis) ||
179
200
  !(yield* isCurrent(state.frame))
180
201
  )
@@ -232,6 +253,22 @@ export const makeProtectedNativeTransport = Effect.fn("ProtectedNativeTransport.
232
253
  );
233
254
 
234
255
  return {
256
+ restrictObservation: Effect.fn("ProtectedNativeTransport.restrictObservation")(
257
+ function* (origins) {
258
+ yield* check;
259
+ observationOrigins =
260
+ origins === undefined
261
+ ? undefined
262
+ : new Set(yield* decode(Schema.Array(CredentialOrigin), origins));
263
+ // A later grant expansion must not revive previously excluded references.
264
+ for (const [ref, state] of controls)
265
+ if (
266
+ observationOrigins !== undefined &&
267
+ !observationOrigins.has(state.control.target.frameOrigin)
268
+ )
269
+ controls.delete(ref);
270
+ },
271
+ ),
235
272
  context,
236
273
  invalidate,
237
274
  close,
@@ -252,8 +289,13 @@ export const makeProtectedNativeTransport = Effect.fn("ProtectedNativeTransport.
252
289
  const discovered: Array<ProtectedBrowserControl> = [];
253
290
  let text = "";
254
291
  let truncated = false;
292
+ const frameOrigins = new Set<string>();
293
+
294
+ for (const frame of yield* observationFrames) {
295
+ const frameOrigin = yield* origin(frame.url());
255
296
 
256
- for (const frame of page.frames()) {
297
+ if (observationOrigins !== undefined && !observationOrigins.has(frameOrigin)) continue;
298
+ frameOrigins.add(frameOrigin);
257
299
  if (typeof frame.isolatedRealm !== "function") return yield* transportError("unsupported");
258
300
  const handle = yield* remote(() => frame.isolatedRealm().evaluateHandle(inspectFrame));
259
301
 
@@ -300,6 +342,8 @@ export const makeProtectedNativeTransport = Effect.fn("ProtectedNativeTransport.
300
342
  ref: yield* uuid,
301
343
  role: desc.role,
302
344
  label: desc.label,
345
+ ...(desc.checked === undefined ? {} : { checked: desc.checked }),
346
+ ...(desc.role === "link" ? { url: desc.action } : {}),
303
347
  target: CredentialTarget.make({
304
348
  topOrigin: before.topOrigin,
305
349
  frameOrigin: yield* origin(frame.url()),
@@ -338,6 +382,7 @@ export const makeProtectedNativeTransport = Effect.fn("ProtectedNativeTransport.
338
382
 
339
383
  return yield* decode(ProtectedDiscovery, {
340
384
  ...before,
385
+ frameOrigins: [...frameOrigins],
341
386
  text,
342
387
  controls: discovered,
343
388
  truncated,
@@ -7,6 +7,7 @@ import {
7
7
  CredentialOffer,
8
8
  CredentialOfferMetadata,
9
9
  CredentialOrigin,
10
+ CredentialObservationDecision,
10
11
  type CredentialTarget,
11
12
  CredentialUseResult,
12
13
  ListCredentialOffers,
@@ -14,6 +15,7 @@ import {
14
15
  ProtectedBrowser,
15
16
  ProtectedBrowserClick,
16
17
  ProtectedBrowserError,
18
+ ProtectedBrowserFill,
17
19
  ProtectedBrowserNavigate,
18
20
  ProtectedBrowserObservation,
19
21
  ProtectedBrowserControl,
@@ -22,6 +24,7 @@ import {
22
24
  type CredentialKind,
23
25
  type CredentialUseAuthorization,
24
26
  type ProtectedBrowserHandle,
27
+ type ProtectedBrowserAction,
25
28
  type ProtectedCleanup,
26
29
  type ProtectedObservationState,
27
30
  } from "@effect-agent/sandbox/ProtectedBrowser";
@@ -51,6 +54,10 @@ export class ProtectedBrowserDispatch extends Context.Service<
51
54
 
52
55
  /** Decoded adapter boundary. SDK exceptions and page diagnostics never cross this port. */
53
56
  export interface ProtectedBrowserTransport {
57
+ /** Select observation/target origins; undefined restores all network-permitted HTTPS frames. */
58
+ readonly restrictObservation: (
59
+ origins: ReadonlyArray<typeof CredentialOrigin.Type> | undefined,
60
+ ) => Effect.Effect<void, ProtectedTransportError>;
54
61
  readonly context: Effect.Effect<typeof ProtectedPageContext.Type, ProtectedTransportError>;
55
62
  readonly discover: Effect.Effect<typeof ProtectedDiscovery.Type, ProtectedTransportError>;
56
63
  readonly target: (ref: string) => Effect.Effect<ProtectedBrowserControl, ProtectedTransportError>;
@@ -58,7 +65,7 @@ export interface ProtectedBrowserTransport {
58
65
  readonly click: (ref: string) => Effect.Effect<void, ProtectedTransportError>;
59
66
  readonly fill: (
60
67
  ref: string,
61
- role: typeof CredentialFieldRole.Type,
68
+ role: typeof CredentialFieldRole.Type | "text" | "select",
62
69
  value: Redacted.Redacted<string>,
63
70
  ) => Effect.Effect<void, ProtectedTransportError, ProtectedBrowserDispatch>;
64
71
  /** Invalidates local references synchronously before bounded exact-session cleanup. */
@@ -133,7 +140,7 @@ const secretFor = (material: BrowserCredentialMaterial, role: typeof CredentialF
133
140
 
134
141
  /**
135
142
  * Fresh private passes only. Account administrators and Browser Rendering token holders are
136
- * trusted operators. No viewer, handoff, raw JavaScript, screenshot or plaintext-fill API exists.
143
+ * trusted operators. No viewer, handoff, raw JavaScript, screenshot or plaintext credential API exists.
137
144
  * Hosts explicitly authorize post-exposure observations for recipients they trust not to echo.
138
145
  */
139
146
  export const browserRunProtectedLayer = () =>
@@ -240,24 +247,69 @@ export const browserRunProtectedLayer = () =>
240
247
 
241
248
  const permitObservation = Effect.gen(function* () {
242
249
  const context = yield* pageContext;
250
+ let origins: ReadonlyArray<typeof CredentialOrigin.Type> | undefined;
243
251
 
244
252
  if (exposures.length > 0) {
245
253
  observation = "protected";
254
+ const principal = yield* caller;
246
255
 
247
- const decision = yield* access
248
- .observation({ ...context, caller: yield* caller, exposures: [...exposures] })
256
+ const rawDecision = yield* access
257
+ .observation({ ...context, caller: principal, exposures: [...exposures] })
249
258
  .pipe(Effect.mapError((error) => fail(error.reason)));
250
259
 
251
- if (decision !== "trust-recipient-no-credential-echo")
252
- return yield* fail("observation-blocked");
260
+ if (Redacted.value(yield* caller) !== Redacted.value(principal))
261
+ return yield* fail("denied");
262
+
263
+ const decision = yield* Schema.decodeUnknownEffect(CredentialObservationDecision)(
264
+ rawDecision,
265
+ ).pipe(Effect.mapError(() => fail("observation-blocked")));
266
+
267
+ if (decision === "deny") return yield* fail("observation-blocked");
268
+ if (typeof decision !== "string") {
269
+ origins = [...decision.origins];
270
+ if (!origins.includes(context.topOrigin)) return yield* fail("observation-blocked");
271
+ }
253
272
  observation = "approved-after-exposure";
254
273
  }
274
+ yield* remote(driver.restrictObservation(origins));
255
275
 
256
- return context;
276
+ return {
277
+ ...context,
278
+ frameOrigins: context.frameOrigins.filter(
279
+ (origin) => origins === undefined || origins.includes(origin),
280
+ ),
281
+ };
257
282
  });
258
283
 
259
284
  const target = (ref: string) => remote(driver.target(ref));
260
285
 
286
+ const authorizeAction = Effect.fn("ProtectedBrowser.authorizeAction")(function* (
287
+ action: ProtectedBrowserAction,
288
+ ) {
289
+ if (access.authorizeAction === undefined) {
290
+ if (action._tag === "Submit") return yield* fail("unsupported");
291
+
292
+ return;
293
+ }
294
+ const principal = yield* caller;
295
+
296
+ yield* access
297
+ .authorizeAction({ caller: principal, action, exposures: [...exposures] })
298
+ .pipe(Effect.mapError((error) => fail(error.reason)));
299
+ if (Redacted.value(yield* caller) !== Redacted.value(principal))
300
+ return yield* fail("denied");
301
+ if (action._tag !== "Navigate") {
302
+ const current = yield* target(action.ref);
303
+
304
+ if (
305
+ !sameTarget(current.target, action.target) ||
306
+ current.role !== (action._tag === "Submit" ? "submit" : action.role) ||
307
+ (action._tag === "Click" && action.role === "link" && current.url !== action.url)
308
+ )
309
+ return yield* fail("stale-reference");
310
+ }
311
+ }, Effect.withTracerEnabled(false));
312
+
261
313
  const bounded = <A>(result: A) => {
262
314
  const bytes = new TextEncoder().encode(JSON.stringify(result)).byteLength;
263
315
 
@@ -366,6 +418,7 @@ export const browserRunProtectedLayer = () =>
366
418
  )
367
419
  return yield* fail("denied");
368
420
  if (exposures.length > 0) yield* permitObservation;
421
+ yield* authorizeAction({ _tag: "Navigate", url: decoded.url });
369
422
  offers.clear();
370
423
  dispatch = "possibly-dispatched";
371
424
  yield* remote(driver.navigate(decoded.url));
@@ -379,8 +432,14 @@ export const browserRunProtectedLayer = () =>
379
432
  const result = yield* remote(driver.discover);
380
433
  const after = yield* permitObservation;
381
434
 
382
- if (before.document !== after.document || result.document !== after.document)
435
+ if (
436
+ before.document !== after.document ||
437
+ result.document !== after.document ||
438
+ result.topOrigin !== after.topOrigin
439
+ )
383
440
  return yield* fail("stale-reference");
441
+ if (result.frameOrigins.some((origin) => !after.frameOrigins.includes(origin)))
442
+ return yield* fail("observation-blocked");
384
443
 
385
444
  return yield* bounded(
386
445
  ProtectedBrowserObservation.make({
@@ -400,12 +459,78 @@ export const browserRunProtectedLayer = () =>
400
459
  yield* permitObservation;
401
460
  const control = yield* target(decoded.ref);
402
461
 
403
- // Credential submission goes through useCredential, never a generic click.
404
- if (control.role !== "link" && control.role !== "button")
462
+ if (
463
+ control.role !== "link" &&
464
+ control.role !== "button" &&
465
+ control.role !== "radio" &&
466
+ control.role !== "checkbox" &&
467
+ control.role !== "submit"
468
+ )
405
469
  return yield* fail("unsupported");
470
+ let action: ProtectedBrowserAction;
471
+
472
+ if (control.role === "link") {
473
+ if (control.url === undefined) return yield* fail("unsupported");
474
+ action = {
475
+ _tag: "Click",
476
+ ref: decoded.ref,
477
+ target: control.target,
478
+ role: "link",
479
+ url: control.url,
480
+ };
481
+ } else if (control.role === "submit") {
482
+ action = { _tag: "Submit", ref: decoded.ref, target: control.target };
483
+ } else {
484
+ action = {
485
+ _tag: "Click",
486
+ ref: decoded.ref,
487
+ target: control.target,
488
+ role: control.role,
489
+ };
490
+ }
491
+ yield* authorizeAction(action);
406
492
  dispatch = "possibly-dispatched";
407
- yield* remote(driver.click(decoded.ref));
493
+ yield* remote(driver.click(decoded.ref)).pipe(
494
+ Effect.catch((error) => {
495
+ if (error.reason === "needs-attention") dispatch = "not-dispatched";
496
+
497
+ return Effect.fail(error);
498
+ }),
499
+ );
500
+ dispatch = "dispatched";
501
+ if (control.role === "submit") milestone = "submission-dispatched";
502
+ yield* permitObservation;
503
+ }),
504
+ ),
505
+ fill: (request) =>
506
+ run(
507
+ Effect.gen(function* () {
508
+ const decoded = yield* Schema.decodeUnknownEffect(ProtectedBrowserFill)(
509
+ request,
510
+ ).pipe(Effect.mapError(() => fail("denied")));
511
+
512
+ yield* permitObservation;
513
+ const control = yield* target(decoded.ref);
514
+
515
+ if (control.role !== "text" && control.role !== "select")
516
+ return yield* fail("unsupported");
517
+ yield* authorizeAction({
518
+ _tag: "Fill",
519
+ ref: decoded.ref,
520
+ target: control.target,
521
+ role: control.role,
522
+ });
523
+ yield* remote(
524
+ driver.fill(decoded.ref, control.role, Redacted.make(decoded.value)),
525
+ ).pipe(
526
+ Effect.provideService(ProtectedBrowserDispatch, {
527
+ mark: Effect.sync(() => {
528
+ dispatch = "possibly-dispatched";
529
+ }),
530
+ }),
531
+ );
408
532
  dispatch = "dispatched";
533
+ milestone = "filled";
409
534
  yield* permitObservation;
410
535
  }),
411
536
  ),
@@ -475,8 +600,6 @@ export const browserRunProtectedLayer = () =>
475
600
 
476
601
  if (Redacted.value(principal) !== Redacted.value(offer.caller))
477
602
  return yield* fail("denied");
478
- if (offer.kind === "card" && decoded.submit !== undefined)
479
- return yield* fail("unsupported");
480
603
  if (
481
604
  new Set(decoded.fields.map((field) => field.ref)).size !==
482
605
  decoded.fields.length ||
@@ -522,6 +645,8 @@ export const browserRunProtectedLayer = () =>
522
645
  yield* access
523
646
  .authorize(authorization)
524
647
  .pipe(Effect.mapError((error) => fail(error.reason)));
648
+ if (Redacted.value(yield* caller) !== Redacted.value(principal))
649
+ return yield* fail("denied");
525
650
  });
526
651
 
527
652
  yield* authorize;